From a350c82893fce7a894983fba0497d0878fc9f9b3 Mon Sep 17 00:00:00 2001 From: Smittix Date: Tue, 24 Feb 2026 08:46:17 +0000 Subject: [PATCH 01/52] Use monotonic audio start tokens across page reloads --- static/js/modes/waterfall.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/static/js/modes/waterfall.js b/static/js/modes/waterfall.js index 7851144..c98de8f 100644 --- a/static/js/modes/waterfall.js +++ b/static/js/modes/waterfall.js @@ -2784,6 +2784,9 @@ const Waterfall = (function () { let monitorDevice = altDevice || selectedDevice; const biasT = !!document.getElementById('wfBiasT')?.checked; const usingSecondaryDevice = !!altDevice; + // Use a high monotonic token so backend start ordering remains + // valid across page reloads (local nonces reset to small values). + const requestToken = Math.trunc((Date.now() * 4096) + (nonce & 0x0fff)); if (!retuneOnly) { _monitorFreqMhz = centerMhz; @@ -2814,7 +2817,7 @@ const Waterfall = (function () { gain, device: monitorDevice, biasT, - requestToken: nonce, + requestToken, }); if (nonce !== _audioConnectNonce) return; @@ -2839,7 +2842,7 @@ const Waterfall = (function () { gain, device: monitorDevice, biasT, - requestToken: nonce, + requestToken, })); if (nonce !== _audioConnectNonce) return; if (payload?.superseded === true || payload?.status === 'stale') return; From 9cd7f1c0c8f62cc8a8f405421bb4c6f9a0a3ac00 Mon Sep 17 00:00:00 2001 From: Smittix Date: Tue, 24 Feb 2026 08:55:32 +0000 Subject: [PATCH 02/52] Snapshot audio tune config when spawning demod process --- routes/listening_post.py | 128 +++++++++++++++++++++++---------------- 1 file changed, 76 insertions(+), 52 deletions(-) diff --git a/routes/listening_post.py b/routes/listening_post.py index 17cdd22..cded266 100644 --- a/routes/listening_post.py +++ b/routes/listening_post.py @@ -665,25 +665,41 @@ def scanner_loop_power(): logger.info("Power sweep scanner thread stopped") -def _start_audio_stream(frequency: float, modulation: str): - """Start audio streaming at given frequency.""" - global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_modulation - - with audio_lock: - # Stop any existing stream - _stop_audio_stream_internal() +def _start_audio_stream( + frequency: float, + modulation: str, + *, + device: int | None = None, + sdr_type: str | None = None, + gain: int | None = None, + squelch: int | None = None, + bias_t: bool | None = None, +): + """Start audio streaming at given frequency.""" + global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_modulation + + with audio_lock: + # Stop any existing stream + _stop_audio_stream_internal() ffmpeg_path = find_ffmpeg() if not ffmpeg_path: logger.error("ffmpeg not found") return - # Determine SDR type and build appropriate command - sdr_type_str = scanner_config.get('sdr_type', 'rtlsdr') - try: - sdr_type = SDRType(sdr_type_str) - except ValueError: - sdr_type = SDRType.RTL_SDR + # Snapshot runtime tuning config so the spawned demod command cannot + # drift if shared scanner_config changes while startup is in-flight. + device_index = int(device if device is not None else scanner_config.get('device', 0)) + gain_value = int(gain if gain is not None else scanner_config.get('gain', 40)) + squelch_value = int(squelch if squelch is not None else scanner_config.get('squelch', 0)) + bias_t_enabled = bool(scanner_config.get('bias_t', False) if bias_t is None else bias_t) + sdr_type_str = str(sdr_type if sdr_type is not None else scanner_config.get('sdr_type', 'rtlsdr')).lower() + + # Determine SDR type and build appropriate command + try: + sdr_type = SDRType(sdr_type_str) + except ValueError: + sdr_type = SDRType.RTL_SDR # Set sample rates based on modulation if modulation == 'wfm': @@ -707,41 +723,41 @@ def _start_audio_stream(frequency: float, modulation: str): freq_hz = int(frequency * 1e6) sdr_cmd = [ rtl_fm_path, - '-M', _rtl_fm_demod_mode(modulation), - '-f', str(freq_hz), - '-s', str(sample_rate), - '-r', str(resample_rate), - '-g', str(scanner_config['gain']), - '-d', str(scanner_config['device']), - '-l', str(scanner_config['squelch']), - ] - if scanner_config.get('bias_t', False): - sdr_cmd.append('-T') - # Omit explicit filename: rtl_fm defaults to stdout. - # (Some builds intermittently stall when '-' is passed explicitly.) - else: - # Use SDR abstraction layer for HackRF, Airspy, LimeSDR, SDRPlay + '-M', _rtl_fm_demod_mode(modulation), + '-f', str(freq_hz), + '-s', str(sample_rate), + '-r', str(resample_rate), + '-g', str(gain_value), + '-d', str(device_index), + '-l', str(squelch_value), + ] + if bias_t_enabled: + sdr_cmd.append('-T') + # Omit explicit filename: rtl_fm defaults to stdout. + # (Some builds intermittently stall when '-' is passed explicitly.) + else: + # Use SDR abstraction layer for HackRF, Airspy, LimeSDR, SDRPlay rx_fm_path = find_rx_fm() if not rx_fm_path: - logger.error(f"rx_fm not found - required for {sdr_type.value}. Install SoapySDR utilities.") - return - - # Create device and get command builder - device = SDRFactory.create_default_device(sdr_type, index=scanner_config['device']) - builder = SDRFactory.get_builder(sdr_type) - - # Build FM demod command - sdr_cmd = builder.build_fm_demod_command( - device=device, - frequency_mhz=frequency, - sample_rate=resample_rate, - gain=float(scanner_config['gain']), - modulation=modulation, - squelch=scanner_config['squelch'], - bias_t=scanner_config.get('bias_t', False) - ) - # Ensure we use the found rx_fm path - sdr_cmd[0] = rx_fm_path + logger.error(f"rx_fm not found - required for {sdr_type.value}. Install SoapySDR utilities.") + return + + # Create device and get command builder + sdr_device = SDRFactory.create_default_device(sdr_type, index=device_index) + builder = SDRFactory.get_builder(sdr_type) + + # Build FM demod command + sdr_cmd = builder.build_fm_demod_command( + device=sdr_device, + frequency_mhz=frequency, + sample_rate=resample_rate, + gain=float(gain_value), + modulation=modulation, + squelch=squelch_value, + bias_t=bias_t_enabled, + ) + # Ensure we use the found rx_fm path + sdr_cmd[0] = rx_fm_path encoder_cmd = [ ffmpeg_path, @@ -762,11 +778,11 @@ def _start_audio_stream(frequency: float, modulation: str): ] try: - # Use subprocess piping for reliable streaming. - # Log stderr to temp files for error diagnosis. - rtl_stderr_log = '/tmp/rtl_fm_stderr.log' - ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log' - logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={scanner_config['device']}") + # Use subprocess piping for reliable streaming. + # Log stderr to temp files for error diagnosis. + rtl_stderr_log = '/tmp/rtl_fm_stderr.log' + ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log' + logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={device_index}") # Retry loop for USB device contention (device may not be # released immediately after a previous process exits) @@ -1432,7 +1448,15 @@ def start_audio() -> Response: }), 409 receiver_active_device = device - _start_audio_stream(frequency, modulation) + _start_audio_stream( + frequency, + modulation, + device=device, + sdr_type=sdr_type, + gain=gain, + squelch=squelch, + bias_t=bias_t, + ) if audio_running: audio_source = 'process' From d9b528f3d3ec35be384d669c2f1b8c3912776e63 Mon Sep 17 00:00:00 2001 From: Smittix Date: Tue, 24 Feb 2026 09:04:51 +0000 Subject: [PATCH 03/52] Retry monitor audio starts after stale token responses --- routes/listening_post.py | 1 + static/js/modes/waterfall.js | 62 ++++++++++++++++++++++++------------ 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/routes/listening_post.py b/routes/listening_post.py index cded266..028aa13 100644 --- a/routes/listening_post.py +++ b/routes/listening_post.py @@ -1336,6 +1336,7 @@ def start_audio() -> Response: 'message': 'Superseded audio start request', 'source': audio_source, 'superseded': True, + 'current_token': audio_start_token, }), 409 audio_start_token = request_token else: diff --git a/static/js/modes/waterfall.js b/static/js/modes/waterfall.js index c98de8f..f428d6e 100644 --- a/static/js/modes/waterfall.js +++ b/static/js/modes/waterfall.js @@ -2810,19 +2810,46 @@ const Waterfall = (function () { // Use live _monitorFreqMhz for retunes so that any user // clicks that changed the VFO during the async setup are // picked up rather than overridden. - let { response, payload } = await _requestAudioStart({ - frequency: centerMhz, - modulation: mode, - squelch, - gain, - device: monitorDevice, - biasT, - requestToken, - }); + const requestAudioStartResynced = async (deviceForRequest) => { + let startResult = await _requestAudioStart({ + frequency: centerMhz, + modulation: mode, + squelch, + gain, + device: deviceForRequest, + biasT, + requestToken, + }); + const startPayload = startResult?.payload || {}; + const isStale = startPayload.superseded === true || startPayload.status === 'stale'; + if (isStale) { + const currentToken = Number(startPayload.current_token); + if (Number.isFinite(currentToken) && currentToken >= 0) { + startResult = await _requestAudioStart({ + frequency: centerMhz, + modulation: mode, + squelch, + gain, + device: deviceForRequest, + biasT, + requestToken: currentToken + 1, + }); + } + } + return startResult; + }; + + let { response, payload } = await requestAudioStartResynced(monitorDevice); if (nonce !== _audioConnectNonce) return; const staleStart = payload?.superseded === true || payload?.status === 'stale'; - if (staleStart) return; + if (staleStart) { + // If the backend still reports stale after token resync, + // schedule a fresh retune so monitor audio does not stay on + // an older station indefinitely. + if (_monitoring) _queueMonitorRetune(90); + return; + } const busy = payload?.error_type === 'DEVICE_BUSY' || (response.status === 409 && !staleStart); if ( busy @@ -2835,17 +2862,12 @@ const Waterfall = (function () { _resumeWaterfallAfterMonitor = true; await _wait(220); monitorDevice = selectedDevice; - ({ response, payload } = await _requestAudioStart({ - frequency: centerMhz, - modulation: mode, - squelch, - gain, - device: monitorDevice, - biasT, - requestToken, - })); + ({ response, payload } = await requestAudioStartResynced(monitorDevice)); if (nonce !== _audioConnectNonce) return; - if (payload?.superseded === true || payload?.status === 'stale') return; + if (payload?.superseded === true || payload?.status === 'stale') { + if (_monitoring) _queueMonitorRetune(90); + return; + } } if (!response.ok || payload.status !== 'started') { From 55c38522a47f2e2a64e337f23055e9ab54303bec Mon Sep 17 00:00:00 2001 From: Smittix Date: Tue, 24 Feb 2026 09:15:24 +0000 Subject: [PATCH 04/52] Bind monitor audio stream to start request token --- routes/listening_post.py | 59 ++++++++++++++++++++++-------------- static/js/modes/waterfall.js | 9 ++++-- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/routes/listening_post.py b/routes/listening_post.py index 028aa13..c2282f8 100644 --- a/routes/listening_post.py +++ b/routes/listening_post.py @@ -1606,14 +1606,25 @@ def audio_probe() -> Response: return jsonify({'status': 'ok', 'bytes': size}) -@receiver_bp.route('/audio/stream') -def stream_audio() -> Response: - """Stream WAV audio.""" - if audio_source == 'waterfall': - for _ in range(40): - if audio_running: - break - time.sleep(0.05) +@receiver_bp.route('/audio/stream') +def stream_audio() -> Response: + """Stream WAV audio.""" + request_token_raw = request.args.get('request_token') + request_token = None + if request_token_raw is not None: + try: + request_token = int(request_token_raw) + except (ValueError, TypeError): + request_token = None + + if request_token is not None and request_token < audio_start_token: + return Response(b'', mimetype='audio/wav', status=204) + + if audio_source == 'waterfall': + for _ in range(40): + if audio_running: + break + time.sleep(0.05) if not audio_running: return Response(b'', mimetype='audio/wav', status=204) @@ -1633,6 +1644,8 @@ def stream_audio() -> Response: inactive_since: float | None = None while audio_running and audio_source == 'waterfall': + if request_token is not None and request_token < audio_start_token: + break chunk = read_shared_monitor_audio_chunk(timeout=1.0) if chunk: inactive_since = None @@ -1672,11 +1685,11 @@ def stream_audio() -> Response: if not audio_running or not audio_process: return Response(b'', mimetype='audio/wav', status=204) - def generate(): - # Capture local reference to avoid race condition with stop - proc = audio_process - if not proc or not proc.stdout: - return + def generate(): + # Capture local reference to avoid race condition with stop + proc = audio_process + if not proc or not proc.stdout: + return try: # Drain stale audio that accumulated in the pipe buffer # between pipeline start and stream connection. Keep the @@ -1695,15 +1708,17 @@ def stream_audio() -> Response: if header_chunk: yield header_chunk - # Stream real-time audio - first_chunk_deadline = time.time() + 20.0 - warned_wait = False - while audio_running and proc.poll() is None: - # Use select to avoid blocking forever - ready, _, _ = select.select([proc.stdout], [], [], 2.0) - if ready: - chunk = proc.stdout.read(8192) - if chunk: + # Stream real-time audio + first_chunk_deadline = time.time() + 20.0 + warned_wait = False + while audio_running and proc.poll() is None: + if request_token is not None and request_token < audio_start_token: + break + # Use select to avoid blocking forever + ready, _, _ = select.select([proc.stdout], [], [], 2.0) + if ready: + chunk = proc.stdout.read(8192) + if chunk: warned_wait = False yield chunk else: diff --git a/static/js/modes/waterfall.js b/static/js/modes/waterfall.js index f428d6e..e202ccf 100644 --- a/static/js/modes/waterfall.js +++ b/static/js/modes/waterfall.js @@ -2603,7 +2603,7 @@ const Waterfall = (function () { player.load(); } - async function _attachMonitorAudio(nonce) { + async function _attachMonitorAudio(nonce, streamToken = null) { const player = document.getElementById('wfAudioPlayer'); if (!player) { return { ok: false, reason: 'player_missing', message: 'Audio player is unavailable.' }; @@ -2622,7 +2622,10 @@ const Waterfall = (function () { } await _pauseMonitorAudioElement(); - player.src = `/receiver/audio/stream?fresh=1&t=${Date.now()}-${attempt}`; + const tokenQuery = (streamToken !== null && streamToken !== undefined && String(streamToken).length > 0) + ? `&request_token=${encodeURIComponent(String(streamToken))}` + : ''; + player.src = `/receiver/audio/stream?fresh=1&t=${Date.now()}-${attempt}${tokenQuery}`; player.load(); try { @@ -2886,7 +2889,7 @@ const Waterfall = (function () { return; } - const attach = await _attachMonitorAudio(nonce); + const attach = await _attachMonitorAudio(nonce, payload?.request_token); if (nonce !== _audioConnectNonce) return; _monitorSource = payload?.source === 'waterfall' ? 'waterfall' : 'process'; if ( From b7d90e8e5ee4500a75b084118dae5b5a2aa17481 Mon Sep 17 00:00:00 2001 From: Smittix Date: Tue, 24 Feb 2026 09:37:22 +0000 Subject: [PATCH 05/52] Fix monitor retune when frequency changes during startup --- static/js/modes/waterfall.js | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/static/js/modes/waterfall.js b/static/js/modes/waterfall.js index e202ccf..48e717e 100644 --- a/static/js/modes/waterfall.js +++ b/static/js/modes/waterfall.js @@ -2763,6 +2763,7 @@ const Waterfall = (function () { _resumeWaterfallAfterMonitor = !!wasRunningWaterfall; } + const liveCenterMhz = _currentCenter(); // Keep an explicit pending tune target so retunes cannot fall // back to a stale frequency during capture restart churn. const requestedTuneMhz = Number.isFinite(_pendingMonitorTuneMhz) @@ -2770,11 +2771,11 @@ const Waterfall = (function () { : ( Number.isFinite(_pendingCaptureVfoMhz) ? _pendingCaptureVfoMhz - : (Number.isFinite(_monitorFreqMhz) ? _monitorFreqMhz : _currentCenter()) + : (Number.isFinite(_monitorFreqMhz) ? _monitorFreqMhz : liveCenterMhz) ); const centerMhz = retuneOnly - ? (Number.isFinite(requestedTuneMhz) ? requestedTuneMhz : _currentCenter()) - : _currentCenter(); + ? (Number.isFinite(liveCenterMhz) ? liveCenterMhz : requestedTuneMhz) + : liveCenterMhz; const mode = document.getElementById('wfMonitorMode')?.value || 'wfm'; const squelch = parseInt(document.getElementById('wfMonitorSquelch')?.value, 10) || 0; const sliderGain = parseInt(document.getElementById('wfMonitorGain')?.value, 10); @@ -2795,6 +2796,8 @@ const Waterfall = (function () { _monitorFreqMhz = centerMhz; } else if (Number.isFinite(centerMhz)) { _monitorFreqMhz = centerMhz; + _pendingMonitorTuneMhz = centerMhz; + _pendingCaptureVfoMhz = centerMhz; } _drawFreqAxis(); _stopSmeter(); @@ -2892,10 +2895,11 @@ const Waterfall = (function () { const attach = await _attachMonitorAudio(nonce, payload?.request_token); if (nonce !== _audioConnectNonce) return; _monitorSource = payload?.source === 'waterfall' ? 'waterfall' : 'process'; - if ( + const pendingTuneMismatch = ( Number.isFinite(_pendingMonitorTuneMhz) - && Math.abs(_pendingMonitorTuneMhz - centerMhz) < 1e-6 - ) { + && Math.abs(_pendingMonitorTuneMhz - centerMhz) >= 1e-6 + ); + if (!pendingTuneMismatch) { _pendingMonitorTuneMhz = null; } @@ -2906,6 +2910,7 @@ const Waterfall = (function () { _setMonitorState(`Monitoring ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()} (audio locked)`); _setStatus('Monitor started but browser blocked playback. Click Unlock Audio.'); _setVisualStatus('MONITOR'); + if (pendingTuneMismatch) _queueMonitorRetune(45); return; } @@ -2949,10 +2954,19 @@ const Waterfall = (function () { } _setStatus(`Audio monitor active on ${displayMhz.toFixed(4)} MHz (${mode.toUpperCase()})`); _setVisualStatus('MONITOR'); + if (pendingTuneMismatch) { + _queueMonitorRetune(45); + } // After a retune reconnect, sync the backend to the latest // VFO in case the user clicked a new frequency while the // audio stream was reconnecting. - if (retuneOnly && _monitorSource === 'waterfall' && _ws && _ws.readyState === WebSocket.OPEN) { + if ( + !pendingTuneMismatch + && retuneOnly + && _monitorSource === 'waterfall' + && _ws + && _ws.readyState === WebSocket.OPEN + ) { _sendWsTuneCmd(); } } catch (err) { From 1a1a398962b9b07fcc6835bc02ae3484dd9e3629 Mon Sep 17 00:00:00 2001 From: Smittix Date: Tue, 24 Feb 2026 09:54:10 +0000 Subject: [PATCH 06/52] Use selected SDR for monitor retune/start path --- static/js/modes/waterfall.js | 49 ++++++++---------------------------- 1 file changed, 10 insertions(+), 39 deletions(-) diff --git a/static/js/modes/waterfall.js b/static/js/modes/waterfall.js index 48e717e..60ad491 100644 --- a/static/js/modes/waterfall.js +++ b/static/js/modes/waterfall.js @@ -2681,25 +2681,6 @@ const Waterfall = (function () { }; } - function _deviceKey(device) { - if (!device) return ''; - return `${device.sdrType || ''}:${device.deviceIndex || 0}`; - } - - function _findAlternateDevice(currentDevice) { - const currentKey = _deviceKey(currentDevice); - for (const d of _devices) { - const candidate = { - sdrType: String(d.sdr_type || 'rtlsdr'), - deviceIndex: parseInt(d.index, 10) || 0, - }; - if (_deviceKey(candidate) !== currentKey) { - return candidate; - } - } - return null; - } - async function _requestAudioStart({ frequency, modulation, @@ -2784,10 +2765,11 @@ const Waterfall = (function () { ? sliderGain : (Number.isFinite(fallbackGain) ? Math.round(fallbackGain) : 40); const selectedDevice = _selectedDevice(); - const altDevice = _running ? _findAlternateDevice(selectedDevice) : null; - let monitorDevice = altDevice || selectedDevice; + // Always target the currently selected SDR for monitor start/retune. + // This keeps waterfall-shared monitor tuning deterministic and avoids + // retuning a different receiver than the one driving the display. + let monitorDevice = selectedDevice; const biasT = !!document.getElementById('wfBiasT')?.checked; - const usingSecondaryDevice = !!altDevice; // Use a high monotonic token so backend start ordering remains // valid across page reloads (local nonces reset to small values). const requestToken = Math.trunc((Date.now() * 4096) + (nonce & 0x0fff)); @@ -2804,14 +2786,10 @@ const Waterfall = (function () { _setUnlockVisible(false); _audioUnlockRequired = false; - if (usingSecondaryDevice) { - _setMonitorState( - `Starting ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()} on ` - + `${monitorDevice.sdrType.toUpperCase()} #${monitorDevice.deviceIndex}...` - ); - } else { - _setMonitorState(`Starting ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()}...`); - } + _setMonitorState( + `Starting ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()} on ` + + `${monitorDevice.sdrType.toUpperCase()} #${monitorDevice.deviceIndex}...` + ); // Use live _monitorFreqMhz for retunes so that any user // clicks that changed the VFO during the async setup are @@ -2857,12 +2835,7 @@ const Waterfall = (function () { return; } const busy = payload?.error_type === 'DEVICE_BUSY' || (response.status === 409 && !staleStart); - if ( - busy - && _running - && !usingSecondaryDevice - && !retuneOnly - ) { + if (busy && _running && !retuneOnly) { _setMonitorState('Audio device busy, pausing waterfall and retrying monitor...'); await stop({ keepStatus: true }); _resumeWaterfallAfterMonitor = true; @@ -2944,13 +2917,11 @@ const Waterfall = (function () { _setMonitorState( `Monitoring ${displayMhz.toFixed(4)} MHz ${mode.toUpperCase()} via shared IQ` ); - } else if (usingSecondaryDevice) { + } else { _setMonitorState( `Monitoring ${displayMhz.toFixed(4)} MHz ${mode.toUpperCase()} ` + `via ${monitorDevice.sdrType.toUpperCase()} #${monitorDevice.deviceIndex}` ); - } else { - _setMonitorState(`Monitoring ${displayMhz.toFixed(4)} MHz ${mode.toUpperCase()}`); } _setStatus(`Audio monitor active on ${displayMhz.toFixed(4)} MHz (${mode.toUpperCase()})`); _setVisualStatus('MONITOR'); From 07b5b72878cb103f2846b689934bae35199883e3 Mon Sep 17 00:00:00 2001 From: Smittix Date: Tue, 24 Feb 2026 09:59:07 +0000 Subject: [PATCH 07/52] Sync monitor state text with tuned waterfall frequency --- static/js/modes/waterfall.js | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/static/js/modes/waterfall.js b/static/js/modes/waterfall.js index 60ad491..c9d9610 100644 --- a/static/js/modes/waterfall.js +++ b/static/js/modes/waterfall.js @@ -2535,6 +2535,12 @@ const Waterfall = (function () { } _updateFreqDisplay(); _setStatus(`Tuned ${_monitorFreqMhz.toFixed(4)} MHz`); + if (_monitoring && _monitorSource === 'waterfall') { + const mode = _getMonitorMode().toUpperCase(); + _setMonitorState(`Monitoring ${_monitorFreqMhz.toFixed(4)} MHz ${mode} via shared IQ`); + _setStatus(`Audio monitor active on ${_monitorFreqMhz.toFixed(4)} MHz (${mode})`); + _setVisualStatus('MONITOR'); + } if (!_monitoring) _setVisualStatus('RUNNING'); } else if (_onRetuneRequired(msg)) { return; @@ -2786,10 +2792,14 @@ const Waterfall = (function () { _setUnlockVisible(false); _audioUnlockRequired = false; - _setMonitorState( - `Starting ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()} on ` - + `${monitorDevice.sdrType.toUpperCase()} #${monitorDevice.deviceIndex}...` - ); + if (retuneOnly && _monitoring) { + _setMonitorState(`Retuning ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()}...`); + } else { + _setMonitorState( + `Starting ${centerMhz.toFixed(4)} MHz ${mode.toUpperCase()} on ` + + `${monitorDevice.sdrType.toUpperCase()} #${monitorDevice.deviceIndex}...` + ); + } // Use live _monitorFreqMhz for retunes so that any user // clicks that changed the VFO during the async setup are @@ -2831,7 +2841,13 @@ const Waterfall = (function () { // If the backend still reports stale after token resync, // schedule a fresh retune so monitor audio does not stay on // an older station indefinitely. - if (_monitoring) _queueMonitorRetune(90); + if (_monitoring) { + const liveMode = _getMonitorMode().toUpperCase(); + _setMonitorState(`Monitoring ${_monitorFreqMhz.toFixed(4)} MHz ${liveMode}`); + _setStatus(`Audio monitor active on ${_monitorFreqMhz.toFixed(4)} MHz (${liveMode})`); + _setVisualStatus('MONITOR'); + _queueMonitorRetune(90); + } return; } const busy = payload?.error_type === 'DEVICE_BUSY' || (response.status === 409 && !staleStart); From 2a5f537381628fe29e6555ce3c38e1a11951d095 Mon Sep 17 00:00:00 2001 From: Smittix Date: Tue, 24 Feb 2026 10:01:29 +0000 Subject: [PATCH 08/52] Coalesce rapid step-button frequency changes --- static/js/modes/waterfall.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/static/js/modes/waterfall.js b/static/js/modes/waterfall.js index c9d9610..a5b4f8c 100644 --- a/static/js/modes/waterfall.js +++ b/static/js/modes/waterfall.js @@ -3262,7 +3262,8 @@ const Waterfall = (function () { function stepFreq(multiplier) { const step = _getNumber('wfStepSize', 0.1); - _setAndTune(_currentCenter() + multiplier * step, true); + // Coalesce rapid step-button presses into one final retune. + _setAndTune(_currentCenter() + multiplier * step, false); } function zoomBy(factor) { From 01abcac8f21c9466ef9104a9afd970f973dca157 Mon Sep 17 00:00:00 2001 From: Smittix Date: Tue, 24 Feb 2026 12:30:31 +0000 Subject: [PATCH 09/52] Add WeFax (Weather Fax) decoder mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement HF radiofax decoding with custom Python DSP pipeline (rtl_fm USB → Goertzel/Hilbert demodulation), 33-station database with broadcast schedules, audio waveform scope, live image preview, and decoded image gallery. Amber/gold UI theme for HF distinction. Co-Authored-By: Claude Opus 4.6 --- config.py | 6 + data/wefax_stations.json | 733 +++++++++++++++++++++++++++ routes/__init__.py | 12 +- routes/wefax.py | 285 +++++++++++ static/css/modes/wefax.css | 422 ++++++++++++++++ static/js/modes/wefax.js | 671 +++++++++++++++++++++++++ templates/index.html | 131 ++++- templates/partials/modes/wefax.html | 110 ++++ templates/partials/nav.html | 2 + tests/test_wefax.py | 430 ++++++++++++++++ utils/wefax.py | 754 ++++++++++++++++++++++++++++ utils/wefax_stations.py | 89 ++++ 12 files changed, 3636 insertions(+), 9 deletions(-) create mode 100644 data/wefax_stations.json create mode 100644 routes/wefax.py create mode 100644 static/css/modes/wefax.css create mode 100644 static/js/modes/wefax.js create mode 100644 templates/partials/modes/wefax.html create mode 100644 tests/test_wefax.py create mode 100644 utils/wefax.py create mode 100644 utils/wefax_stations.py diff --git a/config.py b/config.py index 89d383b..3001fc3 100644 --- a/config.py +++ b/config.py @@ -337,6 +337,12 @@ WEATHER_SAT_PREDICTION_HOURS = _get_env_int('WEATHER_SAT_PREDICTION_HOURS', 24) WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEATHER_SAT_SCHEDULE_REFRESH_MINUTES', 30) WEATHER_SAT_CAPTURE_BUFFER_SECONDS = _get_env_int('WEATHER_SAT_CAPTURE_BUFFER_SECONDS', 30) +# WeFax (Weather Fax) settings +WEFAX_DEFAULT_GAIN = _get_env_float('WEFAX_GAIN', 40.0) +WEFAX_SAMPLE_RATE = _get_env_int('WEFAX_SAMPLE_RATE', 22050) +WEFAX_DEFAULT_IOC = _get_env_int('WEFAX_IOC', 576) +WEFAX_DEFAULT_LPM = _get_env_int('WEFAX_LPM', 120) + # SubGHz transceiver settings (HackRF) SUBGHZ_DEFAULT_FREQUENCY = _get_env_float('SUBGHZ_FREQUENCY', 433.92) SUBGHZ_DEFAULT_SAMPLE_RATE = _get_env_int('SUBGHZ_SAMPLE_RATE', 2000000) diff --git a/data/wefax_stations.json b/data/wefax_stations.json new file mode 100644 index 0000000..9c72f98 --- /dev/null +++ b/data/wefax_stations.json @@ -0,0 +1,733 @@ +{ + "stations": [ + { + "name": "USCG Kodiak", + "callsign": "NOJ", + "country": "US", + "city": "Kodiak, AK", + "coordinates": [57.78, -152.50], + "frequencies": [ + {"khz": 2054, "description": "Night"}, + {"khz": 4298, "description": "Primary"}, + {"khz": 8459, "description": "Day"}, + {"khz": 12412.5, "description": "Extended"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "03:40", "duration_min": 148, "content": "Chart Series 1"}, + {"utc": "09:50", "duration_min": 138, "content": "Chart Series 2"}, + {"utc": "15:40", "duration_min": 148, "content": "Chart Series 3"}, + {"utc": "21:50", "duration_min": 98, "content": "Chart Series 4"} + ] + }, + { + "name": "USCG Boston", + "callsign": "NMF", + "country": "US", + "city": "Boston, MA", + "coordinates": [42.36, -71.04], + "frequencies": [ + {"khz": 4235, "description": "Night"}, + {"khz": 6340.5, "description": "Primary"}, + {"khz": 9110, "description": "Day"}, + {"khz": 12750, "description": "Extended"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "02:30", "duration_min": 20, "content": "Wind/Wave Analysis"}, + {"utc": "04:38", "duration_min": 20, "content": "Sea State Analysis"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "09:30", "duration_min": 20, "content": "48-Hour Surface Prog"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "14:00", "duration_min": 20, "content": "24-Hour Surface Prog"}, + {"utc": "16:00", "duration_min": 20, "content": "Sea State Analysis"}, + {"utc": "18:10", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "22:00", "duration_min": 20, "content": "Satellite Image"} + ] + }, + { + "name": "USCG New Orleans", + "callsign": "NMG", + "country": "US", + "city": "New Orleans, LA", + "coordinates": [29.95, -90.07], + "frequencies": [ + {"khz": 4317.9, "description": "Night"}, + {"khz": 8503.9, "description": "Primary"}, + {"khz": 12789.9, "description": "Day"}, + {"khz": 17146.4, "description": "Extended"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "03:00", "duration_min": 20, "content": "24-Hour Surface Prog"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "09:00", "duration_min": 20, "content": "Sea State Analysis"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "15:00", "duration_min": 20, "content": "48-Hour Surface Prog"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "21:00", "duration_min": 20, "content": "Tropical Analysis"} + ] + }, + { + "name": "USCG Pt. Reyes", + "callsign": "NMC", + "country": "US", + "city": "Pt. Reyes, CA", + "coordinates": [38.07, -122.97], + "frequencies": [ + {"khz": 4346, "description": "Night"}, + {"khz": 8682, "description": "Primary"}, + {"khz": 12786, "description": "Day"}, + {"khz": 17151.2, "description": "Extended"}, + {"khz": 22527, "description": "DX"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "01:40", "duration_min": 20, "content": "Wind/Wave Analysis"}, + {"utc": "06:55", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "11:20", "duration_min": 20, "content": "48-Hour Surface Prog"}, + {"utc": "14:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "18:40", "duration_min": 20, "content": "Sea State Analysis"}, + {"utc": "23:20", "duration_min": 20, "content": "Satellite Image"} + ] + }, + { + "name": "USCG Honolulu", + "callsign": "KVM70", + "country": "US", + "city": "Honolulu, HI", + "coordinates": [21.31, -157.86], + "frequencies": [ + {"khz": 9982.5, "description": "Primary"}, + {"khz": 11090, "description": "Day"}, + {"khz": 16135, "description": "Extended"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "05:19", "duration_min": 20, "content": "Surface Prog"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "17:19", "duration_min": 20, "content": "Sea State Analysis"} + ] + }, + { + "name": "RN Northwood", + "callsign": "GYA", + "country": "GB", + "city": "Northwood, London", + "coordinates": [51.63, -0.42], + "frequencies": [ + {"khz": 2618.5, "description": "Night"}, + {"khz": 3280.5, "description": "Night Alt"}, + {"khz": 4610, "description": "Primary"}, + {"khz": 6834, "description": "Day Alt"}, + {"khz": 8040, "description": "Day"}, + {"khz": 11086.5, "description": "Extended"}, + {"khz": 12390, "description": "Persian Gulf"}, + {"khz": 18261, "description": "DX"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "03:30", "duration_min": 20, "content": "24-Hour Surface Prog"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "08:00", "duration_min": 20, "content": "Sea State Forecast"}, + {"utc": "09:30", "duration_min": 20, "content": "Extended Forecast"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "15:30", "duration_min": 20, "content": "48-Hour Surface Prog"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "19:00", "duration_min": 20, "content": "Wave Period Forecast"}, + {"utc": "21:30", "duration_min": 20, "content": "Extended Forecast"} + ] + }, + { + "name": "DWD Hamburg/Pinneberg", + "callsign": "DDH", + "country": "DE", + "city": "Pinneberg", + "coordinates": [53.66, 9.80], + "frequencies": [ + {"khz": 3855, "description": "Night (DDH3, 10kW)"}, + {"khz": 7880, "description": "Primary (DDK3, 20kW)"}, + {"khz": 13882.5, "description": "Day (DDK6, 20kW)"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "04:30", "duration_min": 20, "content": "Surface Analysis N. Atlantic"}, + {"utc": "07:15", "duration_min": 20, "content": "Surface Prog"}, + {"utc": "09:30", "duration_min": 20, "content": "Surface Analysis Europe"}, + {"utc": "10:07", "duration_min": 20, "content": "Sea State North Sea"}, + {"utc": "13:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "15:20", "duration_min": 20, "content": "Extended Prog"}, + {"utc": "15:40", "duration_min": 20, "content": "Sea Ice Chart"}, + {"utc": "16:30", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "21:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "21:15", "duration_min": 20, "content": "Surface Prog"} + ] + }, + { + "name": "JMA Tokyo", + "callsign": "JMH", + "country": "JP", + "city": "Tokyo", + "coordinates": [35.69, 139.69], + "frequencies": [ + {"khz": 3622.5, "description": "Night"}, + {"khz": 7795, "description": "Primary"}, + {"khz": 13988.5, "description": "Day"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "01:30", "duration_min": 20, "content": "24-Hour Prog"}, + {"utc": "03:00", "duration_min": 20, "content": "Satellite Image"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "07:30", "duration_min": 20, "content": "Wave Analysis"}, + {"utc": "09:00", "duration_min": 20, "content": "Satellite Image"}, + {"utc": "10:19", "duration_min": 20, "content": "Tropical Cyclone Info"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "15:00", "duration_min": 20, "content": "Satellite Image"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "21:00", "duration_min": 20, "content": "48-Hour Prog"} + ] + }, + { + "name": "Kyodo News Tokyo", + "callsign": "JJC", + "country": "JP", + "city": "Tokyo", + "coordinates": [35.69, 139.69], + "frequencies": [ + {"khz": 4316, "description": "Night"}, + {"khz": 8467.5, "description": "Primary"}, + {"khz": 12745.5, "description": "Day"}, + {"khz": 16971, "description": "Extended"}, + {"khz": 17069.6, "description": "DX"}, + {"khz": 22542, "description": "DX 2"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Press Photo/News Fax"}, + {"utc": "04:00", "duration_min": 20, "content": "Press Photo/News Fax"}, + {"utc": "08:00", "duration_min": 20, "content": "Press Photo/News Fax"}, + {"utc": "12:00", "duration_min": 20, "content": "Press Photo/News Fax"}, + {"utc": "16:00", "duration_min": 20, "content": "Press Photo/News Fax"}, + {"utc": "20:00", "duration_min": 20, "content": "Press Photo/News Fax"} + ] + }, + { + "name": "Kagoshima Fisheries", + "callsign": "JFX", + "country": "JP", + "city": "Kagoshima", + "coordinates": [31.60, 130.56], + "frequencies": [ + {"khz": 4274, "description": "Night"}, + {"khz": 8658, "description": "Primary"}, + {"khz": 13074, "description": "Day"}, + {"khz": 16907.5, "description": "Extended"}, + {"khz": 22559.6, "description": "DX"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Sea Surface Temp"}, + {"utc": "04:00", "duration_min": 20, "content": "Fishing Forecast"}, + {"utc": "08:00", "duration_min": 20, "content": "Sea Surface Temp"}, + {"utc": "12:00", "duration_min": 20, "content": "Current Chart"}, + {"utc": "16:00", "duration_min": 20, "content": "Fishing Forecast"}, + {"utc": "20:00", "duration_min": 20, "content": "Sea Surface Temp"} + ] + }, + { + "name": "KMA Seoul", + "callsign": "HLL2", + "country": "KR", + "city": "Seoul", + "coordinates": [37.57, 126.98], + "frequencies": [ + {"khz": 3585, "description": "Night"}, + {"khz": 5857.5, "description": "Primary"}, + {"khz": 7433.5, "description": "Day"}, + {"khz": 9165, "description": "Extended"}, + {"khz": 13570, "description": "DX"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "03:00", "duration_min": 20, "content": "24-Hour Prog"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "09:00", "duration_min": 20, "content": "Satellite Image"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "15:00", "duration_min": 20, "content": "Sea State Analysis"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "21:00", "duration_min": 20, "content": "48-Hour Prog"} + ] + }, + { + "name": "Taipei Met", + "callsign": "BMF", + "country": "TW", + "city": "Taipei", + "coordinates": [25.03, 121.57], + "frequencies": [ + {"khz": 4616, "description": "Primary"}, + {"khz": 8140, "description": "Day"}, + {"khz": 13900, "description": "Extended"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "Bangkok Met", + "callsign": "HSW64", + "country": "TH", + "city": "Bangkok", + "coordinates": [13.76, 100.50], + "frequencies": [ + {"khz": 7396.8, "description": "Primary"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "Shanghai Met", + "callsign": "XSG", + "country": "CN", + "city": "Shanghai", + "coordinates": [31.23, 121.47], + "frequencies": [ + {"khz": 4170, "description": "Night"}, + {"khz": 8302, "description": "Primary"}, + {"khz": 12382, "description": "Day"}, + {"khz": 16559, "description": "Extended"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "Guangzhou Radio", + "callsign": "XSQ", + "country": "CN", + "city": "Guangzhou", + "coordinates": [23.13, 113.26], + "frequencies": [ + {"khz": 4199.8, "description": "Night"}, + {"khz": 8412.5, "description": "Primary"}, + {"khz": 12629.3, "description": "Day"}, + {"khz": 16826.3, "description": "Extended"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "Singapore Met", + "callsign": "9VF", + "country": "SG", + "city": "Singapore", + "coordinates": [1.35, 103.82], + "frequencies": [ + {"khz": 16035, "description": "Primary"}, + {"khz": 17430, "description": "Alternate"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "New Delhi Met", + "callsign": "ATP", + "country": "IN", + "city": "New Delhi", + "coordinates": [28.61, 77.21], + "frequencies": [ + {"khz": 7405, "description": "Night"}, + {"khz": 14842, "description": "Day"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "Murmansk Met", + "callsign": "RBW", + "country": "RU", + "city": "Murmansk", + "coordinates": [68.97, 33.09], + "frequencies": [ + {"khz": 6445.5, "description": "Night"}, + {"khz": 7907, "description": "Primary"}, + {"khz": 8444, "description": "Day"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "07:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "08:00", "duration_min": 20, "content": "Ice Chart"}, + {"utc": "14:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "14:30", "duration_min": 20, "content": "Surface Prog"}, + {"utc": "20:00", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "St. Petersburg Met", + "callsign": "RDD78", + "country": "RU", + "city": "St. Petersburg", + "coordinates": [59.93, 30.32], + "frequencies": [ + {"khz": 2640, "description": "Night"}, + {"khz": 4212, "description": "Primary"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "Athens Met", + "callsign": "SVJ4", + "country": "GR", + "city": "Athens", + "coordinates": [37.97, 23.73], + "frequencies": [ + {"khz": 4482.9, "description": "Night"}, + {"khz": 8106.9, "description": "Primary"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis Med"}, + {"utc": "09:00", "duration_min": 20, "content": "Surface Prog"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis Med"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis Med"} + ] + }, + { + "name": "Charleville Met", + "callsign": "VMC", + "country": "AU", + "city": "Charleville, QLD", + "coordinates": [-26.41, 146.24], + "frequencies": [ + {"khz": 2628, "description": "Night"}, + {"khz": 5100, "description": "Primary"}, + {"khz": 11030, "description": "Day"}, + {"khz": 13920, "description": "Extended"}, + {"khz": 20469, "description": "DX"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "MSLP Analysis"}, + {"utc": "03:00", "duration_min": 20, "content": "Prognosis"}, + {"utc": "06:00", "duration_min": 20, "content": "MSLP Analysis"}, + {"utc": "09:00", "duration_min": 20, "content": "Sea/Swell Chart"}, + {"utc": "12:00", "duration_min": 20, "content": "MSLP Analysis"}, + {"utc": "18:00", "duration_min": 20, "content": "MSLP Analysis"}, + {"utc": "19:00", "duration_min": 20, "content": "Prognosis"} + ] + }, + { + "name": "Wiluna Met", + "callsign": "VMW", + "country": "AU", + "city": "Wiluna, WA", + "coordinates": [-26.59, 120.23], + "frequencies": [ + {"khz": 5755, "description": "Night"}, + {"khz": 7535, "description": "Primary"}, + {"khz": 10555, "description": "Day"}, + {"khz": 15615, "description": "Extended"}, + {"khz": 18060, "description": "DX"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "MSLP Analysis"}, + {"utc": "06:00", "duration_min": 20, "content": "MSLP Analysis"}, + {"utc": "11:00", "duration_min": 20, "content": "Prognosis"}, + {"utc": "18:00", "duration_min": 20, "content": "MSLP Analysis"}, + {"utc": "21:00", "duration_min": 20, "content": "Sea/Swell Chart"} + ] + }, + { + "name": "NZ MetService", + "callsign": "ZKLF", + "country": "NZ", + "city": "Auckland", + "coordinates": [-36.85, 174.76], + "frequencies": [ + {"khz": 3247.4, "description": "Night"}, + {"khz": 5807, "description": "Primary"}, + {"khz": 9459, "description": "Day"}, + {"khz": 13550.5, "description": "Extended"}, + {"khz": 16340.1, "description": "DX"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "CFH Halifax", + "callsign": "CFH", + "country": "CA", + "city": "Halifax, NS", + "coordinates": [44.65, -63.57], + "frequencies": [ + {"khz": 4271, "description": "Night"}, + {"khz": 6496.4, "description": "Primary"}, + {"khz": 10536, "description": "Day"}, + {"khz": 13510, "description": "Extended"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "03:00", "duration_min": 20, "content": "Surface Prog"}, + {"utc": "06:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "18:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "22:22", "duration_min": 20, "content": "Ice Chart"}, + {"utc": "23:01", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "CCG Iqaluit", + "callsign": "VFF", + "country": "CA", + "city": "Iqaluit, NU", + "coordinates": [63.75, -68.52], + "frequencies": [ + {"khz": 3253, "description": "Night"}, + {"khz": 7710, "description": "Day"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:10", "duration_min": 20, "content": "Ice Chart"}, + {"utc": "05:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "07:00", "duration_min": 20, "content": "Ice Chart"}, + {"utc": "10:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "11:00", "duration_min": 20, "content": "Ice Chart"}, + {"utc": "21:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "23:30", "duration_min": 20, "content": "Ice Chart"} + ] + }, + { + "name": "CCG Inuvik", + "callsign": "VFA", + "country": "CA", + "city": "Inuvik, NT", + "coordinates": [68.36, -133.72], + "frequencies": [ + {"khz": 4292, "description": "Night"}, + {"khz": 8457.8, "description": "Primary"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "02:00", "duration_min": 20, "content": "Ice Chart"}, + {"utc": "16:30", "duration_min": 20, "content": "Ice Chart"} + ] + }, + { + "name": "CCG Sydney", + "callsign": "VCO", + "country": "CA", + "city": "Sydney, NS", + "coordinates": [46.14, -60.19], + "frequencies": [ + {"khz": 4416, "description": "Night"}, + {"khz": 6915.1, "description": "Primary"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "11:21", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "11:42", "duration_min": 20, "content": "Surface Prog"}, + {"utc": "17:41", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "22:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "23:31", "duration_min": 20, "content": "Surface Prog"} + ] + }, + { + "name": "Cape Naval", + "callsign": "ZSJ", + "country": "ZA", + "city": "Cape Town", + "coordinates": [-33.92, 18.42], + "frequencies": [ + {"khz": 4014, "description": "Night"}, + {"khz": 7508, "description": "Primary"}, + {"khz": 13538, "description": "Day"}, + {"khz": 18238, "description": "Extended"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "04:30", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "05:00", "duration_min": 20, "content": "Sea State"}, + {"utc": "06:30", "duration_min": 20, "content": "Surface Prog"}, + {"utc": "07:30", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "08:00", "duration_min": 20, "content": "Satellite Image"}, + {"utc": "10:30", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "11:00", "duration_min": 20, "content": "Sea State"}, + {"utc": "15:30", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "15:40", "duration_min": 20, "content": "Surface Prog"}, + {"utc": "22:30", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "Valparaiso Naval", + "callsign": "CBV", + "country": "CL", + "city": "Valparaiso", + "coordinates": [-33.05, -71.62], + "frequencies": [ + {"khz": 4228, "description": "Night"}, + {"khz": 8677, "description": "Primary"}, + {"khz": 17146.4, "description": "Day"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "11:15", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "11:30", "duration_min": 20, "content": "Surface Prog"}, + {"utc": "16:30", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "16:45", "duration_min": 20, "content": "Sea State"}, + {"utc": "19:15", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "19:30", "duration_min": 20, "content": "Surface Prog"}, + {"utc": "22:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "23:10", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "23:25", "duration_min": 20, "content": "Sea State"} + ] + }, + { + "name": "Magallanes Naval", + "callsign": "CBM", + "country": "CL", + "city": "Punta Arenas", + "coordinates": [-53.16, -70.91], + "frequencies": [ + {"khz": 4322, "description": "Night"}, + {"khz": 8696, "description": "Primary"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "01:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "13:00", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "Rio de Janeiro Naval", + "callsign": "PWZ33", + "country": "BR", + "city": "Rio de Janeiro", + "coordinates": [-22.91, -43.17], + "frequencies": [ + {"khz": 12665, "description": "Primary"}, + {"khz": 16978, "description": "Day"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "07:45", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "16:30", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "Dakar Met", + "callsign": "6VU", + "country": "SN", + "city": "Dakar", + "coordinates": [14.69, -17.44], + "frequencies": [ + {"khz": 13667.5, "description": "Primary"}, + {"khz": 19750, "description": "Day"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Surface Analysis"}, + {"utc": "12:00", "duration_min": 20, "content": "Surface Analysis"} + ] + }, + { + "name": "Misaki Fisheries", + "callsign": "JFC", + "country": "JP", + "city": "Miura", + "coordinates": [35.14, 139.62], + "frequencies": [ + {"khz": 8616, "description": "Primary"}, + {"khz": 13074, "description": "Day"}, + {"khz": 17231, "description": "Extended"} + ], + "ioc": 576, + "lpm": 120, + "schedule": [ + {"utc": "00:00", "duration_min": 20, "content": "Sea Surface Temp"}, + {"utc": "06:00", "duration_min": 20, "content": "Current Chart"}, + {"utc": "12:00", "duration_min": 20, "content": "Fishing Forecast"}, + {"utc": "18:00", "duration_min": 20, "content": "Sea Surface Temp"} + ] + } + ] +} diff --git a/routes/__init__.py b/routes/__init__.py index 9f2075f..cf197b9 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -14,7 +14,7 @@ def register_blueprints(app): from .correlation import correlation_bp from .dsc import dsc_bp from .gps import gps_bp - from .listening_post import receiver_bp + from .listening_post import receiver_bp from .meshtastic import meshtastic_bp from .offline import offline_bp from .pager import pager_bp @@ -33,6 +33,7 @@ def register_blueprints(app): from .updater import updater_bp from .vdl2 import vdl2_bp from .weather_sat import weather_sat_bp + from .wefax import wefax_bp from .websdr import websdr_bp from .wifi import wifi_bp from .wifi_v2 import wifi_v2_bp @@ -54,7 +55,7 @@ def register_blueprints(app): app.register_blueprint(gps_bp) app.register_blueprint(settings_bp) app.register_blueprint(correlation_bp) - app.register_blueprint(receiver_bp) + app.register_blueprint(receiver_bp) app.register_blueprint(meshtastic_bp) app.register_blueprint(tscm_bp) app.register_blueprint(spy_stations_bp) @@ -68,9 +69,10 @@ def register_blueprints(app): app.register_blueprint(alerts_bp) # Cross-mode alerts app.register_blueprint(recordings_bp) # Session recordings app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF) - app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking - app.register_blueprint(space_weather_bp) # Space weather monitoring - app.register_blueprint(signalid_bp) # External signal ID enrichment + app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking + app.register_blueprint(space_weather_bp) # Space weather monitoring + app.register_blueprint(signalid_bp) # External signal ID enrichment + app.register_blueprint(wefax_bp) # WeFax HF weather fax decoder # Initialize TSCM state with queue and lock from app import app as app_module diff --git a/routes/wefax.py b/routes/wefax.py new file mode 100644 index 0000000..4afcd98 --- /dev/null +++ b/routes/wefax.py @@ -0,0 +1,285 @@ +"""WeFax (Weather Fax) decoder routes. + +Provides endpoints for decoding HF weather fax transmissions from +maritime/aviation weather services worldwide. +""" + +from __future__ import annotations + +import queue + +from flask import Blueprint, Response, jsonify, request, send_file + +import app as app_module +from utils.logging import get_logger +from utils.sse import sse_stream_fanout +from utils.validation import validate_frequency +from utils.wefax import get_wefax_decoder +from utils.wefax_stations import get_current_broadcasts, get_station, load_stations + +logger = get_logger('intercept.wefax') + +wefax_bp = Blueprint('wefax', __name__, url_prefix='/wefax') + +# SSE progress queue +_wefax_queue: queue.Queue = queue.Queue(maxsize=100) + +# Track active SDR device +wefax_active_device: int | None = None + + +def _progress_callback(data: dict) -> None: + """Callback to queue progress updates for SSE stream.""" + try: + _wefax_queue.put_nowait(data) + except queue.Full: + try: + _wefax_queue.get_nowait() + _wefax_queue.put_nowait(data) + except queue.Empty: + pass + + +@wefax_bp.route('/status') +def get_status(): + """Get WeFax decoder status.""" + decoder = get_wefax_decoder() + return jsonify({ + 'available': True, + 'running': decoder.is_running, + 'image_count': len(decoder.get_images()), + }) + + +@wefax_bp.route('/start', methods=['POST']) +def start_decoder(): + """Start WeFax decoder. + + JSON body: + { + "frequency_khz": 4298, + "station": "NOJ", + "device": 0, + "gain": 40, + "ioc": 576, + "lpm": 120, + "direct_sampling": true + } + """ + decoder = get_wefax_decoder() + + if decoder.is_running: + return jsonify({ + 'status': 'already_running', + 'message': 'WeFax decoder is already running', + }) + + # Clear queue + while not _wefax_queue.empty(): + try: + _wefax_queue.get_nowait() + except queue.Empty: + break + + data = request.get_json(silent=True) or {} + + # Validate frequency (required) + frequency_khz = data.get('frequency_khz') + if frequency_khz is None: + return jsonify({ + 'status': 'error', + 'message': 'frequency_khz is required', + }), 400 + + try: + frequency_khz = float(frequency_khz) + # WeFax operates on HF: 2-30 MHz (2000-30000 kHz) + freq_mhz = frequency_khz / 1000.0 + validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0) + except (TypeError, ValueError) as e: + return jsonify({ + 'status': 'error', + 'message': f'Invalid frequency: {e}', + }), 400 + + station = str(data.get('station', '')).strip() + device_index = data.get('device', 0) + gain = float(data.get('gain', 40.0)) + ioc = int(data.get('ioc', 576)) + lpm = int(data.get('lpm', 120)) + direct_sampling = bool(data.get('direct_sampling', True)) + + # Validate IOC and LPM + if ioc not in (288, 576): + return jsonify({ + 'status': 'error', + 'message': 'IOC must be 288 or 576', + }), 400 + + if lpm not in (60, 120): + return jsonify({ + 'status': 'error', + 'message': 'LPM must be 60 or 120', + }), 400 + + # Claim SDR device + global wefax_active_device + device_int = int(device_index) + error = app_module.claim_sdr_device(device_int, 'wefax') + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error, + }), 409 + + # Set callback and start + decoder.set_callback(_progress_callback) + success = decoder.start( + frequency_khz=frequency_khz, + station=station, + device_index=device_int, + gain=gain, + ioc=ioc, + lpm=lpm, + direct_sampling=direct_sampling, + ) + + if success: + wefax_active_device = device_int + return jsonify({ + 'status': 'started', + 'frequency_khz': frequency_khz, + 'station': station, + 'ioc': ioc, + 'lpm': lpm, + 'device': device_int, + }) + else: + app_module.release_sdr_device(device_int) + return jsonify({ + 'status': 'error', + 'message': 'Failed to start decoder', + }), 500 + + +@wefax_bp.route('/stop', methods=['POST']) +def stop_decoder(): + """Stop WeFax decoder.""" + global wefax_active_device + decoder = get_wefax_decoder() + decoder.stop() + + if wefax_active_device is not None: + app_module.release_sdr_device(wefax_active_device) + wefax_active_device = None + + return jsonify({'status': 'stopped'}) + + +@wefax_bp.route('/stream') +def stream_progress(): + """SSE stream of WeFax decode progress.""" + response = Response( + sse_stream_fanout( + source_queue=_wefax_queue, + channel_key='wefax', + timeout=1.0, + keepalive_interval=30.0, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + response.headers['Connection'] = 'keep-alive' + return response + + +@wefax_bp.route('/images') +def list_images(): + """Get list of decoded WeFax images.""" + decoder = get_wefax_decoder() + images = decoder.get_images() + + limit = request.args.get('limit', type=int) + if limit and limit > 0: + images = images[-limit:] + + return jsonify({ + 'status': 'ok', + 'images': [img.to_dict() for img in images], + 'count': len(images), + }) + + +@wefax_bp.route('/images/') +def get_image(filename: str): + """Get a decoded WeFax image file.""" + decoder = get_wefax_decoder() + + if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): + return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 + + if not filename.endswith('.png'): + return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 + + image_path = decoder._output_dir / filename + if not image_path.exists(): + return jsonify({'status': 'error', 'message': 'Image not found'}), 404 + + return send_file(image_path, mimetype='image/png') + + +@wefax_bp.route('/images/', methods=['DELETE']) +def delete_image(filename: str): + """Delete a decoded WeFax image.""" + decoder = get_wefax_decoder() + + if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): + return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 + + if not filename.endswith('.png'): + return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 + + if decoder.delete_image(filename): + return jsonify({'status': 'ok'}) + else: + return jsonify({'status': 'error', 'message': 'Image not found'}), 404 + + +@wefax_bp.route('/images', methods=['DELETE']) +def delete_all_images(): + """Delete all decoded WeFax images.""" + decoder = get_wefax_decoder() + count = decoder.delete_all_images() + return jsonify({'status': 'ok', 'deleted': count}) + + +@wefax_bp.route('/stations') +def list_stations(): + """Get all WeFax stations from the database.""" + stations = load_stations() + return jsonify({ + 'status': 'ok', + 'stations': stations, + 'count': len(stations), + }) + + +@wefax_bp.route('/stations/') +def station_detail(callsign: str): + """Get station detail including current schedule info.""" + station = get_station(callsign) + if not station: + return jsonify({ + 'status': 'error', + 'message': f'Station {callsign} not found', + }), 404 + + current = get_current_broadcasts(callsign) + + return jsonify({ + 'status': 'ok', + 'station': station, + 'current_broadcasts': current, + }) diff --git a/static/css/modes/wefax.css b/static/css/modes/wefax.css new file mode 100644 index 0000000..2945594 --- /dev/null +++ b/static/css/modes/wefax.css @@ -0,0 +1,422 @@ +/* ============================================ + WeFax (Weather Fax) Mode Styles + Amber/gold theme (#ffaa00) for HF + ============================================ */ + +/* --- Stats Strip --- */ +.wefax-stats-strip { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 14px; + background: var(--bg-card, #0e1117); + border: 1px solid var(--border-color, #1e2a3a); + border-radius: 6px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.wefax-strip-group { + display: flex; + align-items: center; + gap: 10px; +} + +.wefax-strip-status { + display: flex; + align-items: center; + gap: 6px; +} + +.wefax-strip-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #444; + flex-shrink: 0; +} + +.wefax-strip-dot.scanning { background: #ffaa00; animation: wefax-pulse 1.5s ease-in-out infinite; } +.wefax-strip-dot.phasing { background: #ffcc44; animation: wefax-pulse 0.8s ease-in-out infinite; } +.wefax-strip-dot.receiving { background: #00cc66; animation: wefax-pulse 1s ease-in-out infinite; } +.wefax-strip-dot.complete { background: #00cc66; } +.wefax-strip-dot.error { background: #f44; } + +@keyframes wefax-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.wefax-strip-status-text { + font-family: var(--font-mono, 'JetBrains Mono', monospace); + font-size: 11px; + color: var(--text-primary, #e0e0e0); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.wefax-strip-btn { + padding: 4px 12px; + border: 1px solid var(--border-color, #1e2a3a); + border-radius: 4px; + font-family: var(--font-mono, monospace); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; + cursor: pointer; + background: var(--bg-primary, #161b22); + color: var(--text-primary, #e0e0e0); + display: inline-flex; + align-items: center; + gap: 4px; + transition: all 0.15s ease; +} + +.wefax-strip-btn.start { color: #ffaa00; border-color: #ffaa0044; } +.wefax-strip-btn.start:hover { background: #ffaa0015; border-color: #ffaa00; } +.wefax-strip-btn.stop { color: #f44; border-color: #f4444444; } +.wefax-strip-btn.stop:hover { background: #f4441a; border-color: #f44; } + +.wefax-strip-divider { + width: 1px; + height: 20px; + background: var(--border-color, #1e2a3a); +} + +.wefax-strip-stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; +} + +.wefax-strip-value { + font-family: var(--font-mono, monospace); + font-size: 13px; + font-weight: 600; + color: var(--text-primary, #e0e0e0); + font-variant-numeric: tabular-nums; +} + +.wefax-strip-value.accent-amber { color: #ffaa00; } + +.wefax-strip-label { + font-family: var(--font-mono, monospace); + font-size: 8px; + color: var(--text-dim, #555); + text-transform: uppercase; + letter-spacing: 1px; +} + +/* --- Visuals Container --- */ +.wefax-visuals-container { + display: flex; + flex-direction: column; + gap: 0; + width: 100%; +} + +/* --- Main Row --- */ +.wefax-main-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +/* --- Schedule Timeline --- */ +.wefax-schedule-panel { + background: var(--bg-card, #0e1117); + border: 1px solid var(--border-color, #1e2a3a); + border-radius: 6px; + margin-bottom: 12px; + overflow: hidden; +} + +.wefax-schedule-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid var(--border-color, #1e2a3a); +} + +.wefax-schedule-title { + font-family: var(--font-mono, monospace); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: #ffaa00; +} + +.wefax-schedule-list { + display: flex; + flex-direction: column; + max-height: 200px; + overflow-y: auto; +} + +.wefax-schedule-entry { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 12px; + border-bottom: 1px solid var(--border-color, #1e2a3a)11; + font-family: var(--font-mono, monospace); + font-size: 11px; +} + +.wefax-schedule-entry:last-child { border-bottom: none; } + +.wefax-schedule-entry.active { + background: #ffaa0010; + border-left: 3px solid #ffaa00; +} + +.wefax-schedule-entry.upcoming { + background: #ffaa0008; +} + +.wefax-schedule-entry.past { + opacity: 0.4; +} + +.wefax-schedule-time { + color: #ffaa00; + min-width: 45px; + font-variant-numeric: tabular-nums; +} + +.wefax-schedule-content { + color: var(--text-primary, #e0e0e0); + flex: 1; +} + +.wefax-schedule-badge { + font-size: 9px; + padding: 1px 6px; + border-radius: 3px; + background: var(--border-color, #1e2a3a); + color: var(--text-dim, #555); +} + +.wefax-schedule-badge.live { + background: #ffaa0030; + color: #ffaa00; + font-weight: 600; +} + +.wefax-schedule-badge.soon { + background: #ffaa0015; + color: #ffcc66; +} + +.wefax-schedule-empty { + padding: 16px; + text-align: center; + color: var(--text-dim, #555); + font-size: 11px; + font-family: var(--font-mono, monospace); +} + +/* --- Live Section --- */ +.wefax-live-section { + background: var(--bg-card, #0e1117); + border: 1px solid var(--border-color, #1e2a3a); + border-radius: 6px; + overflow: hidden; +} + +.wefax-live-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid var(--border-color, #1e2a3a); +} + +.wefax-live-title { + font-family: var(--font-mono, monospace); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: #ffaa00; +} + +.wefax-live-content { + padding: 12px; + min-height: 200px; + display: flex; + align-items: center; + justify-content: center; +} + +.wefax-idle-state { + text-align: center; + color: var(--text-dim, #555); +} + +.wefax-idle-state svg { + width: 48px; + height: 48px; + color: #ffaa0033; + margin-bottom: 12px; +} + +.wefax-idle-state h4 { + margin: 0 0 4px; + color: var(--text-primary, #e0e0e0); + font-size: 13px; +} + +.wefax-idle-state p { + margin: 0; + font-size: 11px; +} + +.wefax-live-preview { + max-width: 100%; + max-height: 400px; + border-radius: 4px; + image-rendering: pixelated; +} + +/* --- Gallery Section --- */ +.wefax-gallery-section { + background: var(--bg-card, #0e1117); + border: 1px solid var(--border-color, #1e2a3a); + border-radius: 6px; + overflow: hidden; +} + +.wefax-gallery-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid var(--border-color, #1e2a3a); +} + +.wefax-gallery-title { + font-family: var(--font-mono, monospace); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: #ffaa00; +} + +.wefax-gallery-controls { + display: flex; + align-items: center; + gap: 8px; +} + +.wefax-gallery-count { + font-family: var(--font-mono, monospace); + font-size: 11px; + color: var(--text-dim, #555); +} + +.wefax-gallery-clear-btn { + font-family: var(--font-mono, monospace); + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.5px; + background: none; + border: 1px solid var(--border-color, #1e2a3a); + color: var(--text-dim, #555); + padding: 2px 8px; + border-radius: 3px; + cursor: pointer; +} + +.wefax-gallery-clear-btn:hover { + border-color: #f44; + color: #f44; +} + +.wefax-gallery-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 8px; + padding: 10px; + max-height: 500px; + overflow-y: auto; +} + +.wefax-gallery-empty { + padding: 24px; + text-align: center; + color: var(--text-dim, #555); + font-size: 11px; + font-family: var(--font-mono, monospace); + grid-column: 1 / -1; +} + +.wefax-gallery-item { + position: relative; + background: var(--bg-primary, #161b22); + border: 1px solid var(--border-color, #1e2a3a); + border-radius: 4px; + overflow: hidden; +} + +.wefax-gallery-item img { + width: 100%; + aspect-ratio: 4/3; + object-fit: cover; + cursor: pointer; + display: block; +} + +.wefax-gallery-item img:hover { + opacity: 0.85; +} + +.wefax-gallery-meta { + padding: 4px 6px; + display: flex; + flex-direction: column; + gap: 1px; + font-family: var(--font-mono, monospace); + font-size: 9px; + color: var(--text-dim, #555); +} + +.wefax-gallery-actions { + position: absolute; + top: 4px; + right: 4px; + display: flex; + gap: 4px; + opacity: 0; + transition: opacity 0.15s; +} + +.wefax-gallery-item:hover .wefax-gallery-actions { + opacity: 1; +} + +.wefax-gallery-action { + width: 22px; + height: 22px; + border-radius: 3px; + border: none; + background: rgba(0, 0, 0, 0.7); + color: #ccc; + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + text-decoration: none; +} + +.wefax-gallery-action:hover { color: #fff; } +.wefax-gallery-action.delete:hover { color: #f44; } + +/* --- Responsive --- */ +@media (max-width: 768px) { + .wefax-main-row { + grid-template-columns: 1fr; + } +} diff --git a/static/js/modes/wefax.js b/static/js/modes/wefax.js new file mode 100644 index 0000000..c93e41a --- /dev/null +++ b/static/js/modes/wefax.js @@ -0,0 +1,671 @@ +/** + * WeFax (Weather Fax) decoder module. + * + * IIFE providing start/stop controls, station selector, broadcast + * schedule timeline, live image preview, decoded image gallery, + * and audio waveform scope. + */ +var WeFax = (function () { + 'use strict'; + + var state = { + running: false, + initialized: false, + eventSource: null, + stations: [], + images: [], + selectedStation: null, + pollTimer: null, + }; + + // ---- Scope state ---- + + var scopeCtx = null; + var scopeAnim = null; + var scopeHistory = []; + var scopeWaveBuffer = []; + var scopeDisplayWave = []; + var SCOPE_HISTORY_LEN = 200; + var SCOPE_WAVE_BUFFER_LEN = 2048; + var SCOPE_WAVE_INPUT_SMOOTH = 0.55; + var SCOPE_WAVE_DISPLAY_SMOOTH = 0.22; + var SCOPE_WAVE_IDLE_DECAY = 0.96; + var scopeRms = 0; + var scopePeak = 0; + var scopeTargetRms = 0; + var scopeTargetPeak = 0; + var scopeLastWaveAt = 0; + var scopeLastInputSample = 0; + var scopeImageBurst = 0; + + // ---- Initialisation ---- + + function init() { + if (state.initialized) { + // Re-render cached data immediately so UI isn't empty + if (state.stations.length) renderStationDropdown(); + loadImages(); + return; + } + state.initialized = true; + loadStations(); + loadImages(); + } + + function destroy() { + disconnectSSE(); + stopScope(); + if (state.pollTimer) { + clearInterval(state.pollTimer); + state.pollTimer = null; + } + } + + // ---- Stations ---- + + function loadStations() { + fetch('/wefax/stations') + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.status === 'ok' && data.stations) { + state.stations = data.stations; + renderStationDropdown(); + } + }) + .catch(function (err) { + console.error('WeFax: failed to load stations', err); + }); + } + + function renderStationDropdown() { + var sel = document.getElementById('wefaxStation'); + if (!sel) return; + + // Keep the placeholder + sel.innerHTML = ''; + + state.stations.forEach(function (s) { + var opt = document.createElement('option'); + opt.value = s.callsign; + opt.textContent = s.callsign + ' — ' + s.name + ' (' + s.country + ')'; + sel.appendChild(opt); + }); + } + + function onStationChange() { + var sel = document.getElementById('wefaxStation'); + var callsign = sel ? sel.value : ''; + + if (!callsign) { + state.selectedStation = null; + renderFrequencyDropdown([]); + renderScheduleTimeline([]); + return; + } + + var station = state.stations.find(function (s) { return s.callsign === callsign; }); + state.selectedStation = station || null; + + if (station) { + renderFrequencyDropdown(station.frequencies || []); + // Set IOC/LPM from station defaults + var iocSel = document.getElementById('wefaxIOC'); + var lpmSel = document.getElementById('wefaxLPM'); + if (iocSel && station.ioc) iocSel.value = String(station.ioc); + if (lpmSel && station.lpm) lpmSel.value = String(station.lpm); + renderScheduleTimeline(station.schedule || []); + } + } + + function renderFrequencyDropdown(frequencies) { + var sel = document.getElementById('wefaxFrequency'); + if (!sel) return; + + sel.innerHTML = ''; + + if (frequencies.length === 0) { + var opt = document.createElement('option'); + opt.value = ''; + opt.textContent = 'Select station first'; + sel.appendChild(opt); + return; + } + + frequencies.forEach(function (f) { + var opt = document.createElement('option'); + opt.value = String(f.khz); + opt.textContent = f.khz + ' kHz — ' + f.description; + sel.appendChild(opt); + }); + } + + // ---- Start / Stop ---- + + function start() { + if (state.running) return; + + var freqSel = document.getElementById('wefaxFrequency'); + var freqKhz = freqSel ? parseFloat(freqSel.value) : 0; + if (!freqKhz || isNaN(freqKhz)) { + setStatus('Select a station and frequency first'); + return; + } + + var stationSel = document.getElementById('wefaxStation'); + var station = stationSel ? stationSel.value : ''; + var iocSel = document.getElementById('wefaxIOC'); + var lpmSel = document.getElementById('wefaxLPM'); + var gainInput = document.getElementById('wefaxGain'); + var dsCheckbox = document.getElementById('wefaxDirectSampling'); + + var deviceSel = document.getElementById('rtlDevice'); + var device = deviceSel ? parseInt(deviceSel.value, 10) || 0 : 0; + + var body = { + frequency_khz: freqKhz, + station: station, + device: device, + gain: gainInput ? parseFloat(gainInput.value) || 40 : 40, + ioc: iocSel ? parseInt(iocSel.value, 10) : 576, + lpm: lpmSel ? parseInt(lpmSel.value, 10) : 120, + direct_sampling: dsCheckbox ? dsCheckbox.checked : true, + }; + + fetch('/wefax/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.status === 'started' || data.status === 'already_running') { + state.running = true; + updateButtons(true); + setStatus('Scanning ' + freqKhz + ' kHz...'); + setStripFreq(freqKhz); + connectSSE(); + } else { + setStatus('Error: ' + (data.message || 'unknown')); + } + }) + .catch(function (err) { + setStatus('Error: ' + err.message); + }); + } + + function stop() { + fetch('/wefax/stop', { method: 'POST' }) + .then(function (r) { return r.json(); }) + .then(function () { + state.running = false; + updateButtons(false); + setStatus('Stopped'); + disconnectSSE(); + loadImages(); + }) + .catch(function (err) { + console.error('WeFax stop error:', err); + }); + } + + // ---- SSE ---- + + function connectSSE() { + disconnectSSE(); + + var es = new EventSource('/wefax/stream'); + state.eventSource = es; + + es.onmessage = function (evt) { + try { + var data = JSON.parse(evt.data); + if (data.type === 'scope') { + applyScopeData(data); + } else { + handleProgress(data); + } + } catch (e) { /* ignore keepalives */ } + }; + + es.onerror = function () { + // EventSource will auto-reconnect + }; + + // Show scope and start animation + var panel = document.getElementById('wefaxScopePanel'); + if (panel) panel.style.display = 'block'; + initScope(); + } + + function disconnectSSE() { + if (state.eventSource) { + state.eventSource.close(); + state.eventSource = null; + } + stopScope(); + var panel = document.getElementById('wefaxScopePanel'); + if (panel) panel.style.display = 'none'; + } + + function handleProgress(data) { + if (data.type !== 'wefax_progress') return; + + var statusText = data.message || data.status || ''; + setStatus(statusText); + + var dot = document.getElementById('wefaxStripDot'); + if (dot) { + dot.className = 'wefax-strip-dot ' + (data.status || 'idle'); + } + + var statusEl = document.getElementById('wefaxStripStatus'); + if (statusEl) { + var labels = { + scanning: 'Scanning', + phasing: 'Phasing', + receiving: 'Receiving', + complete: 'Complete', + error: 'Error', + stopped: 'Idle', + }; + statusEl.textContent = labels[data.status] || data.status || 'Idle'; + } + + // Update line count + if (data.line_count) { + var lineEl = document.getElementById('wefaxStripLines'); + if (lineEl) lineEl.textContent = String(data.line_count); + } + + // Live preview + if (data.partial_image) { + var previewEl = document.getElementById('wefaxLivePreview'); + if (previewEl) { + previewEl.src = data.partial_image; + previewEl.style.display = 'block'; + } + var idleEl = document.getElementById('wefaxIdleState'); + if (idleEl) idleEl.style.display = 'none'; + } + + // Image complete + if (data.status === 'complete' && data.image) { + scopeImageBurst = 1.0; + loadImages(); + setStatus('Image decoded: ' + (data.line_count || '?') + ' lines'); + } + + if (data.status === 'error') { + state.running = false; + updateButtons(false); + } + + if (data.status === 'stopped') { + state.running = false; + updateButtons(false); + } + } + + // ---- Audio Waveform Scope ---- + + function initScope() { + var canvas = document.getElementById('wefaxScopeCanvas'); + if (!canvas) return; + + if (scopeAnim) { cancelAnimationFrame(scopeAnim); scopeAnim = null; } + + resizeScopeCanvas(canvas); + scopeCtx = canvas.getContext('2d'); + scopeHistory = new Array(SCOPE_HISTORY_LEN).fill(0); + scopeWaveBuffer = []; + scopeDisplayWave = []; + scopeRms = scopePeak = scopeTargetRms = scopeTargetPeak = 0; + scopeImageBurst = scopeLastWaveAt = scopeLastInputSample = 0; + drawScope(); + } + + function stopScope() { + if (scopeAnim) { cancelAnimationFrame(scopeAnim); scopeAnim = null; } + scopeCtx = null; + scopeWaveBuffer = []; + scopeDisplayWave = []; + scopeHistory = []; + scopeLastWaveAt = 0; + scopeLastInputSample = 0; + } + + function resizeScopeCanvas(canvas) { + if (!canvas) return; + var rect = canvas.getBoundingClientRect(); + var dpr = window.devicePixelRatio || 1; + var width = Math.max(1, Math.floor(rect.width * dpr)); + var height = Math.max(1, Math.floor(rect.height * dpr)); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + } + + function applyScopeData(scopeData) { + if (!scopeData || typeof scopeData !== 'object') return; + + scopeTargetRms = Number(scopeData.rms) || 0; + scopeTargetPeak = Number(scopeData.peak) || 0; + + if (Array.isArray(scopeData.waveform) && scopeData.waveform.length) { + for (var i = 0; i < scopeData.waveform.length; i++) { + var sample = Number(scopeData.waveform[i]); + if (!isFinite(sample)) continue; + var normalized = Math.max(-127, Math.min(127, sample)) / 127; + scopeLastInputSample += (normalized - scopeLastInputSample) * SCOPE_WAVE_INPUT_SMOOTH; + scopeWaveBuffer.push(scopeLastInputSample); + } + if (scopeWaveBuffer.length > SCOPE_WAVE_BUFFER_LEN) { + scopeWaveBuffer.splice(0, scopeWaveBuffer.length - SCOPE_WAVE_BUFFER_LEN); + } + scopeLastWaveAt = performance.now(); + } + } + + function drawScope() { + var ctx = scopeCtx; + if (!ctx) return; + + resizeScopeCanvas(ctx.canvas); + var W = ctx.canvas.width, H = ctx.canvas.height, midY = H / 2; + + // Phosphor persistence + ctx.fillStyle = 'rgba(5, 5, 16, 0.26)'; + ctx.fillRect(0, 0, W, H); + + // Smooth RMS/Peak + scopeRms += (scopeTargetRms - scopeRms) * 0.25; + scopePeak += (scopeTargetPeak - scopePeak) * 0.15; + + // Rolling envelope + scopeHistory.push(Math.min(scopeRms / 32768, 1.0)); + if (scopeHistory.length > SCOPE_HISTORY_LEN) scopeHistory.shift(); + + // Grid lines + ctx.strokeStyle = 'rgba(40, 40, 80, 0.4)'; + ctx.lineWidth = 0.8; + var gx, gy; + for (var i = 1; i < 8; i++) { + gx = (W / 8) * i; + ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, H); ctx.stroke(); + } + for (var g = 0.25; g < 1; g += 0.25) { + gy = midY - g * midY; + var gy2 = midY + g * midY; + ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(W, gy); + ctx.moveTo(0, gy2); ctx.lineTo(W, gy2); ctx.stroke(); + } + + // Center baseline + ctx.strokeStyle = 'rgba(60, 60, 100, 0.5)'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(0, midY); ctx.lineTo(W, midY); ctx.stroke(); + + // Amplitude envelope (amber tint) + var envStepX = W / (SCOPE_HISTORY_LEN - 1); + ctx.strokeStyle = 'rgba(255, 170, 0, 0.35)'; + ctx.lineWidth = 1; + ctx.beginPath(); + for (var ei = 0; ei < scopeHistory.length; ei++) { + var ex = ei * envStepX, amp = scopeHistory[ei] * midY * 0.85; + if (ei === 0) ctx.moveTo(ex, midY - amp); else ctx.lineTo(ex, midY - amp); + } + ctx.stroke(); + ctx.beginPath(); + for (var ej = 0; ej < scopeHistory.length; ej++) { + var ex2 = ej * envStepX, amp2 = scopeHistory[ej] * midY * 0.85; + if (ej === 0) ctx.moveTo(ex2, midY + amp2); else ctx.lineTo(ex2, midY + amp2); + } + ctx.stroke(); + + // Waveform trace (amber) + var wavePoints = Math.min(Math.max(120, Math.floor(W / 3.2)), 420); + if (scopeWaveBuffer.length > 1) { + var waveIsFresh = (performance.now() - scopeLastWaveAt) < 700; + var srcLen = scopeWaveBuffer.length; + var srcWindow = Math.min(srcLen, 1536); + var srcStart = srcLen - srcWindow; + + if (scopeDisplayWave.length !== wavePoints) { + scopeDisplayWave = new Array(wavePoints).fill(0); + } + + for (var wi = 0; wi < wavePoints; wi++) { + var a = srcStart + Math.floor((wi / wavePoints) * srcWindow); + var b = srcStart + Math.floor(((wi + 1) / wavePoints) * srcWindow); + var start = Math.max(srcStart, Math.min(srcLen - 1, a)); + var end = Math.max(start + 1, Math.min(srcLen, b)); + var sum = 0, count = 0; + for (var j = start; j < end; j++) { sum += scopeWaveBuffer[j]; count++; } + var targetSample = count > 0 ? sum / count : 0; + scopeDisplayWave[wi] += (targetSample - scopeDisplayWave[wi]) * SCOPE_WAVE_DISPLAY_SMOOTH; + } + + ctx.strokeStyle = waveIsFresh ? '#ffaa00' : 'rgba(255, 170, 0, 0.45)'; + ctx.lineWidth = 1.7; + ctx.shadowColor = '#ffaa00'; + ctx.shadowBlur = waveIsFresh ? 6 : 2; + + var stepX = wavePoints > 1 ? W / (wavePoints - 1) : W; + ctx.beginPath(); + ctx.moveTo(0, midY - scopeDisplayWave[0] * midY * 0.9); + for (var qi = 1; qi < wavePoints - 1; qi++) { + var x = qi * stepX, y = midY - scopeDisplayWave[qi] * midY * 0.9; + var nx = (qi + 1) * stepX, ny = midY - scopeDisplayWave[qi + 1] * midY * 0.9; + ctx.quadraticCurveTo(x, y, (x + nx) / 2, (y + ny) / 2); + } + ctx.lineTo((wavePoints - 1) * stepX, + midY - scopeDisplayWave[wavePoints - 1] * midY * 0.9); + ctx.stroke(); + + if (!waveIsFresh) { + for (var di = 0; di < scopeDisplayWave.length; di++) { + scopeDisplayWave[di] *= SCOPE_WAVE_IDLE_DECAY; + } + } + } + ctx.shadowBlur = 0; + + // Peak indicator + var peakNorm = Math.min(scopePeak / 32768, 1.0); + if (peakNorm > 0.01) { + var peakY = midY - peakNorm * midY * 0.9; + ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); ctx.moveTo(0, peakY); ctx.lineTo(W, peakY); ctx.stroke(); + ctx.setLineDash([]); + } + + // Image-decoded flash (amber overlay) + if (scopeImageBurst > 0.01) { + ctx.fillStyle = 'rgba(255, 170, 0, ' + (scopeImageBurst * 0.15) + ')'; + ctx.fillRect(0, 0, W, H); + scopeImageBurst *= 0.88; + } + + // Label updates + var rmsLabel = document.getElementById('wefaxScopeRmsLabel'); + var peakLabel = document.getElementById('wefaxScopePeakLabel'); + var statusLabel = document.getElementById('wefaxScopeStatusLabel'); + if (rmsLabel) rmsLabel.textContent = Math.round(scopeRms); + if (peakLabel) peakLabel.textContent = Math.round(scopePeak); + if (statusLabel) { + var fresh = (performance.now() - scopeLastWaveAt) < 700; + if (fresh && scopeRms > 1300) { + statusLabel.textContent = 'DEMODULATING'; + statusLabel.style.color = '#ffaa00'; + } else if (fresh && scopeRms > 500) { + statusLabel.textContent = 'CARRIER'; + statusLabel.style.color = '#cc8800'; + } else if (fresh) { + statusLabel.textContent = 'QUIET'; + statusLabel.style.color = '#666'; + } else { + statusLabel.textContent = 'IDLE'; + statusLabel.style.color = '#444'; + } + } + + scopeAnim = requestAnimationFrame(drawScope); + } + + // ---- Images ---- + + function loadImages() { + fetch('/wefax/images') + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.status === 'ok') { + state.images = data.images || []; + renderImageGallery(); + var countEl = document.getElementById('wefaxImageCount'); + if (countEl) countEl.textContent = String(state.images.length); + var stripCount = document.getElementById('wefaxStripImageCount'); + if (stripCount) stripCount.textContent = String(state.images.length); + } + }) + .catch(function (err) { + console.error('WeFax: failed to load images', err); + }); + } + + function renderImageGallery() { + var gallery = document.getElementById('wefaxGallery'); + if (!gallery) return; + + if (state.images.length === 0) { + gallery.innerHTML = ''; + return; + } + + var html = ''; + // Show newest first + var sorted = state.images.slice().reverse(); + sorted.forEach(function (img) { + var ts = img.timestamp ? new Date(img.timestamp).toLocaleString() : ''; + var station = img.station || ''; + var freq = img.frequency_khz ? (img.frequency_khz + ' kHz') : ''; + html += ''; + }); + gallery.innerHTML = html; + } + + function deleteImage(filename) { + fetch('/wefax/images/' + encodeURIComponent(filename), { method: 'DELETE' }) + .then(function () { loadImages(); }) + .catch(function (err) { console.error('WeFax delete error:', err); }); + } + + function deleteAllImages() { + if (!confirm('Delete all WeFax images?')) return; + fetch('/wefax/images', { method: 'DELETE' }) + .then(function () { loadImages(); }) + .catch(function (err) { console.error('WeFax delete all error:', err); }); + } + + function viewImage(url) { + // Open image in modal or new tab + window.open(url, '_blank'); + } + + // ---- Schedule Timeline ---- + + function renderScheduleTimeline(schedule) { + var container = document.getElementById('wefaxScheduleTimeline'); + if (!container) return; + + if (!schedule || schedule.length === 0) { + container.innerHTML = '
Select a station to see broadcast schedule
'; + return; + } + + var now = new Date(); + var nowMin = now.getUTCHours() * 60 + now.getUTCMinutes(); + + var html = '
'; + schedule.forEach(function (entry) { + var parts = entry.utc.split(':'); + var entryMin = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10); + var diff = entryMin - nowMin; + if (diff < -720) diff += 1440; + if (diff > 720) diff -= 1440; + + var cls = 'wefax-schedule-entry'; + var badge = ''; + if (diff >= 0 && diff <= entry.duration_min) { + cls += ' active'; + badge = 'LIVE'; + } else if (diff > 0 && diff <= 60) { + cls += ' upcoming'; + badge = '' + diff + 'm'; + } else if (diff > 0) { + badge = '' + Math.floor(diff / 60) + 'h ' + (diff % 60) + 'm'; + } else { + cls += ' past'; + } + + html += '
'; + html += '' + entry.utc + ''; + html += '' + entry.content + ''; + html += badge; + html += '
'; + }); + html += '
'; + container.innerHTML = html; + } + + // ---- UI helpers ---- + + function updateButtons(running) { + var startBtn = document.getElementById('wefaxStartBtn'); + var stopBtn = document.getElementById('wefaxStopBtn'); + if (startBtn) startBtn.style.display = running ? 'none' : 'inline-flex'; + if (stopBtn) stopBtn.style.display = running ? 'inline-flex' : 'none'; + + var dot = document.getElementById('wefaxStripDot'); + if (dot) dot.className = 'wefax-strip-dot ' + (running ? 'scanning' : 'idle'); + + var statusEl = document.getElementById('wefaxStripStatus'); + if (statusEl && !running) statusEl.textContent = 'Idle'; + } + + function setStatus(msg) { + var el = document.getElementById('wefaxStatusText'); + if (el) el.textContent = msg; + } + + function setStripFreq(khz) { + var el = document.getElementById('wefaxStripFreq'); + if (el) el.textContent = String(khz); + } + + // ---- Public API ---- + + return { + init: init, + destroy: destroy, + start: start, + stop: stop, + onStationChange: onStationChange, + loadImages: loadImages, + deleteImage: deleteImage, + deleteAllImages: deleteAllImages, + viewImage: viewImage, + }; +})(); diff --git a/templates/index.html b/templates/index.html index c41c28b..7bffae6 100644 --- a/templates/index.html +++ b/templates/index.html @@ -79,7 +79,8 @@ gps: "{{ url_for('static', filename='css/modes/gps.css') }}", subghz: "{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9", bt_locate: "{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate4", - spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}" + spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}", + wefax: "{{ url_for('static', filename='css/modes/wefax.css') }}" }; window.INTERCEPT_MODE_STYLE_LOADED = {}; window.ensureModeStyles = function(mode) { @@ -264,6 +265,10 @@ HF SSTV + + + +
+
+
+ --- + KHZ +
+
+ 0 + LINES +
+
+ 0 + IMAGES +
+
+ + + + + + +
+
+ Broadcast Schedule + +
+
+
Select a station to see broadcast schedule
+
+
+ + +
+
+
+
+ + + + + Live Decode +
+
+
+
+ + + + + + +

WeFax Decoder

+

Select a station and click Start to decode weather fax transmissions

+
+ +
+
+ + +
+ + @@ -213,6 +214,7 @@ {% endif %} {{ mobile_item('sstv', 'SSTV', '') }} {{ mobile_item('weathersat', 'WxSat', '') }} + {{ mobile_item('wefax', 'WeFax', '') }} {{ mobile_item('sstv_general', 'HF SSTV', '') }} {{ mobile_item('spaceweather', 'SpaceWx', '') }} {# Wireless #} diff --git a/tests/test_wefax.py b/tests/test_wefax.py new file mode 100644 index 0000000..4ac3b72 --- /dev/null +++ b/tests/test_wefax.py @@ -0,0 +1,430 @@ +"""Tests for WeFax (Weather Fax) routes, decoder, and station loader.""" + +from __future__ import annotations + +import json +import math +from pathlib import Path +from unittest.mock import MagicMock, patch + +import numpy as np + + +def _login_session(client) -> None: + """Mark the Flask test session as authenticated.""" + with client.session_transaction() as sess: + sess['logged_in'] = True + sess['username'] = 'test' + sess['role'] = 'admin' + + +# --------------------------------------------------------------------------- +# Station database tests +# --------------------------------------------------------------------------- + +class TestWeFaxStations: + """WeFax station database tests.""" + + def test_load_stations_returns_list(self): + """load_stations() should return a non-empty list.""" + from utils.wefax_stations import load_stations + stations = load_stations() + assert isinstance(stations, list) + assert len(stations) >= 10 + + def test_station_has_required_fields(self): + """Each station must have required fields.""" + from utils.wefax_stations import load_stations + required = {'name', 'callsign', 'country', 'city', 'coordinates', + 'frequencies', 'ioc', 'lpm', 'schedule'} + for station in load_stations(): + missing = required - set(station.keys()) + assert not missing, f"Station {station.get('callsign', '?')} missing: {missing}" + + def test_get_station_by_callsign(self): + """get_station() should return correct station.""" + from utils.wefax_stations import get_station + station = get_station('NOJ') + assert station is not None + assert station['callsign'] == 'NOJ' + assert station['country'] == 'US' + + def test_get_station_case_insensitive(self): + """get_station() should be case-insensitive.""" + from utils.wefax_stations import get_station + assert get_station('noj') is not None + + def test_get_station_not_found(self): + """get_station() should return None for unknown callsign.""" + from utils.wefax_stations import get_station + assert get_station('XXXXX') is None + + def test_station_frequencies_have_khz(self): + """Each frequency entry must have 'khz' and 'description'.""" + from utils.wefax_stations import load_stations + for station in load_stations(): + for freq in station['frequencies']: + assert 'khz' in freq, f"{station['callsign']} missing khz" + assert 'description' in freq, f"{station['callsign']} missing description" + assert isinstance(freq['khz'], (int, float)) + assert freq['khz'] > 0 + + def test_schedule_format(self): + """Schedule entries must have utc, duration_min, content.""" + from utils.wefax_stations import load_stations + for station in load_stations(): + for entry in station['schedule']: + assert 'utc' in entry + assert 'duration_min' in entry + assert 'content' in entry + # UTC format: HH:MM + parts = entry['utc'].split(':') + assert len(parts) == 2 + assert 0 <= int(parts[0]) <= 23 + assert 0 <= int(parts[1]) <= 59 + + def test_get_current_broadcasts(self): + """get_current_broadcasts() should return up to 3 entries.""" + from utils.wefax_stations import get_current_broadcasts + broadcasts = get_current_broadcasts('NOJ') + assert isinstance(broadcasts, list) + assert len(broadcasts) <= 3 + for b in broadcasts: + assert 'utc' in b + assert 'content' in b + + +# --------------------------------------------------------------------------- +# Decoder unit tests +# --------------------------------------------------------------------------- + +class TestWeFaxDecoder: + """WeFax decoder DSP and data class tests.""" + + def test_freq_to_pixel_black(self): + """1500 Hz should map to 0 (black).""" + from utils.wefax import _freq_to_pixel + assert _freq_to_pixel(1500.0) == 0 + + def test_freq_to_pixel_white(self): + """2300 Hz should map to 255 (white).""" + from utils.wefax import _freq_to_pixel + assert _freq_to_pixel(2300.0) == 255 + + def test_freq_to_pixel_mid(self): + """1900 Hz (carrier) should map to ~128.""" + from utils.wefax import _freq_to_pixel + val = _freq_to_pixel(1900.0) + assert 120 <= val <= 135 + + def test_freq_to_pixel_clamp_low(self): + """Below 1500 Hz should clamp to 0.""" + from utils.wefax import _freq_to_pixel + assert _freq_to_pixel(1000.0) == 0 + + def test_freq_to_pixel_clamp_high(self): + """Above 2300 Hz should clamp to 255.""" + from utils.wefax import _freq_to_pixel + assert _freq_to_pixel(3000.0) == 255 + + def test_ioc_576_pixel_count(self): + """IOC 576 should give pi*576 ≈ 1809 pixels per line.""" + pixels = int(math.pi * 576) + assert pixels == 1809 + + def test_ioc_288_pixel_count(self): + """IOC 288 should give pi*288 ≈ 904 pixels per line.""" + pixels = int(math.pi * 288) + assert pixels == 904 + + def test_goertzel_mag_detects_tone(self): + """Goertzel should detect a pure tone.""" + from utils.wefax import _goertzel_mag + sr = 22050 + freq = 1900.0 + t = np.arange(sr) / sr + samples = np.sin(2 * np.pi * freq * t) + mag = _goertzel_mag(samples[:2205], freq, sr) + # Should be significantly non-zero for a matching tone + assert mag > 1.0 + + def test_goertzel_mag_rejects_wrong_freq(self): + """Goertzel should be much weaker for non-matching frequency.""" + from utils.wefax import _goertzel_mag + sr = 22050 + t = np.arange(sr) / sr + samples = np.sin(2 * np.pi * 1900.0 * t) + mag_match = _goertzel_mag(samples[:2205], 1900.0, sr) + mag_off = _goertzel_mag(samples[:2205], 300.0, sr) + assert mag_match > mag_off * 5 + + def test_detect_tone_start(self): + """detect_tone should identify a 300 Hz start tone.""" + from utils.wefax import _detect_tone + sr = 22050 + t = np.arange(sr) / sr + samples = np.sin(2 * np.pi * 300.0 * t) + assert _detect_tone(samples[:2205], 300.0, sr, threshold=2.0) + + def test_wefax_image_to_dict(self): + """WeFaxImage.to_dict() should produce expected format.""" + from datetime import datetime, timezone + + from utils.wefax import WeFaxImage + img = WeFaxImage( + filename='test.png', + path=Path('/tmp/test.png'), + station='NOJ', + frequency_khz=4298, + timestamp=datetime(2026, 1, 1, tzinfo=timezone.utc), + ioc=576, + lpm=120, + size_bytes=1234, + ) + d = img.to_dict() + assert d['filename'] == 'test.png' + assert d['station'] == 'NOJ' + assert d['frequency_khz'] == 4298 + assert d['ioc'] == 576 + assert d['url'] == '/wefax/images/test.png' + + def test_wefax_progress_to_dict(self): + """WeFaxProgress.to_dict() should produce expected format.""" + from utils.wefax import WeFaxProgress + p = WeFaxProgress( + status='receiving', + station='NOJ', + message='Receiving: 100 lines', + progress_percent=50, + line_count=100, + ) + d = p.to_dict() + assert d['type'] == 'wefax_progress' + assert d['status'] == 'receiving' + assert d['progress'] == 50 + assert d['station'] == 'NOJ' + assert d['line_count'] == 100 + + def test_singleton_returns_same_instance(self, tmp_path): + """get_wefax_decoder() should return a singleton.""" + from utils.wefax import WeFaxDecoder + # Use __new__ to avoid __init__ creating dirs + d1 = WeFaxDecoder.__new__(WeFaxDecoder) + # Test the module-level singleton pattern + import utils.wefax as wefax_mod + original = wefax_mod._decoder + try: + wefax_mod._decoder = d1 + assert wefax_mod.get_wefax_decoder() is d1 + assert wefax_mod.get_wefax_decoder() is d1 + finally: + wefax_mod._decoder = original + + +# --------------------------------------------------------------------------- +# Route tests +# --------------------------------------------------------------------------- + +class TestWeFaxRoutes: + """WeFax route endpoint tests.""" + + def test_status(self, client): + """GET /wefax/status should return decoder status.""" + _login_session(client) + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_decoder.get_images.return_value = [] + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + response = client.get('/wefax/status') + + assert response.status_code == 200 + data = response.get_json() + assert data['available'] is True + assert data['running'] is False + + def test_stations_list(self, client): + """GET /wefax/stations should return station list.""" + _login_session(client) + response = client.get('/wefax/stations') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'ok' + assert data['count'] >= 10 + + def test_station_detail(self, client): + """GET /wefax/stations/NOJ should return station detail.""" + _login_session(client) + response = client.get('/wefax/stations/NOJ') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'ok' + assert data['station']['callsign'] == 'NOJ' + assert 'current_broadcasts' in data + + def test_station_not_found(self, client): + """GET /wefax/stations/XXXXX should return 404.""" + _login_session(client) + response = client.get('/wefax/stations/XXXXX') + assert response.status_code == 404 + + def test_start_requires_frequency(self, client): + """POST /wefax/start without frequency should fail.""" + _login_session(client) + mock_decoder = MagicMock() + mock_decoder.is_running = False + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + response = client.post( + '/wefax/start', + data=json.dumps({}), + content_type='application/json', + ) + + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + + def test_start_validates_frequency_range(self, client): + """POST /wefax/start with out-of-range frequency should fail.""" + _login_session(client) + mock_decoder = MagicMock() + mock_decoder.is_running = False + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + response = client.post( + '/wefax/start', + data=json.dumps({'frequency_khz': 100}), # 0.1 MHz - too low + content_type='application/json', + ) + + assert response.status_code == 400 + + def test_start_validates_ioc(self, client): + """POST /wefax/start with invalid IOC should fail.""" + _login_session(client) + mock_decoder = MagicMock() + mock_decoder.is_running = False + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + response = client.post( + '/wefax/start', + data=json.dumps({'frequency_khz': 4298, 'ioc': 999}), + content_type='application/json', + ) + + assert response.status_code == 400 + data = response.get_json() + assert 'IOC' in data['message'] + + def test_start_validates_lpm(self, client): + """POST /wefax/start with invalid LPM should fail.""" + _login_session(client) + mock_decoder = MagicMock() + mock_decoder.is_running = False + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + response = client.post( + '/wefax/start', + data=json.dumps({'frequency_khz': 4298, 'lpm': 999}), + content_type='application/json', + ) + + assert response.status_code == 400 + data = response.get_json() + assert 'LPM' in data['message'] + + def test_start_success(self, client): + """POST /wefax/start with valid params should succeed.""" + _login_session(client) + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_decoder.start.return_value = True + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \ + patch('routes.wefax.app_module.claim_sdr_device', return_value=None): + response = client.post( + '/wefax/start', + data=json.dumps({ + 'frequency_khz': 4298, + 'station': 'NOJ', + 'device': 0, + 'ioc': 576, + 'lpm': 120, + }), + content_type='application/json', + ) + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'started' + assert data['frequency_khz'] == 4298 + assert data['station'] == 'NOJ' + mock_decoder.start.assert_called_once() + + def test_start_device_busy(self, client): + """POST /wefax/start should return 409 when device is busy.""" + _login_session(client) + mock_decoder = MagicMock() + mock_decoder.is_running = False + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \ + patch('routes.wefax.app_module.claim_sdr_device', + return_value='Device 0 in use by pager'): + response = client.post( + '/wefax/start', + data=json.dumps({'frequency_khz': 4298}), + content_type='application/json', + ) + + assert response.status_code == 409 + data = response.get_json() + assert data['error_type'] == 'DEVICE_BUSY' + + def test_stop(self, client): + """POST /wefax/stop should stop the decoder.""" + _login_session(client) + mock_decoder = MagicMock() + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + response = client.post('/wefax/stop') + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'stopped' + mock_decoder.stop.assert_called_once() + + def test_images_list(self, client): + """GET /wefax/images should return image list.""" + _login_session(client) + mock_decoder = MagicMock() + mock_decoder.get_images.return_value = [] + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + response = client.get('/wefax/images') + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'ok' + assert data['count'] == 0 + + def test_delete_image_invalid_filename(self, client): + """DELETE /wefax/images/ should reject invalid filenames.""" + _login_session(client) + mock_decoder = MagicMock() + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + # Use a filename with special chars that won't be split by Flask routing + response = client.delete('/wefax/images/te$t!file.png') + + assert response.status_code == 400 + + def test_delete_image_wrong_extension(self, client): + """DELETE /wefax/images/ should reject non-PNG.""" + _login_session(client) + mock_decoder = MagicMock() + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + response = client.delete('/wefax/images/test.jpg') + + assert response.status_code == 400 diff --git a/utils/wefax.py b/utils/wefax.py new file mode 100644 index 0000000..d825b42 --- /dev/null +++ b/utils/wefax.py @@ -0,0 +1,754 @@ +"""WeFax (Weather Fax) decoder. + +Decodes HF radiofax (weather fax) transmissions using RTL-SDR direct +sampling mode. The decoder implements the standard WeFax AM protocol: +carrier 1900 Hz, deviation +/-400 Hz (black=1500, white=2300). + +Pipeline: rtl_fm -M usb -E direct2 -> stdout PCM -> Python DSP state machine + +State machine: SCANNING -> PHASING -> RECEIVING -> COMPLETE +""" + +from __future__ import annotations + +import base64 +import contextlib +import io +import math +import subprocess +import threading +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from enum import Enum +from pathlib import Path +from typing import Callable + +import numpy as np + +from utils.logging import get_logger + +logger = get_logger('intercept.wefax') + +try: + from PIL import Image as PILImage +except ImportError: + PILImage = None # type: ignore[assignment,misc] + +# --------------------------------------------------------------------------- +# WeFax protocol constants +# --------------------------------------------------------------------------- +CARRIER_FREQ = 1900.0 # Hz - center/carrier +BLACK_FREQ = 1500.0 # Hz - black level +WHITE_FREQ = 2300.0 # Hz - white level +START_TONE_FREQ = 300.0 # Hz - start tone +STOP_TONE_FREQ = 450.0 # Hz - stop tone +PHASING_FREQ = WHITE_FREQ # White pulse during phasing + +START_TONE_DURATION = 3.0 # Minimum seconds of start tone to detect +STOP_TONE_DURATION = 3.0 # Minimum seconds of stop tone to detect +PHASING_MIN_LINES = 5 # Minimum phasing lines before image + +DEFAULT_SAMPLE_RATE = 22050 +DEFAULT_IOC = 576 +DEFAULT_LPM = 120 + + +class DecoderState(Enum): + """WeFax decoder state machine states.""" + SCANNING = 'scanning' + START_DETECTED = 'start_detected' + PHASING = 'phasing' + RECEIVING = 'receiving' + COMPLETE = 'complete' + + +# --------------------------------------------------------------------------- +# Dataclasses +# --------------------------------------------------------------------------- + +@dataclass +class WeFaxImage: + """Decoded WeFax image metadata.""" + filename: str + path: Path + station: str + frequency_khz: float + timestamp: datetime + ioc: int + lpm: int + size_bytes: int = 0 + + def to_dict(self) -> dict: + return { + 'filename': self.filename, + 'path': str(self.path), + 'station': self.station, + 'frequency_khz': self.frequency_khz, + 'timestamp': self.timestamp.isoformat(), + 'ioc': self.ioc, + 'lpm': self.lpm, + 'size_bytes': self.size_bytes, + 'url': f'/wefax/images/{self.filename}', + } + + +@dataclass +class WeFaxProgress: + """WeFax decode progress update for SSE streaming.""" + status: str # 'scanning', 'phasing', 'receiving', 'complete', 'error', 'stopped' + station: str = '' + message: str = '' + progress_percent: int = 0 + line_count: int = 0 + image: WeFaxImage | None = None + partial_image: str | None = None + + def to_dict(self) -> dict: + result: dict = { + 'type': 'wefax_progress', + 'status': self.status, + 'progress': self.progress_percent, + } + if self.station: + result['station'] = self.station + if self.message: + result['message'] = self.message + if self.line_count: + result['line_count'] = self.line_count + if self.image: + result['image'] = self.image.to_dict() + if self.partial_image: + result['partial_image'] = self.partial_image + return result + + +# --------------------------------------------------------------------------- +# DSP helpers (reuse Goertzel from SSTV where sensible) +# --------------------------------------------------------------------------- + +def _goertzel_mag(samples: np.ndarray, target_freq: float, + sample_rate: int) -> float: + """Compute Goertzel magnitude at a single frequency.""" + n = len(samples) + if n == 0: + return 0.0 + w = 2.0 * math.pi * target_freq / sample_rate + coeff = 2.0 * math.cos(w) + s1 = 0.0 + s2 = 0.0 + for sample in samples: + s0 = float(sample) + coeff * s1 - s2 + s2 = s1 + s1 = s0 + energy = s1 * s1 + s2 * s2 - coeff * s1 * s2 + return math.sqrt(max(0.0, energy)) + + +def _freq_to_pixel(frequency: float) -> int: + """Map WeFax audio frequency to pixel value (0=black, 255=white). + + Linear mapping: 1500 Hz -> 0 (black), 2300 Hz -> 255 (white). + """ + normalized = (frequency - BLACK_FREQ) / (WHITE_FREQ - BLACK_FREQ) + return max(0, min(255, int(normalized * 255 + 0.5))) + + +def _estimate_frequency(samples: np.ndarray, sample_rate: int, + freq_low: float = 1200.0, + freq_high: float = 2500.0) -> float: + """Estimate dominant frequency using coarse+fine Goertzel sweep.""" + if len(samples) == 0: + return 0.0 + + best_freq = freq_low + best_energy = 0.0 + + # Coarse sweep (25 Hz steps) + freq = freq_low + while freq <= freq_high: + energy = _goertzel_mag(samples, freq, sample_rate) ** 2 + if energy > best_energy: + best_energy = energy + best_freq = freq + freq += 25.0 + + # Fine sweep around peak (+/- 25 Hz, 5 Hz steps) + fine_low = max(freq_low, best_freq - 25.0) + fine_high = min(freq_high, best_freq + 25.0) + freq = fine_low + while freq <= fine_high: + energy = _goertzel_mag(samples, freq, sample_rate) ** 2 + if energy > best_energy: + best_energy = energy + best_freq = freq + freq += 5.0 + + return best_freq + + +def _detect_tone(samples: np.ndarray, target_freq: float, + sample_rate: int, threshold: float = 3.0) -> bool: + """Detect if a specific tone dominates the signal.""" + target_mag = _goertzel_mag(samples, target_freq, sample_rate) + # Check against a few reference frequencies + refs = [1000.0, 1500.0, 1900.0, 2300.0] + refs = [f for f in refs if abs(f - target_freq) > 100] + if not refs: + return target_mag > 0.01 + avg_ref = sum(_goertzel_mag(samples, f, sample_rate) for f in refs) / len(refs) + if avg_ref <= 0: + return target_mag > 0.01 + return target_mag / avg_ref >= threshold + + +# --------------------------------------------------------------------------- +# WeFaxDecoder +# --------------------------------------------------------------------------- + +class WeFaxDecoder: + """WeFax decoder singleton. + + Manages rtl_fm subprocess and decodes WeFax images using a state + machine that detects start/stop tones, phasing signals, and + demodulates image lines. + """ + + def __init__(self) -> None: + self._rtl_process: subprocess.Popen | None = None + self._running = False + self._lock = threading.Lock() + self._callback: Callable[[dict], None] | None = None + self._last_scope_time: float = 0.0 + self._output_dir = Path('instance/wefax_images') + self._images: list[WeFaxImage] = [] + self._decode_thread: threading.Thread | None = None + + # Current session parameters + self._station = '' + self._frequency_khz = 0.0 + self._ioc = DEFAULT_IOC + self._lpm = DEFAULT_LPM + self._sample_rate = DEFAULT_SAMPLE_RATE + self._device_index = 0 + self._gain = 40.0 + self._direct_sampling = True + + self._output_dir.mkdir(parents=True, exist_ok=True) + + @property + def is_running(self) -> bool: + return self._running + + def set_callback(self, callback: Callable[[dict], None]) -> None: + """Set callback for progress updates (fed to SSE queue).""" + self._callback = callback + + def start( + self, + frequency_khz: float, + station: str = '', + device_index: int = 0, + gain: float = 40.0, + ioc: int = DEFAULT_IOC, + lpm: int = DEFAULT_LPM, + direct_sampling: bool = True, + ) -> bool: + """Start WeFax decoder. + + Args: + frequency_khz: Frequency in kHz (e.g. 4298 for NOJ). + station: Station callsign for metadata. + device_index: RTL-SDR device index. + gain: Receiver gain in dB. + ioc: Index of Cooperation (576 or 288). + lpm: Lines per minute (120 or 60). + direct_sampling: Enable RTL-SDR direct sampling for HF. + + Returns: + True if started successfully. + """ + with self._lock: + if self._running: + return True + + self._station = station + self._frequency_khz = frequency_khz + self._ioc = ioc + self._lpm = lpm + self._device_index = device_index + self._gain = gain + self._direct_sampling = direct_sampling + self._sample_rate = DEFAULT_SAMPLE_RATE + + try: + self._running = True + self._start_pipeline() + + logger.info( + f"WeFax decoder started: {frequency_khz} kHz, " + f"station={station}, IOC={ioc}, LPM={lpm}" + ) + self._emit_progress(WeFaxProgress( + status='scanning', + station=station, + message=f'Scanning {frequency_khz} kHz for WeFax start tone...', + )) + return True + + except Exception as e: + self._running = False + logger.error(f"Failed to start WeFax decoder: {e}") + self._emit_progress(WeFaxProgress( + status='error', + message=str(e), + )) + return False + + def _start_pipeline(self) -> None: + """Start rtl_fm subprocess in USB mode for WeFax.""" + freq_hz = int(self._frequency_khz * 1000) + + rtl_cmd = [ + 'rtl_fm', + '-d', str(self._device_index), + '-f', str(freq_hz), + '-M', 'usb', + '-s', str(self._sample_rate), + '-r', str(self._sample_rate), + '-g', str(self._gain), + ] + + if self._direct_sampling: + rtl_cmd.extend(['-E', 'direct2']) + + rtl_cmd.append('-') + + logger.info(f"Starting rtl_fm: {' '.join(rtl_cmd)}") + + self._rtl_process = subprocess.Popen( + rtl_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + self._decode_thread = threading.Thread( + target=self._decode_audio_stream, daemon=True) + self._decode_thread.start() + + def _decode_audio_stream(self) -> None: + """Read audio from rtl_fm and decode WeFax images. + + Runs in a background thread. Processes 100ms chunks through + the start-tone / phasing / image state machine. + """ + sr = self._sample_rate + chunk_samples = sr // 10 # 100ms + chunk_bytes = chunk_samples * 2 # int16 + + state = DecoderState.SCANNING + start_tone_count = 0 + stop_tone_count = 0 + phasing_line_count = 0 + + # Image parameters + pixels_per_line = int(math.pi * self._ioc) + line_duration_s = 60.0 / self._lpm + samples_per_line = int(line_duration_s * sr) + + # Image buffer + image_lines: list[np.ndarray] = [] + line_buffer = np.zeros(0, dtype=np.float64) + max_lines = 2000 # Safety limit + + rtl_fm_error = '' + last_partial_line = -1 + + logger.info( + f"WeFax decode thread started: IOC={self._ioc}, " + f"LPM={self._lpm}, pixels/line={pixels_per_line}, " + f"samples/line={samples_per_line}" + ) + + while self._running and self._rtl_process: + try: + raw_data = self._rtl_process.stdout.read(chunk_bytes) + if not raw_data: + if self._running: + stderr_msg = '' + if self._rtl_process and self._rtl_process.stderr: + with contextlib.suppress(Exception): + stderr_msg = self._rtl_process.stderr.read().decode( + errors='replace').strip() + rc = self._rtl_process.poll() if self._rtl_process else None + logger.warning(f"rtl_fm stream ended (exit code: {rc})") + if stderr_msg: + logger.warning(f"rtl_fm stderr: {stderr_msg}") + rtl_fm_error = stderr_msg + break + + n_samples = len(raw_data) // 2 + if n_samples == 0: + continue + + raw_int16 = np.frombuffer(raw_data[:n_samples * 2], dtype=np.int16) + samples = raw_int16.astype(np.float64) / 32768.0 + + # Emit scope waveform for frontend visualisation + self._emit_scope(raw_int16) + + if state == DecoderState.SCANNING: + # Look for 300 Hz start tone + if _detect_tone(samples, START_TONE_FREQ, sr, threshold=2.5): + start_tone_count += 1 + # Need sustained detection (>= START_TONE_DURATION seconds) + needed = int(START_TONE_DURATION / 0.1) + if start_tone_count >= needed: + state = DecoderState.PHASING + phasing_line_count = 0 + logger.info("WeFax start tone detected, entering phasing") + self._emit_progress(WeFaxProgress( + status='phasing', + station=self._station, + message='Start tone detected, synchronising...', + )) + else: + start_tone_count = max(0, start_tone_count - 1) + + elif state == DecoderState.PHASING: + # Count phasing lines (alternating black/white pulses) + phasing_line_count += 1 + needed_phasing = max(PHASING_MIN_LINES, int(2.0 / 0.1)) + if phasing_line_count >= needed_phasing: + state = DecoderState.RECEIVING + image_lines = [] + line_buffer = np.zeros(0, dtype=np.float64) + last_partial_line = -1 + logger.info("Phasing complete, receiving image") + self._emit_progress(WeFaxProgress( + status='receiving', + station=self._station, + message='Receiving image...', + )) + + elif state == DecoderState.RECEIVING: + # Check for stop tone + if _detect_tone(samples, STOP_TONE_FREQ, sr, threshold=2.5): + stop_tone_count += 1 + needed_stop = int(STOP_TONE_DURATION / 0.1) + if stop_tone_count >= needed_stop: + # Process any remaining line buffer + if len(line_buffer) >= samples_per_line * 0.5: + line_pixels = self._decode_line( + line_buffer, pixels_per_line, sr) + image_lines.append(line_pixels) + + state = DecoderState.COMPLETE + logger.info( + f"Stop tone detected, image complete: " + f"{len(image_lines)} lines" + ) + break + else: + stop_tone_count = max(0, stop_tone_count - 1) + + # Accumulate samples into line buffer + line_buffer = np.concatenate([line_buffer, samples]) + + # Extract complete lines + while len(line_buffer) >= samples_per_line: + line_samples = line_buffer[:samples_per_line] + line_buffer = line_buffer[samples_per_line:] + + line_pixels = self._decode_line( + line_samples, pixels_per_line, sr) + image_lines.append(line_pixels) + + # Safety limit + if len(image_lines) >= max_lines: + logger.warning("WeFax max lines reached, saving image") + state = DecoderState.COMPLETE + break + + # Emit progress periodically + current_lines = len(image_lines) + if current_lines > 0 and current_lines != last_partial_line and current_lines % 20 == 0: + last_partial_line = current_lines + # Rough progress estimate (typical chart ~800 lines) + pct = min(95, int(current_lines / 8)) + partial_url = self._encode_partial( + image_lines, pixels_per_line) + self._emit_progress(WeFaxProgress( + status='receiving', + station=self._station, + message=f'Receiving: {current_lines} lines', + progress_percent=pct, + line_count=current_lines, + partial_image=partial_url, + )) + + except Exception as e: + logger.error(f"Error in WeFax decode thread: {e}") + if not self._running: + break + time.sleep(0.1) + + # Save image if we got data + if state == DecoderState.COMPLETE and image_lines: + self._save_image(image_lines, pixels_per_line) + elif state == DecoderState.RECEIVING and len(image_lines) > 20: + # Save partial image if we had significant data + logger.info(f"Saving partial WeFax image: {len(image_lines)} lines") + self._save_image(image_lines, pixels_per_line) + + # Clean up + with self._lock: + was_running = self._running + self._running = False + if self._rtl_process: + with contextlib.suppress(Exception): + self._rtl_process.terminate() + self._rtl_process.wait(timeout=2) + self._rtl_process = None + + if was_running: + err_detail = rtl_fm_error.split('\n')[-1] if rtl_fm_error else '' + if state != DecoderState.COMPLETE: + msg = f'rtl_fm failed: {err_detail}' if err_detail else 'Decode stopped unexpectedly' + self._emit_progress(WeFaxProgress( + status='error', message=msg)) + else: + self._emit_progress(WeFaxProgress( + status='stopped', message='Decoder stopped')) + + logger.info("WeFax decode thread ended") + + def _decode_line(self, line_samples: np.ndarray, + pixels_per_line: int, sample_rate: int) -> np.ndarray: + """Decode one scan line from audio samples to pixel values. + + Uses instantaneous frequency estimation via the analytic signal + (Hilbert transform), then maps frequency to grayscale. + """ + n = len(line_samples) + pixels = np.zeros(pixels_per_line, dtype=np.uint8) + + if n < pixels_per_line: + return pixels + + samples_per_pixel = n / pixels_per_line + + # Use Hilbert transform for instantaneous frequency + try: + analytic = np.fft.ifft( + np.fft.fft(line_samples) * 2 * (np.arange(n) < n // 2)) + inst_phase = np.unwrap(np.angle(analytic)) + inst_freq = np.diff(inst_phase) / (2.0 * math.pi) * sample_rate + inst_freq = np.clip(inst_freq, BLACK_FREQ - 200, WHITE_FREQ + 200) + + # Average frequency per pixel + for px in range(pixels_per_line): + start_idx = int(px * samples_per_pixel) + end_idx = int((px + 1) * samples_per_pixel) + end_idx = min(end_idx, len(inst_freq)) + if start_idx >= end_idx: + continue + avg_freq = float(np.mean(inst_freq[start_idx:end_idx])) + pixels[px] = _freq_to_pixel(avg_freq) + + except Exception: + # Fallback: simple Goertzel per pixel window + for px in range(pixels_per_line): + start_idx = int(px * samples_per_pixel) + end_idx = int((px + 1) * samples_per_pixel) + if start_idx >= len(line_samples) or start_idx >= end_idx: + break + window = line_samples[start_idx:end_idx] + freq = _estimate_frequency(window, sample_rate, + BLACK_FREQ - 200, WHITE_FREQ + 200) + pixels[px] = _freq_to_pixel(freq) + + return pixels + + def _encode_partial(self, image_lines: list[np.ndarray], + width: int) -> str | None: + """Encode current image lines as a JPEG data URL for live preview.""" + if PILImage is None or not image_lines: + return None + try: + height = len(image_lines) + img_array = np.zeros((height, width), dtype=np.uint8) + for i, line in enumerate(image_lines): + img_array[i, :len(line)] = line[:width] + img = PILImage.fromarray(img_array, mode='L') + buf = io.BytesIO() + img.save(buf, format='JPEG', quality=40) + b64 = base64.b64encode(buf.getvalue()).decode('ascii') + return f'data:image/jpeg;base64,{b64}' + except Exception: + return None + + def _save_image(self, image_lines: list[np.ndarray], + width: int) -> None: + """Save completed image to disk.""" + if PILImage is None: + logger.error("Cannot save image: Pillow not installed") + self._emit_progress(WeFaxProgress( + status='error', + message='Cannot save image - Pillow not installed', + )) + return + + try: + height = len(image_lines) + img_array = np.zeros((height, width), dtype=np.uint8) + for i, line in enumerate(image_lines): + img_array[i, :len(line)] = line[:width] + + img = PILImage.fromarray(img_array, mode='L') + timestamp = datetime.now(timezone.utc) + station_tag = self._station or 'unknown' + filename = f"wefax_{timestamp.strftime('%Y%m%d_%H%M%S')}_{station_tag}.png" + filepath = self._output_dir / filename + img.save(filepath, 'PNG') + + wefax_image = WeFaxImage( + filename=filename, + path=filepath, + station=self._station, + frequency_khz=self._frequency_khz, + timestamp=timestamp, + ioc=self._ioc, + lpm=self._lpm, + size_bytes=filepath.stat().st_size, + ) + self._images.append(wefax_image) + + logger.info(f"WeFax image saved: {filename} ({wefax_image.size_bytes} bytes)") + self._emit_progress(WeFaxProgress( + status='complete', + station=self._station, + message=f'Image decoded: {height} lines', + progress_percent=100, + line_count=height, + image=wefax_image, + )) + + except Exception as e: + logger.error(f"Error saving WeFax image: {e}") + self._emit_progress(WeFaxProgress( + status='error', + message=f'Error saving image: {e}', + )) + + def stop(self) -> None: + """Stop WeFax decoder.""" + with self._lock: + self._running = False + + if self._rtl_process: + try: + self._rtl_process.terminate() + self._rtl_process.wait(timeout=5) + except Exception: + with contextlib.suppress(Exception): + self._rtl_process.kill() + self._rtl_process = None + + logger.info("WeFax decoder stopped") + + def get_images(self) -> list[WeFaxImage]: + """Get list of decoded images.""" + self._scan_images() + return list(self._images) + + def delete_image(self, filename: str) -> bool: + """Delete a single decoded image.""" + filepath = self._output_dir / filename + if not filepath.exists(): + return False + filepath.unlink() + self._images = [img for img in self._images if img.filename != filename] + logger.info(f"Deleted WeFax image: {filename}") + return True + + def delete_all_images(self) -> int: + """Delete all decoded images. Returns count deleted.""" + count = 0 + for filepath in self._output_dir.glob('*.png'): + filepath.unlink() + count += 1 + self._images.clear() + logger.info(f"Deleted all WeFax images ({count} files)") + return count + + def _scan_images(self) -> None: + """Scan output directory for images not yet tracked.""" + known = {img.filename for img in self._images} + for filepath in self._output_dir.glob('*.png'): + if filepath.name not in known: + try: + stat = filepath.stat() + image = WeFaxImage( + filename=filepath.name, + path=filepath, + station='', + frequency_khz=0, + timestamp=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc), + ioc=self._ioc, + lpm=self._lpm, + size_bytes=stat.st_size, + ) + self._images.append(image) + except Exception as e: + logger.warning(f"Error scanning image {filepath}: {e}") + + def _emit_progress(self, progress: WeFaxProgress) -> None: + """Emit progress update to callback.""" + if self._callback: + try: + self._callback(progress.to_dict()) + except Exception as e: + logger.error(f"Error in progress callback: {e}") + + def _emit_scope(self, raw_int16: np.ndarray) -> None: + """Emit scope waveform data for frontend visualisation.""" + if not self._callback: + return + + now = time.monotonic() + if now - self._last_scope_time < 0.1: + return + self._last_scope_time = now + + try: + peak = int(np.max(np.abs(raw_int16))) + rms = int(np.sqrt(np.mean(raw_int16.astype(np.float64) ** 2))) + + # Downsample to 256 signed int8 values for lightweight transport + window = raw_int16[-256:] if len(raw_int16) > 256 else raw_int16 + waveform = np.clip(window // 256, -127, 127).astype(np.int8).tolist() + + self._callback({ + 'type': 'scope', + 'rms': rms, + 'peak': peak, + 'waveform': waveform, + }) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Module-level singleton +# --------------------------------------------------------------------------- + +_decoder: WeFaxDecoder | None = None + + +def get_wefax_decoder() -> WeFaxDecoder: + """Get or create the global WeFax decoder instance.""" + global _decoder + if _decoder is None: + _decoder = WeFaxDecoder() + return _decoder diff --git a/utils/wefax_stations.py b/utils/wefax_stations.py new file mode 100644 index 0000000..d80289c --- /dev/null +++ b/utils/wefax_stations.py @@ -0,0 +1,89 @@ +"""WeFax station database loader. + +Loads and caches station data from data/wefax_stations.json. Provides +lookup by callsign and current-broadcast filtering based on UTC time. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path + +_stations_cache: list[dict] | None = None +_stations_by_callsign: dict[str, dict] = {} + +_STATIONS_PATH = Path(__file__).resolve().parent.parent / 'data' / 'wefax_stations.json' + + +def load_stations() -> list[dict]: + """Load all WeFax stations from JSON, caching on first call.""" + global _stations_cache, _stations_by_callsign + + if _stations_cache is not None: + return _stations_cache + + with open(_STATIONS_PATH) as f: + data = json.load(f) + + _stations_cache = data.get('stations', []) + _stations_by_callsign = {s['callsign']: s for s in _stations_cache} + return _stations_cache + + +def get_station(callsign: str) -> dict | None: + """Get a single station by callsign.""" + load_stations() + return _stations_by_callsign.get(callsign.upper()) + + +def get_current_broadcasts(callsign: str) -> list[dict]: + """Return schedule entries closest to the current UTC time. + + Returns up to 3 entries: the most recent past broadcast and the + next two upcoming ones, annotated with ``minutes_until`` or + ``minutes_ago`` relative to now. + """ + station = get_station(callsign) + if not station: + return [] + + now = datetime.now(timezone.utc) + current_minutes = now.hour * 60 + now.minute + + schedule = station.get('schedule', []) + if not schedule: + return [] + + # Convert schedule times to minutes-since-midnight for comparison + entries: list[tuple[int, dict]] = [] + for entry in schedule: + parts = entry['utc'].split(':') + mins = int(parts[0]) * 60 + int(parts[1]) + entries.append((mins, entry)) + entries.sort(key=lambda x: x[0]) + + # Find closest entries relative to now + results = [] + for mins, entry in entries: + diff = mins - current_minutes + # Wrap around midnight + if diff < -720: + diff += 1440 + elif diff > 720: + diff -= 1440 + + annotated = dict(entry) + if diff >= 0: + annotated['minutes_until'] = diff + else: + annotated['minutes_ago'] = abs(diff) + annotated['_sort_key'] = abs(diff) + results.append(annotated) + + results.sort(key=lambda x: x['_sort_key']) + + # Return 3 nearest entries, clean up sort key + for r in results: + r.pop('_sort_key', None) + return results[:3] From 085a6177f93c3d5aa1b843a264e4d140417496a1 Mon Sep 17 00:00:00 2001 From: Smittix Date: Tue, 24 Feb 2026 13:28:53 +0000 Subject: [PATCH 10/52] Add WeFax start button feedback and auto-capture scheduler Fix silent failure when starting without station/frequency selected by flashing amber on status text and dropdowns. Add auto-capture scheduler that uses fixed UTC broadcast schedules from station data to automatically start/stop WeFax decoding at broadcast times. Co-Authored-By: Claude Opus 4.6 --- config.py | 2 + routes/wefax.py | 146 +++++++++ static/css/modes/wefax.css | 24 ++ static/js/modes/wefax.js | 135 ++++++++- templates/index.html | 5 + templates/partials/modes/wefax.html | 12 + utils/wefax_scheduler.py | 448 ++++++++++++++++++++++++++++ 7 files changed, 771 insertions(+), 1 deletion(-) create mode 100644 utils/wefax_scheduler.py diff --git a/config.py b/config.py index 3001fc3..db9db3f 100644 --- a/config.py +++ b/config.py @@ -342,6 +342,8 @@ WEFAX_DEFAULT_GAIN = _get_env_float('WEFAX_GAIN', 40.0) WEFAX_SAMPLE_RATE = _get_env_int('WEFAX_SAMPLE_RATE', 22050) WEFAX_DEFAULT_IOC = _get_env_int('WEFAX_IOC', 576) WEFAX_DEFAULT_LPM = _get_env_int('WEFAX_LPM', 120) +WEFAX_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEFAX_SCHEDULE_REFRESH_MINUTES', 30) +WEFAX_CAPTURE_BUFFER_SECONDS = _get_env_int('WEFAX_CAPTURE_BUFFER_SECONDS', 30) # SubGHz transceiver settings (HackRF) SUBGHZ_DEFAULT_FREQUENCY = _get_env_float('SUBGHZ_FREQUENCY', 433.92) diff --git a/routes/wefax.py b/routes/wefax.py index 4afcd98..bc81132 100644 --- a/routes/wefax.py +++ b/routes/wefax.py @@ -255,6 +255,152 @@ def delete_all_images(): return jsonify({'status': 'ok', 'deleted': count}) +# ======================== +# Auto-Scheduler Endpoints +# ======================== + + +def _scheduler_event_callback(event: dict) -> None: + """Forward scheduler events to the SSE queue.""" + try: + _wefax_queue.put_nowait(event) + except queue.Full: + try: + _wefax_queue.get_nowait() + _wefax_queue.put_nowait(event) + except queue.Empty: + pass + + +@wefax_bp.route('/schedule/enable', methods=['POST']) +def enable_schedule(): + """Enable auto-scheduling of WeFax broadcast captures. + + JSON body: + { + "station": "NOJ", + "frequency_khz": 4298, + "device": 0, + "gain": 40, + "ioc": 576, + "lpm": 120, + "direct_sampling": true + } + + Returns: + JSON with scheduler status. + """ + from utils.wefax_scheduler import get_wefax_scheduler + + data = request.get_json(silent=True) or {} + + station = str(data.get('station', '')).strip() + if not station: + return jsonify({ + 'status': 'error', + 'message': 'station is required', + }), 400 + + frequency_khz = data.get('frequency_khz') + if frequency_khz is None: + return jsonify({ + 'status': 'error', + 'message': 'frequency_khz is required', + }), 400 + + try: + frequency_khz = float(frequency_khz) + freq_mhz = frequency_khz / 1000.0 + validate_frequency(freq_mhz, min_mhz=2.0, max_mhz=30.0) + except (TypeError, ValueError) as e: + return jsonify({ + 'status': 'error', + 'message': f'Invalid frequency: {e}', + }), 400 + + device = int(data.get('device', 0)) + gain = float(data.get('gain', 40.0)) + ioc = int(data.get('ioc', 576)) + lpm = int(data.get('lpm', 120)) + direct_sampling = bool(data.get('direct_sampling', True)) + + scheduler = get_wefax_scheduler() + scheduler.set_callbacks(_progress_callback, _scheduler_event_callback) + + try: + result = scheduler.enable( + station=station, + frequency_khz=frequency_khz, + device=device, + gain=gain, + ioc=ioc, + lpm=lpm, + direct_sampling=direct_sampling, + ) + except Exception: + logger.exception("Failed to enable WeFax scheduler") + return jsonify({ + 'status': 'error', + 'message': 'Failed to enable scheduler', + }), 500 + + return jsonify({'status': 'ok', **result}) + + +@wefax_bp.route('/schedule/disable', methods=['POST']) +def disable_schedule(): + """Disable auto-scheduling.""" + from utils.wefax_scheduler import get_wefax_scheduler + + scheduler = get_wefax_scheduler() + result = scheduler.disable() + return jsonify(result) + + +@wefax_bp.route('/schedule/status') +def schedule_status(): + """Get current scheduler state.""" + from utils.wefax_scheduler import get_wefax_scheduler + + scheduler = get_wefax_scheduler() + return jsonify(scheduler.get_status()) + + +@wefax_bp.route('/schedule/broadcasts') +def schedule_broadcasts(): + """List scheduled broadcasts.""" + from utils.wefax_scheduler import get_wefax_scheduler + + scheduler = get_wefax_scheduler() + broadcasts = scheduler.get_broadcasts() + return jsonify({ + 'status': 'ok', + 'broadcasts': broadcasts, + 'count': len(broadcasts), + }) + + +@wefax_bp.route('/schedule/skip/', methods=['POST']) +def skip_broadcast(broadcast_id: str): + """Skip a scheduled broadcast.""" + from utils.wefax_scheduler import get_wefax_scheduler + + if not broadcast_id.replace('_', '').replace('-', '').isalnum(): + return jsonify({ + 'status': 'error', + 'message': 'Invalid broadcast ID', + }), 400 + + scheduler = get_wefax_scheduler() + if scheduler.skip_broadcast(broadcast_id): + return jsonify({'status': 'skipped', 'broadcast_id': broadcast_id}) + else: + return jsonify({ + 'status': 'error', + 'message': 'Broadcast not found or already processed', + }), 404 + + @wefax_bp.route('/stations') def list_stations(): """Get all WeFax stations from the database.""" diff --git a/static/css/modes/wefax.css b/static/css/modes/wefax.css index 2945594..f2f74f5 100644 --- a/static/css/modes/wefax.css +++ b/static/css/modes/wefax.css @@ -108,6 +108,30 @@ letter-spacing: 1px; } +/* --- Schedule Toggle --- */ +.wefax-schedule-toggle { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-size: 10px; + font-family: var(--font-mono, 'JetBrains Mono', monospace); + color: var(--text-dim, #666); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.wefax-schedule-toggle input[type="checkbox"] { + width: 14px; + height: 14px; + cursor: pointer; + accent-color: #ffaa00; +} + +.wefax-schedule-toggle input:checked + span { + color: #ffaa00; +} + /* --- Visuals Container --- */ .wefax-visuals-container { display: flex; diff --git a/static/js/modes/wefax.js b/static/js/modes/wefax.js index c93e41a..4a0d7c4 100644 --- a/static/js/modes/wefax.js +++ b/static/js/modes/wefax.js @@ -50,6 +50,7 @@ var WeFax = (function () { state.initialized = true; loadStations(); loadImages(); + checkSchedulerStatus(); } function destroy() { @@ -147,7 +148,7 @@ var WeFax = (function () { var freqSel = document.getElementById('wefaxFrequency'); var freqKhz = freqSel ? parseFloat(freqSel.value) : 0; if (!freqKhz || isNaN(freqKhz)) { - setStatus('Select a station and frequency first'); + flashStartError(); return; } @@ -248,6 +249,24 @@ var WeFax = (function () { } function handleProgress(data) { + // Handle scheduler events + if (data.type === 'schedule_capture_start') { + setStatus('Auto-capture started: ' + (data.broadcast ? data.broadcast.content : '')); + state.running = true; + updateButtons(true); + connectSSE(); + return; + } + if (data.type === 'schedule_capture_complete') { + setStatus('Auto-capture complete'); + loadImages(); + return; + } + if (data.type === 'schedule_capture_skipped') { + setStatus('Broadcast skipped: ' + (data.reason || '')); + return; + } + if (data.type !== 'wefax_progress') return; var statusText = data.message || data.status || ''; @@ -655,6 +674,119 @@ var WeFax = (function () { if (el) el.textContent = String(khz); } + function flashStartError() { + setStatus('Select a station and frequency first'); + var statusEl = document.getElementById('wefaxStatusText'); + if (statusEl) { + statusEl.style.color = '#ffaa00'; + statusEl.style.fontWeight = '600'; + setTimeout(function () { + statusEl.style.color = ''; + statusEl.style.fontWeight = ''; + }, 2500); + } + var stationSel = document.getElementById('wefaxStation'); + var freqSel = document.getElementById('wefaxFrequency'); + [stationSel, freqSel].forEach(function (el) { + if (!el) return; + el.style.borderColor = '#ffaa00'; + el.style.boxShadow = '0 0 4px #ffaa0066'; + setTimeout(function () { + el.style.borderColor = ''; + el.style.boxShadow = ''; + }, 2500); + }); + } + + // ---- Auto-Capture Scheduler ---- + + function checkSchedulerStatus() { + fetch('/wefax/schedule/status') + .then(function (r) { return r.json(); }) + .then(function (data) { + var strip = document.getElementById('wefaxStripAutoSchedule'); + var sidebar = document.getElementById('wefaxSidebarAutoSchedule'); + if (strip) strip.checked = !!data.enabled; + if (sidebar) sidebar.checked = !!data.enabled; + }) + .catch(function () { /* ignore */ }); + } + + function enableScheduler() { + var stationSel = document.getElementById('wefaxStation'); + var station = stationSel ? stationSel.value : ''; + var freqSel = document.getElementById('wefaxFrequency'); + var freqKhz = freqSel ? parseFloat(freqSel.value) : 0; + + if (!station || !freqKhz || isNaN(freqKhz)) { + flashStartError(); + syncSchedulerCheckboxes(false); + return; + } + + var deviceSel = document.getElementById('rtlDevice'); + var device = deviceSel ? parseInt(deviceSel.value, 10) || 0 : 0; + var gainInput = document.getElementById('wefaxGain'); + var iocSel = document.getElementById('wefaxIOC'); + var lpmSel = document.getElementById('wefaxLPM'); + var dsCheckbox = document.getElementById('wefaxDirectSampling'); + + fetch('/wefax/schedule/enable', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + station: station, + frequency_khz: freqKhz, + device: device, + gain: gainInput ? parseFloat(gainInput.value) || 40 : 40, + ioc: iocSel ? parseInt(iocSel.value, 10) : 576, + lpm: lpmSel ? parseInt(lpmSel.value, 10) : 120, + direct_sampling: dsCheckbox ? dsCheckbox.checked : true, + }), + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.status === 'ok') { + setStatus('Auto-capture enabled — ' + (data.scheduled_count || 0) + ' broadcasts scheduled'); + syncSchedulerCheckboxes(true); + } else { + setStatus('Scheduler error: ' + (data.message || 'unknown')); + syncSchedulerCheckboxes(false); + } + }) + .catch(function (err) { + setStatus('Scheduler error: ' + err.message); + syncSchedulerCheckboxes(false); + }); + } + + function disableScheduler() { + fetch('/wefax/schedule/disable', { method: 'POST' }) + .then(function (r) { return r.json(); }) + .then(function () { + setStatus('Auto-capture disabled'); + syncSchedulerCheckboxes(false); + }) + .catch(function (err) { + console.error('WeFax scheduler disable error:', err); + }); + } + + function toggleScheduler(checkbox) { + if (checkbox.checked) { + enableScheduler(); + } else { + disableScheduler(); + } + } + + function syncSchedulerCheckboxes(enabled) { + var strip = document.getElementById('wefaxStripAutoSchedule'); + var sidebar = document.getElementById('wefaxSidebarAutoSchedule'); + if (strip) strip.checked = enabled; + if (sidebar) sidebar.checked = enabled; + } + // ---- Public API ---- return { @@ -667,5 +799,6 @@ var WeFax = (function () { deleteImage: deleteImage, deleteAllImages: deleteAllImages, viewImage: viewImage, + toggleScheduler: toggleScheduler, }; })(); diff --git a/templates/index.html b/templates/index.html index 7bffae6..3672fc2 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2545,6 +2545,11 @@ +
diff --git a/templates/partials/modes/wefax.html b/templates/partials/modes/wefax.html index a834e82..319e6a0 100644 --- a/templates/partials/modes/wefax.html +++ b/templates/partials/modes/wefax.html @@ -50,6 +50,18 @@
+
+

Auto Capture

+
+ + +
+

+ Automatically decode at scheduled broadcast times. +

+
+

HF Antenna Guide

diff --git a/utils/wefax_scheduler.py b/utils/wefax_scheduler.py new file mode 100644 index 0000000..373b116 --- /dev/null +++ b/utils/wefax_scheduler.py @@ -0,0 +1,448 @@ +"""WeFax auto-capture scheduler. + +Automatically captures WeFax broadcasts based on station broadcast schedules. +Uses threading.Timer for scheduling — no external dependencies required. + +Unlike the weather satellite scheduler which uses TLE-based orbital prediction, +WeFax stations broadcast on fixed UTC schedules, making scheduling simpler. +""" + +from __future__ import annotations + +import threading +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any, Callable + +from utils.logging import get_logger +from utils.wefax import get_wefax_decoder +from utils.wefax_stations import get_station + +logger = get_logger('intercept.wefax_scheduler') + +# Import config defaults +try: + from config import ( + WEFAX_CAPTURE_BUFFER_SECONDS, + WEFAX_SCHEDULE_REFRESH_MINUTES, + ) +except ImportError: + WEFAX_SCHEDULE_REFRESH_MINUTES = 30 + WEFAX_CAPTURE_BUFFER_SECONDS = 30 + + +class ScheduledBroadcast: + """A broadcast scheduled for automatic capture.""" + + def __init__( + self, + station: str, + callsign: str, + frequency_khz: float, + utc_time: str, + duration_min: int, + content: str, + ): + self.id: str = str(uuid.uuid4())[:8] + self.station = station + self.callsign = callsign + self.frequency_khz = frequency_khz + self.utc_time = utc_time + self.duration_min = duration_min + self.content = content + self.status: str = 'scheduled' # scheduled, capturing, complete, skipped + self._timer: threading.Timer | None = None + self._stop_timer: threading.Timer | None = None + + def to_dict(self) -> dict[str, Any]: + return { + 'id': self.id, + 'station': self.station, + 'callsign': self.callsign, + 'frequency_khz': self.frequency_khz, + 'utc_time': self.utc_time, + 'duration_min': self.duration_min, + 'content': self.content, + 'status': self.status, + } + + +class WeFaxScheduler: + """Auto-scheduler for WeFax broadcast captures.""" + + def __init__(self): + self._enabled = False + self._lock = threading.Lock() + self._broadcasts: list[ScheduledBroadcast] = [] + self._refresh_timer: threading.Timer | None = None + self._station: str = '' + self._callsign: str = '' + self._frequency_khz: float = 0.0 + self._device: int = 0 + self._gain: float = 40.0 + self._ioc: int = 576 + self._lpm: int = 120 + self._direct_sampling: bool = True + self._progress_callback: Callable[[dict], None] | None = None + self._event_callback: Callable[[dict[str, Any]], None] | None = None + + @property + def enabled(self) -> bool: + return self._enabled + + def set_callbacks( + self, + progress_callback: Callable[[dict], None], + event_callback: Callable[[dict[str, Any]], None], + ) -> None: + """Set callbacks for progress and scheduler events.""" + self._progress_callback = progress_callback + self._event_callback = event_callback + + def enable( + self, + station: str, + frequency_khz: float, + device: int = 0, + gain: float = 40.0, + ioc: int = 576, + lpm: int = 120, + direct_sampling: bool = True, + ) -> dict[str, Any]: + """Enable auto-scheduling for a station/frequency. + + Args: + station: Station callsign. + frequency_khz: Frequency in kHz. + device: RTL-SDR device index. + gain: SDR gain in dB. + ioc: Index of Cooperation (576 or 288). + lpm: Lines per minute (120 or 60). + direct_sampling: Enable direct sampling for HF. + + Returns: + Status dict with scheduled broadcasts. + """ + station_data = get_station(station) + if not station_data: + return {'status': 'error', 'message': f'Station {station} not found'} + + with self._lock: + self._station = station_data.get('name', station) + self._callsign = station + self._frequency_khz = frequency_khz + self._device = device + self._gain = gain + self._ioc = ioc + self._lpm = lpm + self._direct_sampling = direct_sampling + self._enabled = True + + self._refresh_schedule() + + return self.get_status() + + def disable(self) -> dict[str, Any]: + """Disable auto-scheduling and cancel all timers.""" + with self._lock: + self._enabled = False + + # Cancel refresh timer + if self._refresh_timer: + self._refresh_timer.cancel() + self._refresh_timer = None + + # Cancel all broadcast timers + for b in self._broadcasts: + if b._timer: + b._timer.cancel() + b._timer = None + if b._stop_timer: + b._stop_timer.cancel() + b._stop_timer = None + + self._broadcasts.clear() + + logger.info("WeFax auto-scheduler disabled") + return {'status': 'disabled'} + + def skip_broadcast(self, broadcast_id: str) -> bool: + """Manually skip a scheduled broadcast.""" + with self._lock: + for b in self._broadcasts: + if b.id == broadcast_id and b.status == 'scheduled': + b.status = 'skipped' + if b._timer: + b._timer.cancel() + b._timer = None + logger.info( + "Skipped broadcast: %s at %s", b.content, b.utc_time + ) + self._emit_event({ + 'type': 'schedule_capture_skipped', + 'broadcast': b.to_dict(), + 'reason': 'manual', + }) + return True + return False + + def get_status(self) -> dict[str, Any]: + """Get current scheduler status.""" + with self._lock: + return { + 'enabled': self._enabled, + 'station': self._station, + 'callsign': self._callsign, + 'frequency_khz': self._frequency_khz, + 'device': self._device, + 'gain': self._gain, + 'ioc': self._ioc, + 'lpm': self._lpm, + 'scheduled_count': sum( + 1 for b in self._broadcasts if b.status == 'scheduled' + ), + 'total_broadcasts': len(self._broadcasts), + } + + def get_broadcasts(self) -> list[dict[str, Any]]: + """Get list of scheduled broadcasts.""" + with self._lock: + return [b.to_dict() for b in self._broadcasts] + + def _refresh_schedule(self) -> None: + """Recompute broadcast schedule and set timers.""" + if not self._enabled: + return + + station_data = get_station(self._callsign) + if not station_data: + logger.error("Station %s not found during refresh", self._callsign) + return + + schedule = station_data.get('schedule', []) + + with self._lock: + # Cancel existing timers + for b in self._broadcasts: + if b._timer: + b._timer.cancel() + if b._stop_timer: + b._stop_timer.cancel() + + # Keep completed/skipped for history, replace scheduled + history = [ + b for b in self._broadcasts + if b.status in ('complete', 'skipped', 'capturing') + ] + self._broadcasts = history + + now = datetime.now(timezone.utc) + buffer = WEFAX_CAPTURE_BUFFER_SECONDS + + for entry in schedule: + utc_time = entry.get('utc', '') + duration_min = entry.get('duration_min', 20) + content = entry.get('content', '') + + parts = utc_time.split(':') + if len(parts) != 2: + continue + + try: + hour = int(parts[0]) + minute = int(parts[1]) + except ValueError: + continue + + # Compute next occurrence (today or tomorrow) + broadcast_dt = now.replace( + hour=hour, minute=minute, second=0, microsecond=0 + ) + capture_end = broadcast_dt + timedelta( + minutes=duration_min, seconds=buffer + ) + + # If the broadcast end is already past, schedule for tomorrow + if capture_end <= now: + broadcast_dt += timedelta(days=1) + capture_end = broadcast_dt + timedelta( + minutes=duration_min, seconds=buffer + ) + + capture_start = broadcast_dt - timedelta(seconds=buffer) + + # Check if already in history + history_key = f"{self._callsign}_{utc_time}" + if any( + f"{h.callsign}_{h.utc_time}" == history_key + for h in history + ): + continue + + sb = ScheduledBroadcast( + station=self._station, + callsign=self._callsign, + frequency_khz=self._frequency_khz, + utc_time=utc_time, + duration_min=duration_min, + content=content, + ) + + # Schedule capture timer + delay = max(0.0, (capture_start - now).total_seconds()) + sb._timer = threading.Timer( + delay, self._execute_capture, args=[sb] + ) + sb._timer.daemon = True + sb._timer.start() + + self._broadcasts.append(sb) + + logger.info( + "WeFax scheduler refreshed: %d broadcasts scheduled", + sum(1 for b in self._broadcasts if b.status == 'scheduled'), + ) + + # Schedule next refresh + if self._refresh_timer: + self._refresh_timer.cancel() + self._refresh_timer = threading.Timer( + WEFAX_SCHEDULE_REFRESH_MINUTES * 60, + self._refresh_schedule, + ) + self._refresh_timer.daemon = True + self._refresh_timer.start() + + def _execute_capture(self, sb: ScheduledBroadcast) -> None: + """Execute capture for a scheduled broadcast.""" + if not self._enabled or sb.status != 'scheduled': + return + + decoder = get_wefax_decoder() + + if decoder.is_running: + logger.info("Decoder busy, skipping scheduled broadcast: %s", sb.content) + sb.status = 'skipped' + self._emit_event({ + 'type': 'schedule_capture_skipped', + 'broadcast': sb.to_dict(), + 'reason': 'decoder_busy', + }) + return + + # Claim SDR device + try: + import app as app_module + error = app_module.claim_sdr_device(self._device, 'wefax') + if error: + logger.info( + "SDR device busy, skipping: %s - %s", sb.content, error + ) + sb.status = 'skipped' + self._emit_event({ + 'type': 'schedule_capture_skipped', + 'broadcast': sb.to_dict(), + 'reason': 'device_busy', + }) + return + except ImportError: + pass + + sb.status = 'capturing' + + # Set up callbacks + if self._progress_callback: + decoder.set_callback(self._progress_callback) + + def _release_device(): + try: + import app as app_module + app_module.release_sdr_device(self._device) + except ImportError: + pass + + success = decoder.start( + frequency_khz=self._frequency_khz, + station=self._callsign, + device_index=self._device, + gain=self._gain, + ioc=self._ioc, + lpm=self._lpm, + direct_sampling=self._direct_sampling, + ) + + if success: + logger.info("Auto-scheduler started capture: %s", sb.content) + self._emit_event({ + 'type': 'schedule_capture_start', + 'broadcast': sb.to_dict(), + }) + + # Schedule stop timer at broadcast end + buffer + now = datetime.now(timezone.utc) + parts = sb.utc_time.split(':') + broadcast_dt = now.replace( + hour=int(parts[0]), minute=int(parts[1]), + second=0, microsecond=0, + ) + if broadcast_dt < now - timedelta(hours=1): + broadcast_dt += timedelta(days=1) + stop_dt = broadcast_dt + timedelta( + minutes=sb.duration_min, + seconds=WEFAX_CAPTURE_BUFFER_SECONDS, + ) + stop_delay = max(0.0, (stop_dt - now).total_seconds()) + + if stop_delay > 0: + sb._stop_timer = threading.Timer( + stop_delay, self._stop_capture, args=[sb, _release_device] + ) + sb._stop_timer.daemon = True + sb._stop_timer.start() + else: + sb.status = 'skipped' + _release_device() + self._emit_event({ + 'type': 'schedule_capture_skipped', + 'broadcast': sb.to_dict(), + 'reason': 'start_failed', + }) + + def _stop_capture( + self, sb: ScheduledBroadcast, release_fn: Callable + ) -> None: + """Stop capture at broadcast end.""" + decoder = get_wefax_decoder() + if decoder.is_running: + decoder.stop() + logger.info("Auto-scheduler stopped capture: %s", sb.content) + + sb.status = 'complete' + release_fn() + self._emit_event({ + 'type': 'schedule_capture_complete', + 'broadcast': sb.to_dict(), + }) + + def _emit_event(self, event: dict[str, Any]) -> None: + """Emit scheduler event to callback.""" + if self._event_callback: + try: + self._event_callback(event) + except Exception as e: + logger.error("Error in scheduler event callback: %s", e) + + +# Singleton +_scheduler: WeFaxScheduler | None = None +_scheduler_lock = threading.Lock() + + +def get_wefax_scheduler() -> WeFaxScheduler: + """Get or create the global WeFax scheduler instance.""" + global _scheduler + if _scheduler is None: + with _scheduler_lock: + if _scheduler is None: + _scheduler = WeFaxScheduler() + return _scheduler From 2da8dca167593af5f60c03728e51c4c4d00527cd Mon Sep 17 00:00:00 2001 From: Smittix Date: Tue, 24 Feb 2026 15:17:01 +0000 Subject: [PATCH 11/52] Add WeFax 24h broadcast timeline and improve start button feedback Flash the Start button itself with amber pulse when clicked without a station selected, and show "Select Station" in the strip status text right next to the button so the error is immediately visible. Add a 24-hour timeline bar with broadcast window markers, red UTC time cursor, and countdown boxes (HRS/MIN/SEC) that tick down to the next broadcast. Broadcasts show as amber blocks on the timeline track with imminent/active visual states matching the weather satellite pattern. Co-Authored-By: Claude Opus 4.6 --- static/css/modes/wefax.css | 154 ++++++++++++++++++++++++++++ static/js/modes/wefax.js | 205 +++++++++++++++++++++++++++++++++++++ templates/index.html | 22 ++++ 3 files changed, 381 insertions(+) diff --git a/static/css/modes/wefax.css b/static/css/modes/wefax.css index f2f74f5..6ece97b 100644 --- a/static/css/modes/wefax.css +++ b/static/css/modes/wefax.css @@ -74,6 +74,13 @@ .wefax-strip-btn.start { color: #ffaa00; border-color: #ffaa0044; } .wefax-strip-btn.start:hover { background: #ffaa0015; border-color: #ffaa00; } +.wefax-strip-btn.start.wefax-strip-btn-error { + border-color: #ffaa00; + color: #ffaa00; + box-shadow: 0 0 8px rgba(255, 170, 0, 0.3); + animation: wefax-pulse 0.6s ease-in-out 3; +} + .wefax-strip-btn.stop { color: #f44; border-color: #f4444444; } .wefax-strip-btn.stop:hover { background: #f4441a; border-color: #f44; } @@ -438,6 +445,153 @@ .wefax-gallery-action:hover { color: #fff; } .wefax-gallery-action.delete:hover { color: #f44; } +/* --- Countdown Bar + Timeline --- */ +.wefax-countdown-bar { + display: flex; + align-items: center; + gap: 16px; + padding: 10px 16px; + background: var(--bg-secondary, #141820); + border: 1px solid var(--border-color, #1e2a3a); + border-radius: 6px; + margin-bottom: 12px; +} + +.wefax-countdown-next { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.wefax-countdown-boxes { + display: flex; + gap: 4px; +} + +.wefax-countdown-box { + display: flex; + flex-direction: column; + align-items: center; + padding: 4px 8px; + background: var(--bg-primary, #0d1117); + border: 1px solid var(--border-color, #2a3040); + border-radius: 4px; + min-width: 40px; +} + +.wefax-countdown-box.imminent { + border-color: #ffaa00; + box-shadow: 0 0 8px rgba(255, 170, 0, 0.2); +} + +.wefax-countdown-box.active { + border-color: #ffaa00; + box-shadow: 0 0 8px rgba(255, 170, 0, 0.3); + animation: wefax-glow 1.5s ease-in-out infinite; +} + +@keyframes wefax-glow { + 0%, 100% { box-shadow: 0 0 8px rgba(255, 170, 0, 0.3); } + 50% { box-shadow: 0 0 16px rgba(255, 170, 0, 0.5); } +} + +.wefax-cd-value { + font-size: 16px; + font-weight: 700; + font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; + color: var(--text-primary, #e0e0e0); + line-height: 1; +} + +.wefax-cd-unit { + font-size: 8px; + color: var(--text-dim, #666); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 2px; +} + +.wefax-countdown-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.wefax-countdown-content { + font-size: 12px; + font-weight: 600; + color: #ffaa00; + font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; +} + +.wefax-countdown-detail { + font-size: 10px; + color: var(--text-dim, #666); + font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; +} + +.wefax-timeline { + flex: 1; + position: relative; + height: 36px; + min-width: 200px; +} + +.wefax-timeline-track { + position: absolute; + top: 4px; + left: 0; + right: 0; + height: 16px; + background: var(--bg-primary, #0d1117); + border: 1px solid var(--border-color, #2a3040); + border-radius: 3px; + overflow: hidden; +} + +.wefax-timeline-broadcast { + position: absolute; + top: 0; + height: 100%; + background: rgba(255, 170, 0, 0.5); + border-radius: 2px; + cursor: default; + opacity: 0.8; + min-width: 2px; +} + +.wefax-timeline-broadcast:hover { + opacity: 1; +} + +.wefax-timeline-broadcast.active { + background: rgba(255, 170, 0, 0.85); + border: 1px solid #ffaa00; +} + +.wefax-timeline-cursor { + position: absolute; + top: 2px; + width: 2px; + height: 20px; + background: #ff4444; + border-radius: 1px; + z-index: 2; +} + +.wefax-timeline-labels { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + justify-content: space-between; + font-size: 8px; + color: var(--text-dim, #666); + font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; +} + /* --- Responsive --- */ @media (max-width: 768px) { .wefax-main-row { diff --git a/static/js/modes/wefax.js b/static/js/modes/wefax.js index 4a0d7c4..ba54587 100644 --- a/static/js/modes/wefax.js +++ b/static/js/modes/wefax.js @@ -16,6 +16,7 @@ var WeFax = (function () { images: [], selectedStation: null, pollTimer: null, + countdownInterval: null, }; // ---- Scope state ---- @@ -56,6 +57,7 @@ var WeFax = (function () { function destroy() { disconnectSSE(); stopScope(); + stopCountdownTimer(); if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; @@ -101,6 +103,8 @@ var WeFax = (function () { state.selectedStation = null; renderFrequencyDropdown([]); renderScheduleTimeline([]); + renderBroadcastTimeline([]); + stopCountdownTimer(); return; } @@ -115,6 +119,8 @@ var WeFax = (function () { if (iocSel && station.ioc) iocSel.value = String(station.ioc); if (lpmSel && station.lpm) lpmSel.value = String(station.lpm); renderScheduleTimeline(station.schedule || []); + renderBroadcastTimeline(station.schedule || []); + startCountdownTimer(); } } @@ -676,6 +682,29 @@ var WeFax = (function () { function flashStartError() { setStatus('Select a station and frequency first'); + + // Flash the Start button itself (most visible feedback) + var startBtn = document.getElementById('wefaxStartBtn'); + if (startBtn) { + startBtn.classList.add('wefax-strip-btn-error'); + setTimeout(function () { + startBtn.classList.remove('wefax-strip-btn-error'); + }, 2500); + } + + // Show error in strip status text (right next to the button) + var stripStatus = document.getElementById('wefaxStripStatus'); + if (stripStatus) { + var prevText = stripStatus.textContent; + stripStatus.textContent = 'Select Station'; + stripStatus.style.color = '#ffaa00'; + setTimeout(function () { + stripStatus.textContent = prevText || 'Idle'; + stripStatus.style.color = ''; + }, 2500); + } + + // Also update the schedule panel status var statusEl = document.getElementById('wefaxStatusText'); if (statusEl) { statusEl.style.color = '#ffaa00'; @@ -685,6 +714,8 @@ var WeFax = (function () { statusEl.style.fontWeight = ''; }, 2500); } + + // Flash station/frequency dropdowns var stationSel = document.getElementById('wefaxStation'); var freqSel = document.getElementById('wefaxFrequency'); [stationSel, freqSel].forEach(function (el) { @@ -698,6 +729,180 @@ var WeFax = (function () { }); } + // ---- Broadcast Timeline + Countdown ---- + + function renderBroadcastTimeline(schedule) { + var bar = document.getElementById('wefaxCountdownBar'); + var track = document.getElementById('wefaxTimelineTrack'); + if (!bar || !track) return; + + if (!schedule || schedule.length === 0) { + bar.style.display = 'none'; + return; + } + + bar.style.display = 'flex'; + + // Clear existing broadcast markers + var existing = track.querySelectorAll('.wefax-timeline-broadcast'); + for (var i = 0; i < existing.length; i++) { + existing[i].parentNode.removeChild(existing[i]); + } + + var now = new Date(); + var nowMin = now.getUTCHours() * 60 + now.getUTCMinutes(); + + schedule.forEach(function (entry) { + var parts = entry.utc.split(':'); + var startMin = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10); + var duration = entry.duration_min || 20; + var leftPct = (startMin / 1440) * 100; + var widthPct = (duration / 1440) * 100; + + var block = document.createElement('div'); + block.className = 'wefax-timeline-broadcast'; + block.title = entry.utc + ' — ' + entry.content; + + // Mark active broadcasts + var diff = nowMin - startMin; + if (diff >= 0 && diff < duration) { + block.classList.add('active'); + } + + block.style.left = leftPct + '%'; + block.style.width = Math.max(widthPct, 0.3) + '%'; + track.appendChild(block); + }); + + updateTimelineCursor(); + } + + function updateTimelineCursor() { + var cursor = document.getElementById('wefaxTimelineCursor'); + if (!cursor) return; + + var now = new Date(); + var nowMin = now.getUTCHours() * 60 + now.getUTCMinutes() + now.getUTCSeconds() / 60; + cursor.style.left = ((nowMin / 1440) * 100) + '%'; + } + + function startCountdownTimer() { + stopCountdownTimer(); + updateCountdown(); + state.countdownInterval = setInterval(function () { + updateCountdown(); + updateTimelineCursor(); + }, 1000); + } + + function updateCountdown() { + var station = state.selectedStation; + if (!station || !station.schedule || !station.schedule.length) return; + + var now = new Date(); + var nowMin = now.getUTCHours() * 60 + now.getUTCMinutes() + now.getUTCSeconds() / 60; + + // Find next upcoming or currently active broadcast + var bestDiff = Infinity; + var bestEntry = null; + var isActive = false; + + station.schedule.forEach(function (entry) { + var parts = entry.utc.split(':'); + var startMin = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10); + var duration = entry.duration_min || 20; + + // Check if currently active + var elapsed = nowMin - startMin; + if (elapsed < 0) elapsed += 1440; + if (elapsed >= 0 && elapsed < duration) { + bestEntry = entry; + bestDiff = 0; + isActive = true; + return; + } + + // Time until start + var diff = startMin - nowMin; + if (diff < 0) diff += 1440; + if (diff < bestDiff) { + bestDiff = diff; + bestEntry = entry; + } + }); + + if (!bestEntry) return; + + var hoursEl = document.getElementById('wefaxCdHours'); + var minsEl = document.getElementById('wefaxCdMins'); + var secsEl = document.getElementById('wefaxCdSecs'); + var contentEl = document.getElementById('wefaxCountdownContent'); + var detailEl = document.getElementById('wefaxCountdownDetail'); + var boxes = document.getElementById('wefaxCountdownBoxes'); + + if (isActive) { + // Show "LIVE" countdown + var parts = bestEntry.utc.split(':'); + var startMin2 = parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10); + var duration2 = bestEntry.duration_min || 20; + var elapsed2 = nowMin - startMin2; + if (elapsed2 < 0) elapsed2 += 1440; + var remaining = duration2 - elapsed2; + var remTotalSec = Math.max(0, Math.floor(remaining * 60)); + var h = Math.floor(remTotalSec / 3600); + var m = Math.floor((remTotalSec % 3600) / 60); + var s = remTotalSec % 60; + + if (hoursEl) hoursEl.textContent = String(h).padStart(2, '0'); + if (minsEl) minsEl.textContent = String(m).padStart(2, '0'); + if (secsEl) secsEl.textContent = String(s).padStart(2, '0'); + if (contentEl) contentEl.textContent = bestEntry.content; + if (detailEl) detailEl.textContent = 'LIVE — ' + bestEntry.utc + ' UTC'; + + // Set active class on boxes + if (boxes) { + var boxEls = boxes.querySelectorAll('.wefax-countdown-box'); + for (var i = 0; i < boxEls.length; i++) { + boxEls[i].classList.remove('imminent'); + boxEls[i].classList.add('active'); + } + } + } else { + // Countdown to next + var totalSec = Math.max(0, Math.floor(bestDiff * 60)); + var h2 = Math.floor(totalSec / 3600); + var m2 = Math.floor((totalSec % 3600) / 60); + var s2 = totalSec % 60; + + if (hoursEl) hoursEl.textContent = String(h2).padStart(2, '0'); + if (minsEl) minsEl.textContent = String(m2).padStart(2, '0'); + if (secsEl) secsEl.textContent = String(s2).padStart(2, '0'); + if (contentEl) contentEl.textContent = bestEntry.content; + if (detailEl) detailEl.textContent = 'Next at ' + bestEntry.utc + ' UTC'; + + // Set imminent class when < 10 min + if (boxes) { + var boxEls2 = boxes.querySelectorAll('.wefax-countdown-box'); + var isImminent = bestDiff < 10; + for (var j = 0; j < boxEls2.length; j++) { + boxEls2[j].classList.remove('active'); + if (isImminent) { + boxEls2[j].classList.add('imminent'); + } else { + boxEls2[j].classList.remove('imminent'); + } + } + } + } + } + + function stopCountdownTimer() { + if (state.countdownInterval) { + clearInterval(state.countdownInterval); + state.countdownInterval = null; + } + } + // ---- Auto-Capture Scheduler ---- function checkSchedulerStatus() { diff --git a/templates/index.html b/templates/index.html index 3672fc2..01b1c01 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2568,6 +2568,28 @@
+ + + + + + @@ -201,6 +202,7 @@ {{ mobile_item('sensor', '433MHz', '') }} {{ mobile_item('rtlamr', 'Meters', '') }} {{ mobile_item('subghz', 'SubGHz', '') }} + {{ mobile_item('morse', 'Morse', '') }} {# Tracking #} {{ mobile_item('adsb', 'Aircraft', '', '/adsb/dashboard') }} {{ mobile_item('ais', 'Vessels', '', '/ais/dashboard') }} diff --git a/tests/test_morse.py b/tests/test_morse.py new file mode 100644 index 0000000..bb6da32 --- /dev/null +++ b/tests/test_morse.py @@ -0,0 +1,393 @@ +"""Tests for Morse code decoder (utils/morse.py) and routes.""" + +from __future__ import annotations + +import math +import queue +import struct +import threading + +import pytest + +from utils.morse import ( + CHAR_TO_MORSE, + MORSE_TABLE, + GoertzelFilter, + MorseDecoder, + morse_decoder_thread, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _login_session(client) -> None: + """Mark the Flask test session as authenticated.""" + with client.session_transaction() as sess: + sess['logged_in'] = True + sess['username'] = 'test' + sess['role'] = 'admin' + + +def generate_tone(freq: float, duration: float, sample_rate: int = 8000, amplitude: float = 0.8) -> bytes: + """Generate a pure sine wave as 16-bit LE PCM bytes.""" + n_samples = int(sample_rate * duration) + samples = [] + for i in range(n_samples): + t = i / sample_rate + val = int(amplitude * 32767 * math.sin(2 * math.pi * freq * t)) + samples.append(max(-32768, min(32767, val))) + return struct.pack(f'<{len(samples)}h', *samples) + + +def generate_silence(duration: float, sample_rate: int = 8000) -> bytes: + """Generate silence as 16-bit LE PCM bytes.""" + n_samples = int(sample_rate * duration) + return b'\x00\x00' * n_samples + + +def generate_morse_audio(text: str, wpm: int = 15, tone_freq: float = 700.0, sample_rate: int = 8000) -> bytes: + """Generate PCM audio for a Morse-encoded string.""" + dit_dur = 1.2 / wpm + dah_dur = 3 * dit_dur + element_gap = dit_dur + char_gap = 3 * dit_dur + word_gap = 7 * dit_dur + + audio = b'' + words = text.upper().split() + for wi, word in enumerate(words): + for ci, char in enumerate(word): + morse = CHAR_TO_MORSE.get(char) + if morse is None: + continue + for ei, element in enumerate(morse): + if element == '.': + audio += generate_tone(tone_freq, dit_dur, sample_rate) + elif element == '-': + audio += generate_tone(tone_freq, dah_dur, sample_rate) + if ei < len(morse) - 1: + audio += generate_silence(element_gap, sample_rate) + if ci < len(word) - 1: + audio += generate_silence(char_gap, sample_rate) + if wi < len(words) - 1: + audio += generate_silence(word_gap, sample_rate) + + # Add some leading/trailing silence for threshold settling + silence = generate_silence(0.3, sample_rate) + return silence + audio + silence + + +# --------------------------------------------------------------------------- +# MORSE_TABLE tests +# --------------------------------------------------------------------------- + +class TestMorseTable: + def test_all_26_letters_present(self): + chars = set(MORSE_TABLE.values()) + for letter in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ': + assert letter in chars, f"Missing letter: {letter}" + + def test_all_10_digits_present(self): + chars = set(MORSE_TABLE.values()) + for digit in '0123456789': + assert digit in chars, f"Missing digit: {digit}" + + def test_reverse_lookup_consistent(self): + for morse, char in MORSE_TABLE.items(): + if char in CHAR_TO_MORSE: + assert CHAR_TO_MORSE[char] == morse + + def test_no_duplicate_morse_codes(self): + """Each morse pattern should map to exactly one character.""" + assert len(MORSE_TABLE) == len(set(MORSE_TABLE.keys())) + + +# --------------------------------------------------------------------------- +# GoertzelFilter tests +# --------------------------------------------------------------------------- + +class TestGoertzelFilter: + def test_detects_target_frequency(self): + gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160) + # Generate 700 Hz tone + samples = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(160)] + mag = gf.magnitude(samples) + assert mag > 10.0, f"Expected high magnitude for target freq, got {mag}" + + def test_rejects_off_frequency(self): + gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160) + # Generate 1500 Hz tone (well off target) + samples = [0.8 * math.sin(2 * math.pi * 1500 * i / 8000) for i in range(160)] + mag_off = gf.magnitude(samples) + + # Compare with on-target + samples_on = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(160)] + mag_on = gf.magnitude(samples_on) + + assert mag_on > mag_off * 3, "Target freq should be significantly stronger than off-freq" + + def test_silence_returns_near_zero(self): + gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160) + samples = [0.0] * 160 + mag = gf.magnitude(samples) + assert mag < 0.01, f"Expected near-zero for silence, got {mag}" + + def test_different_block_sizes(self): + for block_size in [80, 160, 320]: + gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=block_size) + samples = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(block_size)] + mag = gf.magnitude(samples) + assert mag > 5.0, f"Should detect tone with block_size={block_size}" + + +# --------------------------------------------------------------------------- +# MorseDecoder tests +# --------------------------------------------------------------------------- + +class TestMorseDecoder: + def _make_decoder(self, wpm=15): + """Create decoder with pre-warmed threshold for testing.""" + decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=wpm) + # Warm up noise floor with silence + silence = generate_silence(0.5) + decoder.process_block(silence) + # Warm up signal peak with tone + tone = generate_tone(700.0, 0.3) + decoder.process_block(tone) + # More silence to settle + silence2 = generate_silence(0.5) + decoder.process_block(silence2) + # Reset state after warm-up + decoder._tone_on = False + decoder._current_symbol = '' + decoder._tone_blocks = 0 + decoder._silence_blocks = 0 + return decoder + + def test_dit_detection(self): + """A single dit should produce a '.' in the symbol buffer.""" + decoder = self._make_decoder() + dit_dur = 1.2 / 15 + + # Send a tone burst (dit) + tone = generate_tone(700.0, dit_dur) + decoder.process_block(tone) + + # Send silence to trigger end of tone + silence = generate_silence(dit_dur * 2) + decoder.process_block(silence) + + # Symbol buffer should have a dot + assert '.' in decoder._current_symbol, f"Expected '.' in symbol, got '{decoder._current_symbol}'" + + def test_dah_detection(self): + """A longer tone should produce a '-' in the symbol buffer.""" + decoder = self._make_decoder() + dah_dur = 3 * 1.2 / 15 + + tone = generate_tone(700.0, dah_dur) + decoder.process_block(tone) + + silence = generate_silence(dah_dur) + decoder.process_block(silence) + + assert '-' in decoder._current_symbol, f"Expected '-' in symbol, got '{decoder._current_symbol}'" + + def test_decode_letter_e(self): + """E is a single dit - the simplest character.""" + decoder = self._make_decoder() + audio = generate_morse_audio('E', wpm=15) + events = decoder.process_block(audio) + events.extend(decoder.flush()) + + chars = [e for e in events if e['type'] == 'morse_char'] + decoded = ''.join(e['char'] for e in chars) + assert 'E' in decoded, f"Expected 'E' in decoded text, got '{decoded}'" + + def test_decode_letter_t(self): + """T is a single dah.""" + decoder = self._make_decoder() + audio = generate_morse_audio('T', wpm=15) + events = decoder.process_block(audio) + events.extend(decoder.flush()) + + chars = [e for e in events if e['type'] == 'morse_char'] + decoded = ''.join(e['char'] for e in chars) + assert 'T' in decoded, f"Expected 'T' in decoded text, got '{decoded}'" + + def test_word_space_detection(self): + """A long silence between words should produce decoded chars with a space.""" + decoder = self._make_decoder() + dit_dur = 1.2 / 15 + # E = dit + audio = generate_tone(700.0, dit_dur) + generate_silence(7 * dit_dur * 1.5) + # T = dah + audio += generate_tone(700.0, 3 * dit_dur) + generate_silence(3 * dit_dur) + events = decoder.process_block(audio) + events.extend(decoder.flush()) + + spaces = [e for e in events if e['type'] == 'morse_space'] + assert len(spaces) >= 1, "Expected at least one word space" + + def test_scope_events_generated(self): + """Decoder should produce scope events for visualization.""" + audio = generate_morse_audio('SOS', wpm=15) + decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15) + + events = decoder.process_block(audio) + + scope_events = [e for e in events if e['type'] == 'scope'] + assert len(scope_events) > 0, "Expected scope events" + # Check scope event structure + se = scope_events[0] + assert 'amplitudes' in se + assert 'threshold' in se + assert 'tone_on' in se + + def test_adaptive_threshold_adjusts(self): + """After processing audio, threshold should be non-zero.""" + decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15) + + # Process some tone + silence + audio = generate_tone(700.0, 0.3) + generate_silence(0.3) + decoder.process_block(audio) + + assert decoder._threshold > 0, "Threshold should adapt above zero" + + def test_flush_emits_pending_char(self): + """flush() should emit any accumulated but not-yet-decoded symbol.""" + decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15) + decoder._current_symbol = '.' # Manually set pending dit + events = decoder.flush() + assert len(events) == 1 + assert events[0]['type'] == 'morse_char' + assert events[0]['char'] == 'E' + + def test_flush_empty_returns_nothing(self): + decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15) + events = decoder.flush() + assert events == [] + + +# --------------------------------------------------------------------------- +# morse_decoder_thread tests +# --------------------------------------------------------------------------- + +class TestMorseDecoderThread: + def test_thread_stops_on_event(self): + """Thread should exit when stop_event is set.""" + import io + # Create a fake stdout that blocks until stop + stop = threading.Event() + q = queue.Queue(maxsize=100) + + # Feed some audio then close + audio = generate_morse_audio('E', wpm=15) + fake_stdout = io.BytesIO(audio) + + t = threading.Thread( + target=morse_decoder_thread, + args=(fake_stdout, q, stop), + ) + t.daemon = True + t.start() + t.join(timeout=5) + assert not t.is_alive(), "Thread should finish after reading all data" + + def test_thread_produces_events(self): + """Thread should push character events to the queue.""" + import io + from unittest.mock import patch + stop = threading.Event() + q = queue.Queue(maxsize=1000) + + # Generate audio with pre-warmed decoder in mind + # The thread creates a fresh decoder, so generate lots of audio + audio = generate_silence(0.5) + generate_morse_audio('SOS', wpm=10) + generate_silence(1.0) + fake_stdout = io.BytesIO(audio) + + # Patch SCOPE_INTERVAL to 0 so scope events aren't throttled in fast reads + with patch('utils.morse.time') as mock_time: + # Make monotonic() always return increasing values + counter = [0.0] + def fake_monotonic(): + counter[0] += 0.15 # each call advances 150ms + return counter[0] + mock_time.monotonic = fake_monotonic + + t = threading.Thread( + target=morse_decoder_thread, + args=(fake_stdout, q, stop), + ) + t.daemon = True + t.start() + t.join(timeout=10) + + events = [] + while not q.empty(): + events.append(q.get_nowait()) + + # Should have at least some events (scope or char) + assert len(events) > 0, "Expected events from thread" + + +# --------------------------------------------------------------------------- +# Route tests +# --------------------------------------------------------------------------- + +class TestMorseRoutes: + def test_start_missing_required_fields(self, client): + """Start should succeed with defaults.""" + _login_session(client) + with pytest.MonkeyPatch.context() as m: + m.setattr('app.morse_process', None) + # Should fail because rtl_fm won't be found in test env + resp = client.post('/morse/start', json={'frequency': '14.060'}) + assert resp.status_code in (200, 400, 409, 500) + + def test_stop_when_not_running(self, client): + """Stop when nothing is running should return not_running.""" + _login_session(client) + with pytest.MonkeyPatch.context() as m: + m.setattr('app.morse_process', None) + resp = client.post('/morse/stop') + data = resp.get_json() + assert data['status'] == 'not_running' + + def test_status_when_not_running(self, client): + """Status should report not running.""" + _login_session(client) + with pytest.MonkeyPatch.context() as m: + m.setattr('app.morse_process', None) + resp = client.get('/morse/status') + data = resp.get_json() + assert data['running'] is False + + def test_invalid_tone_freq(self, client): + """Tone frequency outside range should be rejected.""" + _login_session(client) + with pytest.MonkeyPatch.context() as m: + m.setattr('app.morse_process', None) + resp = client.post('/morse/start', json={ + 'frequency': '14.060', + 'tone_freq': '50', # too low + }) + assert resp.status_code == 400 + + def test_invalid_wpm(self, client): + """WPM outside range should be rejected.""" + _login_session(client) + with pytest.MonkeyPatch.context() as m: + m.setattr('app.morse_process', None) + resp = client.post('/morse/start', json={ + 'frequency': '14.060', + 'wpm': '100', # too high + }) + assert resp.status_code == 400 + + def test_stream_endpoint_exists(self, client): + """Stream endpoint should return SSE content type.""" + _login_session(client) + resp = client.get('/morse/stream') + assert resp.content_type.startswith('text/event-stream') diff --git a/utils/morse.py b/utils/morse.py new file mode 100644 index 0000000..cd354f3 --- /dev/null +++ b/utils/morse.py @@ -0,0 +1,276 @@ +"""Morse code (CW) decoder using Goertzel tone detection. + +Signal chain: rtl_fm -M usb → raw PCM → Goertzel filter → timing state machine → characters. +""" + +from __future__ import annotations + +import contextlib +import math +import queue +import struct +import threading +import time +from datetime import datetime +from typing import Any + +# International Morse Code table +MORSE_TABLE: dict[str, str] = { + '.-': 'A', '-...': 'B', '-.-.': 'C', '-..': 'D', '.': 'E', + '..-.': 'F', '--.': 'G', '....': 'H', '..': 'I', '.---': 'J', + '-.-': 'K', '.-..': 'L', '--': 'M', '-.': 'N', '---': 'O', + '.--.': 'P', '--.-': 'Q', '.-.': 'R', '...': 'S', '-': 'T', + '..-': 'U', '...-': 'V', '.--': 'W', '-..-': 'X', '-.--': 'Y', + '--..': 'Z', + '-----': '0', '.----': '1', '..---': '2', '...--': '3', + '....-': '4', '.....': '5', '-....': '6', '--...': '7', + '---..': '8', '----.': '9', + '.-.-.-': '.', '--..--': ',', '..--..': '?', '.----.': "'", + '-.-.--': '!', '-..-.': '/', '-.--.': '(', '-.--.-': ')', + '.-...': '&', '---...': ':', '-.-.-.': ';', '-...-': '=', + '.-.-.': '+', '-....-': '-', '..--.-': '_', '.-..-.': '"', + '...-..-': '$', '.--.-.': '@', + # Prosigns (unique codes only; -...- and -.--.- already mapped above) + '-.-.-': '', '.-.-': '', '...-.-': '', +} + +# Reverse lookup: character → morse notation +CHAR_TO_MORSE: dict[str, str] = {v: k for k, v in MORSE_TABLE.items()} + + +class GoertzelFilter: + """Single-frequency tone detector using the Goertzel algorithm. + + O(N) per block, much cheaper than FFT for detecting one frequency. + """ + + def __init__(self, target_freq: float, sample_rate: int, block_size: int): + self.target_freq = target_freq + self.sample_rate = sample_rate + self.block_size = block_size + # Precompute coefficient + k = round(target_freq * block_size / sample_rate) + omega = 2.0 * math.pi * k / block_size + self.coeff = 2.0 * math.cos(omega) + + def magnitude(self, samples: list[float] | tuple[float, ...]) -> float: + """Compute magnitude of the target frequency in the sample block.""" + s0 = 0.0 + s1 = 0.0 + s2 = 0.0 + coeff = self.coeff + for sample in samples: + s0 = sample + coeff * s1 - s2 + s2 = s1 + s1 = s0 + return math.sqrt(s1 * s1 + s2 * s2 - coeff * s1 * s2) + + +class MorseDecoder: + """Real-time Morse decoder with adaptive threshold. + + Processes blocks of PCM audio and emits decoded characters. + Timing based on PARIS standard: dit = 1.2/WPM seconds. + """ + + def __init__( + self, + sample_rate: int = 8000, + tone_freq: float = 700.0, + wpm: int = 15, + ): + self.sample_rate = sample_rate + self.tone_freq = tone_freq + self.wpm = wpm + + # Goertzel filter: ~50 blocks/sec at 8kHz + self._block_size = sample_rate // 50 + self._filter = GoertzelFilter(tone_freq, sample_rate, self._block_size) + self._block_duration = self._block_size / sample_rate # seconds per block + + # Timing thresholds (in blocks, converted from seconds) + dit_sec = 1.2 / wpm + self._dah_threshold = 2.0 * dit_sec / self._block_duration # blocks + self._dit_min = 0.3 * dit_sec / self._block_duration # min blocks for dit + self._char_gap = 3.0 * dit_sec / self._block_duration # blocks + self._word_gap = 7.0 * dit_sec / self._block_duration # blocks + + # Adaptive threshold via EMA + self._noise_floor = 0.0 + self._signal_peak = 0.0 + self._threshold = 0.0 + self._ema_alpha = 0.1 # smoothing factor + + # State machine (counts in blocks, not wall-clock time) + self._tone_on = False + self._tone_blocks = 0 # blocks since tone started + self._silence_blocks = 0 # blocks since silence started + self._current_symbol = '' # accumulates dits/dahs for current char + self._pending_buffer: list[float] = [] + self._blocks_processed = 0 # total blocks for warm-up tracking + + def process_block(self, pcm_bytes: bytes) -> list[dict[str, Any]]: + """Process a chunk of 16-bit LE PCM and return decoded events. + + Returns list of event dicts with keys: + type: 'scope' | 'morse_char' | 'morse_space' + + type-specific fields + """ + events: list[dict[str, Any]] = [] + + # Unpack PCM samples + n_samples = len(pcm_bytes) // 2 + if n_samples == 0: + return events + + samples = struct.unpack(f'<{n_samples}h', pcm_bytes[:n_samples * 2]) + + # Feed samples into pending buffer and process in blocks + self._pending_buffer.extend(samples) + + amplitudes: list[float] = [] + + while len(self._pending_buffer) >= self._block_size: + block = self._pending_buffer[:self._block_size] + self._pending_buffer = self._pending_buffer[self._block_size:] + + # Normalize to [-1, 1] + normalized = [s / 32768.0 for s in block] + mag = self._filter.magnitude(normalized) + amplitudes.append(mag) + + self._blocks_processed += 1 + + # Update adaptive threshold + if mag < self._threshold or self._threshold == 0: + self._noise_floor += self._ema_alpha * (mag - self._noise_floor) + else: + self._signal_peak += self._ema_alpha * (mag - self._signal_peak) + + self._threshold = (self._noise_floor + self._signal_peak) / 2.0 + + tone_detected = mag > self._threshold and self._threshold > 0 + + if tone_detected and not self._tone_on: + # Tone just started - check silence duration for gaps + self._tone_on = True + silence_count = self._silence_blocks + self._tone_blocks = 0 + + if self._current_symbol and silence_count >= self._char_gap: + # Character gap - decode accumulated symbol + char = MORSE_TABLE.get(self._current_symbol) + if char: + events.append({ + 'type': 'morse_char', + 'char': char, + 'morse': self._current_symbol, + 'timestamp': datetime.now().strftime('%H:%M:%S'), + }) + + if silence_count >= self._word_gap: + events.append({ + 'type': 'morse_space', + 'timestamp': datetime.now().strftime('%H:%M:%S'), + }) + + self._current_symbol = '' + + elif not tone_detected and self._tone_on: + # Tone just ended - classify as dit or dah + self._tone_on = False + tone_count = self._tone_blocks + self._silence_blocks = 0 + + if tone_count >= self._dah_threshold: + self._current_symbol += '-' + elif tone_count >= self._dit_min: + self._current_symbol += '.' + + elif tone_detected and self._tone_on: + self._tone_blocks += 1 + + elif not tone_detected and not self._tone_on: + self._silence_blocks += 1 + + # Emit scope data for visualization (~10 Hz is handled by caller) + if amplitudes: + events.append({ + 'type': 'scope', + 'amplitudes': amplitudes, + 'threshold': self._threshold, + 'tone_on': self._tone_on, + }) + + return events + + def flush(self) -> list[dict[str, Any]]: + """Flush any pending symbol at end of stream.""" + events: list[dict[str, Any]] = [] + if self._current_symbol: + char = MORSE_TABLE.get(self._current_symbol) + if char: + events.append({ + 'type': 'morse_char', + 'char': char, + 'morse': self._current_symbol, + 'timestamp': datetime.now().strftime('%H:%M:%S'), + }) + self._current_symbol = '' + return events + + +def morse_decoder_thread( + rtl_stdout, + output_queue: queue.Queue, + stop_event: threading.Event, + sample_rate: int = 8000, + tone_freq: float = 700.0, + wpm: int = 15, +) -> None: + """Thread function: reads PCM from rtl_fm, decodes Morse, pushes to queue. + + Reads raw 16-bit LE PCM from *rtl_stdout* and feeds it through the + MorseDecoder, pushing scope and character events onto *output_queue*. + """ + import logging + logger = logging.getLogger('intercept.morse') + + CHUNK = 4096 # bytes per read (2048 samples at 16-bit mono) + SCOPE_INTERVAL = 0.1 # scope updates at ~10 Hz + last_scope = time.monotonic() + + decoder = MorseDecoder( + sample_rate=sample_rate, + tone_freq=tone_freq, + wpm=wpm, + ) + + try: + while not stop_event.is_set(): + data = rtl_stdout.read(CHUNK) + if not data: + break + + events = decoder.process_block(data) + + for event in events: + if event['type'] == 'scope': + # Throttle scope events to ~10 Hz + now = time.monotonic() + if now - last_scope >= SCOPE_INTERVAL: + last_scope = now + with contextlib.suppress(queue.Full): + output_queue.put_nowait(event) + else: + # Character and space events always go through + with contextlib.suppress(queue.Full): + output_queue.put_nowait(event) + + except Exception as e: + logger.debug(f"Morse decoder thread error: {e}") + finally: + # Flush any pending symbol + for event in decoder.flush(): + with contextlib.suppress(queue.Full): + output_queue.put_nowait(event) From ecdc060d81e26c601fd795203b57ed6713f0d5b2 Mon Sep 17 00:00:00 2001 From: Smittix Date: Wed, 25 Feb 2026 20:58:57 +0000 Subject: [PATCH 47/52] Add HackRF support to TSCM RF scan and misc improvements TSCM RF scan now auto-detects HackRF via SDRFactory and uses hackrf_sweep as an alternative to rtl_power. Also includes improvements to listening post, rtlamr, weather satellite, SubGHz, Meshtastic, SSTV, WeFax, and process monitor modules. Fixes #154 Co-Authored-By: Claude Opus 4.6 --- routes/listening_post.py | 992 +++--- routes/rtlamr.py | 55 +- routes/tscm.py | 115 +- static/js/modes/weather-satellite.js | 2597 +++++++-------- tests/test_subghz.py | 402 +-- tests/test_weather_sat_decoder.py | 3 +- utils/meshtastic.py | 99 +- utils/process_monitor.py | 23 +- utils/sstv/sstv_decoder.py | 94 +- utils/subghz.py | 4416 +++++++++++++------------- utils/weather_sat.py | 199 +- utils/wefax.py | 62 +- 12 files changed, 4630 insertions(+), 4427 deletions(-) diff --git a/routes/listening_post.py b/routes/listening_post.py index c2282f8..1ae8428 100644 --- a/routes/listening_post.py +++ b/routes/listening_post.py @@ -38,15 +38,15 @@ receiver_bp = Blueprint('receiver', __name__, url_prefix='/receiver') # ============================================ # Audio demodulation state -audio_process = None -audio_rtl_process = None -audio_lock = threading.Lock() -audio_start_lock = threading.Lock() -audio_running = False -audio_frequency = 0.0 -audio_modulation = 'fm' -audio_source = 'process' -audio_start_token = 0 +audio_process = None +audio_rtl_process = None +audio_lock = threading.Lock() +audio_start_lock = threading.Lock() +audio_running = False +audio_frequency = 0.0 +audio_modulation = 'fm' +audio_source = 'process' +audio_start_token = 0 # Scanner state scanner_thread: Optional[threading.Thread] = None @@ -665,238 +665,244 @@ def scanner_loop_power(): logger.info("Power sweep scanner thread stopped") -def _start_audio_stream( - frequency: float, - modulation: str, - *, - device: int | None = None, - sdr_type: str | None = None, - gain: int | None = None, - squelch: int | None = None, - bias_t: bool | None = None, -): - """Start audio streaming at given frequency.""" - global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_modulation - - with audio_lock: - # Stop any existing stream - _stop_audio_stream_internal() +def _start_audio_stream( + frequency: float, + modulation: str, + *, + device: int | None = None, + sdr_type: str | None = None, + gain: int | None = None, + squelch: int | None = None, + bias_t: bool | None = None, +): + """Start audio streaming at given frequency.""" + global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_modulation + + # Stop existing stream and snapshot config under lock + with audio_lock: + _stop_audio_stream_internal() ffmpeg_path = find_ffmpeg() if not ffmpeg_path: logger.error("ffmpeg not found") return - # Snapshot runtime tuning config so the spawned demod command cannot - # drift if shared scanner_config changes while startup is in-flight. - device_index = int(device if device is not None else scanner_config.get('device', 0)) - gain_value = int(gain if gain is not None else scanner_config.get('gain', 40)) - squelch_value = int(squelch if squelch is not None else scanner_config.get('squelch', 0)) - bias_t_enabled = bool(scanner_config.get('bias_t', False) if bias_t is None else bias_t) - sdr_type_str = str(sdr_type if sdr_type is not None else scanner_config.get('sdr_type', 'rtlsdr')).lower() - - # Determine SDR type and build appropriate command - try: - sdr_type = SDRType(sdr_type_str) - except ValueError: - sdr_type = SDRType.RTL_SDR + # Snapshot runtime tuning config so the spawned demod command cannot + # drift if shared scanner_config changes while startup is in-flight. + device_index = int(device if device is not None else scanner_config.get('device', 0)) + gain_value = int(gain if gain is not None else scanner_config.get('gain', 40)) + squelch_value = int(squelch if squelch is not None else scanner_config.get('squelch', 0)) + bias_t_enabled = bool(scanner_config.get('bias_t', False) if bias_t is None else bias_t) + sdr_type_str = str(sdr_type if sdr_type is not None else scanner_config.get('sdr_type', 'rtlsdr')).lower() - # Set sample rates based on modulation - if modulation == 'wfm': - sample_rate = 170000 - resample_rate = 32000 - elif modulation in ['usb', 'lsb']: - sample_rate = 12000 - resample_rate = 12000 - else: - sample_rate = 24000 - resample_rate = 24000 + # Build commands outside lock (no blocking I/O, just command construction) + try: + resolved_sdr_type = SDRType(sdr_type_str) + except ValueError: + resolved_sdr_type = SDRType.RTL_SDR - # Build the SDR command based on device type - if sdr_type == SDRType.RTL_SDR: - # Use rtl_fm for RTL-SDR devices - rtl_fm_path = find_rtl_fm() - if not rtl_fm_path: - logger.error("rtl_fm not found") - return + # Set sample rates based on modulation + if modulation == 'wfm': + sample_rate = 170000 + resample_rate = 32000 + elif modulation in ['usb', 'lsb']: + sample_rate = 12000 + resample_rate = 12000 + else: + sample_rate = 24000 + resample_rate = 24000 - freq_hz = int(frequency * 1e6) - sdr_cmd = [ - rtl_fm_path, - '-M', _rtl_fm_demod_mode(modulation), - '-f', str(freq_hz), - '-s', str(sample_rate), - '-r', str(resample_rate), - '-g', str(gain_value), - '-d', str(device_index), - '-l', str(squelch_value), - ] - if bias_t_enabled: - sdr_cmd.append('-T') - # Omit explicit filename: rtl_fm defaults to stdout. - # (Some builds intermittently stall when '-' is passed explicitly.) - else: - # Use SDR abstraction layer for HackRF, Airspy, LimeSDR, SDRPlay - rx_fm_path = find_rx_fm() - if not rx_fm_path: - logger.error(f"rx_fm not found - required for {sdr_type.value}. Install SoapySDR utilities.") - return - - # Create device and get command builder - sdr_device = SDRFactory.create_default_device(sdr_type, index=device_index) - builder = SDRFactory.get_builder(sdr_type) - - # Build FM demod command - sdr_cmd = builder.build_fm_demod_command( - device=sdr_device, - frequency_mhz=frequency, - sample_rate=resample_rate, - gain=float(gain_value), - modulation=modulation, - squelch=squelch_value, - bias_t=bias_t_enabled, - ) - # Ensure we use the found rx_fm path - sdr_cmd[0] = rx_fm_path + # Build the SDR command based on device type + if resolved_sdr_type == SDRType.RTL_SDR: + rtl_fm_path = find_rtl_fm() + if not rtl_fm_path: + logger.error("rtl_fm not found") + return - encoder_cmd = [ - ffmpeg_path, - '-hide_banner', - '-loglevel', 'error', - '-fflags', 'nobuffer', - '-flags', 'low_delay', - '-probesize', '32', - '-analyzeduration', '0', - '-f', 's16le', - '-ar', str(resample_rate), - '-ac', '1', - '-i', 'pipe:0', - '-acodec', 'pcm_s16le', - '-ar', '44100', - '-f', 'wav', - 'pipe:1' + freq_hz = int(frequency * 1e6) + sdr_cmd = [ + rtl_fm_path, + '-M', _rtl_fm_demod_mode(modulation), + '-f', str(freq_hz), + '-s', str(sample_rate), + '-r', str(resample_rate), + '-g', str(gain_value), + '-d', str(device_index), + '-l', str(squelch_value), ] + if bias_t_enabled: + sdr_cmd.append('-T') + else: + rx_fm_path = find_rx_fm() + if not rx_fm_path: + logger.error(f"rx_fm not found - required for {resolved_sdr_type.value}. Install SoapySDR utilities.") + return - try: - # Use subprocess piping for reliable streaming. - # Log stderr to temp files for error diagnosis. - rtl_stderr_log = '/tmp/rtl_fm_stderr.log' - ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log' - logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={device_index}") + sdr_device = SDRFactory.create_default_device(resolved_sdr_type, index=device_index) + builder = SDRFactory.get_builder(resolved_sdr_type) + sdr_cmd = builder.build_fm_demod_command( + device=sdr_device, + frequency_mhz=frequency, + sample_rate=resample_rate, + gain=float(gain_value), + modulation=modulation, + squelch=squelch_value, + bias_t=bias_t_enabled, + ) + sdr_cmd[0] = rx_fm_path - # Retry loop for USB device contention (device may not be - # released immediately after a previous process exits) - max_attempts = 3 - for attempt in range(max_attempts): - audio_rtl_process = None - audio_process = None - rtl_err_handle = None - ffmpeg_err_handle = None - try: - rtl_err_handle = open(rtl_stderr_log, 'w') - ffmpeg_err_handle = open(ffmpeg_stderr_log, 'w') - audio_rtl_process = subprocess.Popen( - sdr_cmd, - stdout=subprocess.PIPE, - stderr=rtl_err_handle, - bufsize=0, - start_new_session=True # Create new process group for clean shutdown - ) - audio_process = subprocess.Popen( - encoder_cmd, - stdin=audio_rtl_process.stdout, - stdout=subprocess.PIPE, - stderr=ffmpeg_err_handle, - bufsize=0, - start_new_session=True # Create new process group for clean shutdown - ) - if audio_rtl_process.stdout: - audio_rtl_process.stdout.close() - finally: - if rtl_err_handle: - rtl_err_handle.close() - if ffmpeg_err_handle: - ffmpeg_err_handle.close() + encoder_cmd = [ + ffmpeg_path, + '-hide_banner', + '-loglevel', 'error', + '-fflags', 'nobuffer', + '-flags', 'low_delay', + '-probesize', '32', + '-analyzeduration', '0', + '-f', 's16le', + '-ar', str(resample_rate), + '-ac', '1', + '-i', 'pipe:0', + '-acodec', 'pcm_s16le', + '-ar', '44100', + '-f', 'wav', + 'pipe:1' + ] - # Brief delay to check if process started successfully - time.sleep(0.3) + # Retry loop outside lock — spawning + health check sleeps don't block + # other operations. audio_start_lock already serializes callers. + try: + rtl_stderr_log = '/tmp/rtl_fm_stderr.log' + ffmpeg_stderr_log = '/tmp/ffmpeg_stderr.log' + logger.info(f"Starting audio: {frequency} MHz, mod={modulation}, device={device_index}") - if (audio_rtl_process and audio_rtl_process.poll() is not None) or ( - audio_process and audio_process.poll() is not None - ): - # Read stderr from temp files - rtl_stderr = '' - ffmpeg_stderr = '' - try: - with open(rtl_stderr_log, 'r') as f: - rtl_stderr = f.read().strip() - except Exception: - pass - try: - with open(ffmpeg_stderr_log, 'r') as f: - ffmpeg_stderr = f.read().strip() - except Exception: - pass + new_rtl_proc = None + new_audio_proc = None + max_attempts = 3 + for attempt in range(max_attempts): + new_rtl_proc = None + new_audio_proc = None + rtl_err_handle = None + ffmpeg_err_handle = None + try: + rtl_err_handle = open(rtl_stderr_log, 'w') + ffmpeg_err_handle = open(ffmpeg_stderr_log, 'w') + new_rtl_proc = subprocess.Popen( + sdr_cmd, + stdout=subprocess.PIPE, + stderr=rtl_err_handle, + bufsize=0, + start_new_session=True + ) + new_audio_proc = subprocess.Popen( + encoder_cmd, + stdin=new_rtl_proc.stdout, + stdout=subprocess.PIPE, + stderr=ffmpeg_err_handle, + bufsize=0, + start_new_session=True + ) + if new_rtl_proc.stdout: + new_rtl_proc.stdout.close() + finally: + if rtl_err_handle: + rtl_err_handle.close() + if ffmpeg_err_handle: + ffmpeg_err_handle.close() - if 'usb_claim_interface' in rtl_stderr and attempt < max_attempts - 1: - logger.warning(f"USB device busy (attempt {attempt + 1}/{max_attempts}), waiting for release...") - if audio_process: - try: - audio_process.terminate() - audio_process.wait(timeout=0.5) - except Exception: - pass - if audio_rtl_process: - try: - audio_rtl_process.terminate() - audio_rtl_process.wait(timeout=0.5) - except Exception: - pass - time.sleep(1.0) - continue + # Brief delay to check if process started successfully + time.sleep(0.3) - if audio_process and audio_process.poll() is None: - try: - audio_process.terminate() - audio_process.wait(timeout=0.5) - except Exception: - pass - if audio_rtl_process and audio_rtl_process.poll() is None: - try: - audio_rtl_process.terminate() - audio_rtl_process.wait(timeout=0.5) - except Exception: - pass - audio_process = None - audio_rtl_process = None - - logger.error( - f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}" - ) - return - - # Pipeline started successfully - break - - # Keep monitor startup tolerant: some demod chains can take - # several seconds before producing stream bytes. - if ( - not audio_process - or not audio_rtl_process - or audio_process.poll() is not None - or audio_rtl_process.poll() is not None + if (new_rtl_proc and new_rtl_proc.poll() is not None) or ( + new_audio_proc and new_audio_proc.poll() is not None ): - logger.warning("Audio pipeline did not remain alive after startup") - _stop_audio_stream_internal() + rtl_stderr = '' + ffmpeg_stderr = '' + try: + with open(rtl_stderr_log, 'r') as f: + rtl_stderr = f.read().strip() + except Exception: + pass + try: + with open(ffmpeg_stderr_log, 'r') as f: + ffmpeg_stderr = f.read().strip() + except Exception: + pass + + if 'usb_claim_interface' in rtl_stderr and attempt < max_attempts - 1: + logger.warning(f"USB device busy (attempt {attempt + 1}/{max_attempts}), waiting for release...") + if new_audio_proc: + try: + new_audio_proc.terminate() + new_audio_proc.wait(timeout=0.5) + except Exception: + pass + if new_rtl_proc: + try: + new_rtl_proc.terminate() + new_rtl_proc.wait(timeout=0.5) + except Exception: + pass + time.sleep(1.0) + continue + + if new_audio_proc and new_audio_proc.poll() is None: + try: + new_audio_proc.terminate() + new_audio_proc.wait(timeout=0.5) + except Exception: + pass + if new_rtl_proc and new_rtl_proc.poll() is None: + try: + new_rtl_proc.terminate() + new_rtl_proc.wait(timeout=0.5) + except Exception: + pass + new_audio_proc = None + new_rtl_proc = None + + logger.error( + f"Audio pipeline exited immediately. rtl_fm stderr: {rtl_stderr}, ffmpeg stderr: {ffmpeg_stderr}" + ) return + # Pipeline started successfully + break + + # Verify pipeline is still alive, then install under lock + if ( + not new_audio_proc + or not new_rtl_proc + or new_audio_proc.poll() is not None + or new_rtl_proc.poll() is not None + ): + logger.warning("Audio pipeline did not remain alive after startup") + # Clean up failed processes + if new_audio_proc: + try: + new_audio_proc.terminate() + new_audio_proc.wait(timeout=0.5) + except Exception: + pass + if new_rtl_proc: + try: + new_rtl_proc.terminate() + new_rtl_proc.wait(timeout=0.5) + except Exception: + pass + return + + # Install processes under lock + with audio_lock: + audio_rtl_process = new_rtl_proc + audio_process = new_audio_proc audio_running = True audio_frequency = frequency audio_modulation = modulation - logger.info(f"Audio stream started: {frequency} MHz ({modulation}) via {sdr_type.value}") + logger.info(f"Audio stream started: {frequency} MHz ({modulation}) via {resolved_sdr_type.value}") - except Exception as e: - logger.error(f"Failed to start audio stream: {e}") + except Exception as e: + logger.error(f"Failed to start audio stream: {e}") def _stop_audio_stream(): @@ -1287,211 +1293,223 @@ def get_presets() -> Response: # MANUAL AUDIO ENDPOINTS (for direct listening) # ============================================ -@receiver_bp.route('/audio/start', methods=['POST']) -def start_audio() -> Response: - """Start audio at specific frequency (manual mode).""" - global scanner_running, scanner_active_device, receiver_active_device, scanner_power_process, scanner_thread - global audio_running, audio_frequency, audio_modulation, audio_source, audio_start_token - - data = request.json or {} - - try: - frequency = float(data.get('frequency', 0)) - modulation = normalize_modulation(data.get('modulation', 'wfm')) - squelch = int(data.get('squelch', 0)) - gain = int(data.get('gain', 40)) - device = int(data.get('device', 0)) - sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower() - request_token_raw = data.get('request_token') - request_token = int(request_token_raw) if request_token_raw is not None else None - bias_t_raw = data.get('bias_t', scanner_config.get('bias_t', False)) - if isinstance(bias_t_raw, str): - bias_t = bias_t_raw.strip().lower() in {'1', 'true', 'yes', 'on'} - else: - bias_t = bool(bias_t_raw) - except (ValueError, TypeError) as e: - return jsonify({ - 'status': 'error', - 'message': f'Invalid parameter: {e}' - }), 400 - - if frequency <= 0: - return jsonify({ - 'status': 'error', - 'message': 'frequency is required' - }), 400 - - valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay'] - if sdr_type not in valid_sdr_types: - return jsonify({ - 'status': 'error', - 'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}' - }), 400 - - with audio_start_lock: - if request_token is not None: - if request_token < audio_start_token: - return jsonify({ - 'status': 'stale', - 'message': 'Superseded audio start request', - 'source': audio_source, - 'superseded': True, - 'current_token': audio_start_token, - }), 409 - audio_start_token = request_token - else: - audio_start_token += 1 - request_token = audio_start_token - - # Stop scanner if running - if scanner_running: - scanner_running = False - if scanner_active_device is not None: - app_module.release_sdr_device(scanner_active_device) - scanner_active_device = None - if scanner_thread and scanner_thread.is_alive(): - try: - scanner_thread.join(timeout=2.0) - except Exception: - pass - if scanner_power_process and scanner_power_process.poll() is None: - try: - scanner_power_process.terminate() - scanner_power_process.wait(timeout=1) - except Exception: - try: - scanner_power_process.kill() - except Exception: - pass - scanner_power_process = None - try: - subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5) - except Exception: - pass - time.sleep(0.5) - - # Update config for audio - scanner_config['squelch'] = squelch - scanner_config['gain'] = gain - scanner_config['device'] = device - scanner_config['sdr_type'] = sdr_type - scanner_config['bias_t'] = bias_t - - # Preferred path: when waterfall WebSocket is active on the same SDR, - # derive monitor audio from that IQ stream instead of spawning rtl_fm. - try: - from routes.waterfall_websocket import ( - get_shared_capture_status, - start_shared_monitor_from_capture, - ) - - shared = get_shared_capture_status() - if shared.get('running') and shared.get('device') == device: - _stop_audio_stream() - ok, msg = start_shared_monitor_from_capture( - device=device, - frequency_mhz=frequency, - modulation=modulation, - squelch=squelch, - ) - if ok: - audio_running = True - audio_frequency = frequency - audio_modulation = modulation - audio_source = 'waterfall' - # Shared monitor uses the waterfall's existing SDR claim. - if receiver_active_device is not None: - app_module.release_sdr_device(receiver_active_device) - receiver_active_device = None - return jsonify({ - 'status': 'started', - 'frequency': frequency, - 'modulation': modulation, - 'source': 'waterfall', - 'request_token': request_token, - }) - logger.warning(f"Shared waterfall monitor unavailable: {msg}") - except Exception as e: - logger.debug(f"Shared waterfall monitor probe failed: {e}") - - # Stop waterfall if it's using the same SDR (SSE path) - if waterfall_running and waterfall_active_device == device: - _stop_waterfall_internal() - time.sleep(0.2) - - # Claim device for listening audio. The WebSocket waterfall handler - # may still be tearing down its IQ capture process (thread join + - # safe_terminate can take several seconds), so we retry with back-off - # to give the USB device time to be fully released. - if receiver_active_device is None or receiver_active_device != device: - if receiver_active_device is not None: - app_module.release_sdr_device(receiver_active_device) - receiver_active_device = None - - error = None - max_claim_attempts = 6 - for attempt in range(max_claim_attempts): - error = app_module.claim_sdr_device(device, 'receiver') - if not error: - break - if attempt < max_claim_attempts - 1: - logger.debug( - f"Device claim attempt {attempt + 1}/{max_claim_attempts} " - f"failed, retrying in 0.5s: {error}" - ) - time.sleep(0.5) - - if error: - return jsonify({ - 'status': 'error', - 'error_type': 'DEVICE_BUSY', - 'message': error - }), 409 - receiver_active_device = device - - _start_audio_stream( - frequency, - modulation, - device=device, - sdr_type=sdr_type, - gain=gain, - squelch=squelch, - bias_t=bias_t, - ) - - if audio_running: - audio_source = 'process' - return jsonify({ - 'status': 'started', - 'frequency': audio_frequency, - 'modulation': audio_modulation, - 'source': 'process', - 'request_token': request_token, - }) - - # Avoid leaving a stale device claim after startup failure. - if receiver_active_device is not None: - app_module.release_sdr_device(receiver_active_device) - receiver_active_device = None - - start_error = '' - for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'): - try: - with open(log_path, 'r') as handle: - content = handle.read().strip() - if content: - start_error = content.splitlines()[-1] - break - except Exception: - continue - - message = 'Failed to start audio. Check SDR device.' - if start_error: - message = f'Failed to start audio: {start_error}' - return jsonify({ - 'status': 'error', - 'message': message - }), 500 +@receiver_bp.route('/audio/start', methods=['POST']) +def start_audio() -> Response: + """Start audio at specific frequency (manual mode).""" + global scanner_running, scanner_active_device, receiver_active_device, scanner_power_process, scanner_thread + global audio_running, audio_frequency, audio_modulation, audio_source, audio_start_token + + data = request.json or {} + + try: + frequency = float(data.get('frequency', 0)) + modulation = normalize_modulation(data.get('modulation', 'wfm')) + squelch = int(data.get('squelch', 0)) + gain = int(data.get('gain', 40)) + device = int(data.get('device', 0)) + sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower() + request_token_raw = data.get('request_token') + request_token = int(request_token_raw) if request_token_raw is not None else None + bias_t_raw = data.get('bias_t', scanner_config.get('bias_t', False)) + if isinstance(bias_t_raw, str): + bias_t = bias_t_raw.strip().lower() in {'1', 'true', 'yes', 'on'} + else: + bias_t = bool(bias_t_raw) + except (ValueError, TypeError) as e: + return jsonify({ + 'status': 'error', + 'message': f'Invalid parameter: {e}' + }), 400 + + if frequency <= 0: + return jsonify({ + 'status': 'error', + 'message': 'frequency is required' + }), 400 + + valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay'] + if sdr_type not in valid_sdr_types: + return jsonify({ + 'status': 'error', + 'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}' + }), 400 + + with audio_start_lock: + if request_token is not None: + if request_token < audio_start_token: + return jsonify({ + 'status': 'stale', + 'message': 'Superseded audio start request', + 'source': audio_source, + 'superseded': True, + 'current_token': audio_start_token, + }), 409 + audio_start_token = request_token + else: + audio_start_token += 1 + request_token = audio_start_token + + # Grab scanner refs inside lock, signal stop, clear state + need_scanner_teardown = False + scanner_thread_ref = None + scanner_proc_ref = None + if scanner_running: + scanner_running = False + if scanner_active_device is not None: + app_module.release_sdr_device(scanner_active_device) + scanner_active_device = None + scanner_thread_ref = scanner_thread + scanner_proc_ref = scanner_power_process + scanner_power_process = None + need_scanner_teardown = True + + # Update config for audio + scanner_config['squelch'] = squelch + scanner_config['gain'] = gain + scanner_config['device'] = device + scanner_config['sdr_type'] = sdr_type + scanner_config['bias_t'] = bias_t + + # Scanner teardown outside lock (blocking: thread join, process wait, pkill, sleep) + if need_scanner_teardown: + if scanner_thread_ref and scanner_thread_ref.is_alive(): + try: + scanner_thread_ref.join(timeout=2.0) + except Exception: + pass + if scanner_proc_ref and scanner_proc_ref.poll() is None: + try: + scanner_proc_ref.terminate() + scanner_proc_ref.wait(timeout=1) + except Exception: + try: + scanner_proc_ref.kill() + except Exception: + pass + try: + subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5) + except Exception: + pass + time.sleep(0.5) + + # Re-acquire lock for waterfall check and device claim + with audio_start_lock: + + # Preferred path: when waterfall WebSocket is active on the same SDR, + # derive monitor audio from that IQ stream instead of spawning rtl_fm. + try: + from routes.waterfall_websocket import ( + get_shared_capture_status, + start_shared_monitor_from_capture, + ) + + shared = get_shared_capture_status() + if shared.get('running') and shared.get('device') == device: + _stop_audio_stream() + ok, msg = start_shared_monitor_from_capture( + device=device, + frequency_mhz=frequency, + modulation=modulation, + squelch=squelch, + ) + if ok: + audio_running = True + audio_frequency = frequency + audio_modulation = modulation + audio_source = 'waterfall' + # Shared monitor uses the waterfall's existing SDR claim. + if receiver_active_device is not None: + app_module.release_sdr_device(receiver_active_device) + receiver_active_device = None + return jsonify({ + 'status': 'started', + 'frequency': frequency, + 'modulation': modulation, + 'source': 'waterfall', + 'request_token': request_token, + }) + logger.warning(f"Shared waterfall monitor unavailable: {msg}") + except Exception as e: + logger.debug(f"Shared waterfall monitor probe failed: {e}") + + # Stop waterfall if it's using the same SDR (SSE path) + if waterfall_running and waterfall_active_device == device: + _stop_waterfall_internal() + time.sleep(0.2) + + # Claim device for listening audio. The WebSocket waterfall handler + # may still be tearing down its IQ capture process (thread join + + # safe_terminate can take several seconds), so we retry with back-off + # to give the USB device time to be fully released. + if receiver_active_device is None or receiver_active_device != device: + if receiver_active_device is not None: + app_module.release_sdr_device(receiver_active_device) + receiver_active_device = None + + error = None + max_claim_attempts = 6 + for attempt in range(max_claim_attempts): + error = app_module.claim_sdr_device(device, 'receiver') + if not error: + break + if attempt < max_claim_attempts - 1: + logger.debug( + f"Device claim attempt {attempt + 1}/{max_claim_attempts} " + f"failed, retrying in 0.5s: {error}" + ) + time.sleep(0.5) + + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error + }), 409 + receiver_active_device = device + + _start_audio_stream( + frequency, + modulation, + device=device, + sdr_type=sdr_type, + gain=gain, + squelch=squelch, + bias_t=bias_t, + ) + + if audio_running: + audio_source = 'process' + return jsonify({ + 'status': 'started', + 'frequency': audio_frequency, + 'modulation': audio_modulation, + 'source': 'process', + 'request_token': request_token, + }) + + # Avoid leaving a stale device claim after startup failure. + if receiver_active_device is not None: + app_module.release_sdr_device(receiver_active_device) + receiver_active_device = None + + start_error = '' + for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'): + try: + with open(log_path, 'r') as handle: + content = handle.read().strip() + if content: + start_error = content.splitlines()[-1] + break + except Exception: + continue + + message = 'Failed to start audio. Check SDR device.' + if start_error: + message = f'Failed to start audio: {start_error}' + return jsonify({ + 'status': 'error', + 'message': message + }), 500 @receiver_bp.route('/audio/stop', methods=['POST']) @@ -1606,64 +1624,64 @@ def audio_probe() -> Response: return jsonify({'status': 'ok', 'bytes': size}) -@receiver_bp.route('/audio/stream') -def stream_audio() -> Response: - """Stream WAV audio.""" - request_token_raw = request.args.get('request_token') - request_token = None - if request_token_raw is not None: - try: - request_token = int(request_token_raw) - except (ValueError, TypeError): - request_token = None - - if request_token is not None and request_token < audio_start_token: - return Response(b'', mimetype='audio/wav', status=204) - - if audio_source == 'waterfall': - for _ in range(40): - if audio_running: - break - time.sleep(0.05) +@receiver_bp.route('/audio/stream') +def stream_audio() -> Response: + """Stream WAV audio.""" + request_token_raw = request.args.get('request_token') + request_token = None + if request_token_raw is not None: + try: + request_token = int(request_token_raw) + except (ValueError, TypeError): + request_token = None + + if request_token is not None and request_token < audio_start_token: + return Response(b'', mimetype='audio/wav', status=204) + + if audio_source == 'waterfall': + for _ in range(40): + if audio_running: + break + time.sleep(0.05) if not audio_running: return Response(b'', mimetype='audio/wav', status=204) - def generate_shared(): - global audio_running, audio_source - try: - from routes.waterfall_websocket import ( - get_shared_capture_status, + def generate_shared(): + global audio_running, audio_source + try: + from routes.waterfall_websocket import ( + get_shared_capture_status, read_shared_monitor_audio_chunk, ) except Exception: return - # Browser expects an immediate WAV header. - yield _wav_header(sample_rate=48000) - inactive_since: float | None = None - - while audio_running and audio_source == 'waterfall': - if request_token is not None and request_token < audio_start_token: - break - chunk = read_shared_monitor_audio_chunk(timeout=1.0) - if chunk: - inactive_since = None - yield chunk - continue - shared = get_shared_capture_status() - if shared.get('running') and shared.get('monitor_enabled'): - inactive_since = None - continue - if inactive_since is None: - inactive_since = time.monotonic() - continue - if (time.monotonic() - inactive_since) < 4.0: - continue - if not shared.get('running') or not shared.get('monitor_enabled'): - audio_running = False - audio_source = 'process' - break + # Browser expects an immediate WAV header. + yield _wav_header(sample_rate=48000) + inactive_since: float | None = None + + while audio_running and audio_source == 'waterfall': + if request_token is not None and request_token < audio_start_token: + break + chunk = read_shared_monitor_audio_chunk(timeout=1.0) + if chunk: + inactive_since = None + yield chunk + continue + shared = get_shared_capture_status() + if shared.get('running') and shared.get('monitor_enabled'): + inactive_since = None + continue + if inactive_since is None: + inactive_since = time.monotonic() + continue + if (time.monotonic() - inactive_since) < 4.0: + continue + if not shared.get('running') or not shared.get('monitor_enabled'): + audio_running = False + audio_source = 'process' + break return Response( generate_shared(), @@ -1685,11 +1703,11 @@ def stream_audio() -> Response: if not audio_running or not audio_process: return Response(b'', mimetype='audio/wav', status=204) - def generate(): - # Capture local reference to avoid race condition with stop - proc = audio_process - if not proc or not proc.stdout: - return + def generate(): + # Capture local reference to avoid race condition with stop + proc = audio_process + if not proc or not proc.stdout: + return try: # Drain stale audio that accumulated in the pipe buffer # between pipeline start and stream connection. Keep the @@ -1708,17 +1726,17 @@ def stream_audio() -> Response: if header_chunk: yield header_chunk - # Stream real-time audio - first_chunk_deadline = time.time() + 20.0 - warned_wait = False - while audio_running and proc.poll() is None: - if request_token is not None and request_token < audio_start_token: - break - # Use select to avoid blocking forever - ready, _, _ = select.select([proc.stdout], [], [], 2.0) - if ready: - chunk = proc.stdout.read(8192) - if chunk: + # Stream real-time audio + first_chunk_deadline = time.time() + 20.0 + warned_wait = False + while audio_running and proc.poll() is None: + if request_token is not None and request_token < audio_start_token: + break + # Use select to avoid blocking forever + ready, _, _ = select.select([proc.stdout], [], [], 2.0) + if ready: + chunk = proc.stdout.read(8192) + if chunk: warned_wait = False yield chunk else: diff --git a/routes/rtlamr.py b/routes/rtlamr.py index 96bdc44..3acb291 100644 --- a/routes/rtlamr.py +++ b/routes/rtlamr.py @@ -138,36 +138,34 @@ def start_rtlamr() -> Response: output_format = data.get('format', 'json') # Start rtl_tcp first + rtl_tcp_just_started = False + rtl_tcp_cmd_str = '' with rtl_tcp_lock: if not rtl_tcp_process: logger.info("Starting rtl_tcp server...") try: rtl_tcp_cmd = ['rtl_tcp', '-a', '0.0.0.0'] - + # Add device index if not 0 if device and device != '0': rtl_tcp_cmd.extend(['-d', str(device)]) - + # Add gain if not auto if gain and gain != '0': rtl_tcp_cmd.extend(['-g', str(gain)]) - + # Add PPM correction if not 0 if ppm and ppm != '0': rtl_tcp_cmd.extend(['-p', str(ppm)]) - + rtl_tcp_process = subprocess.Popen( rtl_tcp_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) register_process(rtl_tcp_process) - - # Wait a moment for rtl_tcp to start - time.sleep(3) - - logger.info(f"rtl_tcp started: {' '.join(rtl_tcp_cmd)}") - app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {" ".join(rtl_tcp_cmd)}'}) + rtl_tcp_just_started = True + rtl_tcp_cmd_str = ' '.join(rtl_tcp_cmd) except Exception as e: logger.error(f"Failed to start rtl_tcp: {e}") # Release SDR device on rtl_tcp failure @@ -176,6 +174,12 @@ def start_rtlamr() -> Response: rtlamr_active_device = None return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500 + # Wait for rtl_tcp to start outside lock + if rtl_tcp_just_started: + time.sleep(3) + logger.info(f"rtl_tcp started: {rtl_tcp_cmd_str}") + app_module.rtlamr_queue.put({'type': 'info', 'text': f'rtl_tcp: {rtl_tcp_cmd_str}'}) + # Build rtlamr command cmd = [ 'rtlamr', @@ -258,25 +262,34 @@ def start_rtlamr() -> Response: def stop_rtlamr() -> Response: global rtl_tcp_process, rtlamr_active_device + # Grab process refs inside locks, clear state, then terminate outside + rtlamr_proc = None with app_module.rtlamr_lock: if app_module.rtlamr_process: - app_module.rtlamr_process.terminate() - try: - app_module.rtlamr_process.wait(timeout=2) - except subprocess.TimeoutExpired: - app_module.rtlamr_process.kill() + rtlamr_proc = app_module.rtlamr_process app_module.rtlamr_process = None + if rtlamr_proc: + rtlamr_proc.terminate() + try: + rtlamr_proc.wait(timeout=2) + except subprocess.TimeoutExpired: + rtlamr_proc.kill() + # Also stop rtl_tcp + tcp_proc = None with rtl_tcp_lock: if rtl_tcp_process: - rtl_tcp_process.terminate() - try: - rtl_tcp_process.wait(timeout=2) - except subprocess.TimeoutExpired: - rtl_tcp_process.kill() + tcp_proc = rtl_tcp_process rtl_tcp_process = None - logger.info("rtl_tcp stopped") + + if tcp_proc: + tcp_proc.terminate() + try: + tcp_proc.wait(timeout=2) + except subprocess.TimeoutExpired: + tcp_proc.kill() + logger.info("rtl_tcp stopped") # Release device from registry if rtlamr_active_device is not None: diff --git a/routes/tscm.py b/routes/tscm.py index 4f60b42..dd3a862 100644 --- a/routes/tscm.py +++ b/routes/tscm.py @@ -1345,7 +1345,7 @@ def _scan_rf_signals( sweep_ranges: list[dict] | None = None ) -> list[dict]: """ - Scan for RF signals using SDR (rtl_power). + Scan for RF signals using SDR (rtl_power or hackrf_sweep). Scans common surveillance frequency bands: - 88-108 MHz: FM broadcast (potential FM bugs) @@ -1375,39 +1375,50 @@ def _scan_rf_signals( logger.info(f"Starting RF scan (device={sdr_device})") + # Detect available SDR devices and sweep tools rtl_power_path = shutil.which('rtl_power') - if not rtl_power_path: - logger.warning("rtl_power not found in PATH, RF scanning unavailable") + hackrf_sweep_path = shutil.which('hackrf_sweep') + + sdr_type = None + sweep_tool_path = None + + try: + from utils.sdr import SDRFactory + from utils.sdr.base import SDRType + devices = SDRFactory.detect_devices() + rtlsdr_available = any(d.sdr_type == SDRType.RTL_SDR for d in devices) + hackrf_available = any(d.sdr_type == SDRType.HACKRF for d in devices) + except ImportError: + rtlsdr_available = False + hackrf_available = False + + # Pick the best available SDR + sweep tool combo + if rtlsdr_available and rtl_power_path: + sdr_type = 'rtlsdr' + sweep_tool_path = rtl_power_path + logger.info(f"Using RTL-SDR with rtl_power at: {rtl_power_path}") + elif hackrf_available and hackrf_sweep_path: + sdr_type = 'hackrf' + sweep_tool_path = hackrf_sweep_path + logger.info(f"Using HackRF with hackrf_sweep at: {hackrf_sweep_path}") + elif rtl_power_path: + # Tool exists but no device detected — try anyway (detection may have failed) + sdr_type = 'rtlsdr' + sweep_tool_path = rtl_power_path + logger.info(f"No SDR detected but rtl_power found, attempting RTL-SDR scan") + elif hackrf_sweep_path: + sdr_type = 'hackrf' + sweep_tool_path = hackrf_sweep_path + logger.info(f"No SDR detected but hackrf_sweep found, attempting HackRF scan") + + if not sweep_tool_path: + logger.warning("No supported sweep tool found (rtl_power or hackrf_sweep)") _emit_event('rf_status', { 'status': 'error', - 'message': 'rtl_power not installed. Install rtl-sdr package for RF scanning.', + 'message': 'No SDR sweep tool installed. Install rtl-sdr (rtl_power) or HackRF (hackrf_sweep) for RF scanning.', }) return signals - logger.info(f"Found rtl_power at: {rtl_power_path}") - - # Test if RTL-SDR device is accessible - rtl_test_path = shutil.which('rtl_test') - if rtl_test_path: - try: - test_result = subprocess.run( - [rtl_test_path, '-t'], - capture_output=True, - text=True, - timeout=5 - ) - if 'No supported devices found' in test_result.stderr or test_result.returncode != 0: - logger.warning("No RTL-SDR device found") - _emit_event('rf_status', { - 'status': 'error', - 'message': 'No RTL-SDR device connected. Connect an RTL-SDR dongle for RF scanning.', - }) - return signals - except subprocess.TimeoutExpired: - pass # Device might be busy, continue anyway - except Exception as e: - logger.debug(f"rtl_test check failed: {e}") - # Define frequency bands to scan (in Hz) # Format: (start_freq, end_freq, bin_size, description) scan_bands: list[tuple[int, int, int, str]] = [] @@ -1448,7 +1459,7 @@ def _scan_rf_signals( try: # Build device argument - device_arg = ['-d', str(sdr_device if sdr_device is not None else 0)] + device_idx = sdr_device if sdr_device is not None else 0 # Scan each band and look for strong signals for start_freq, end_freq, bin_size, band_name in scan_bands: @@ -1458,15 +1469,27 @@ def _scan_rf_signals( logger.info(f"Scanning {band_name} ({start_freq/1e6:.1f}-{end_freq/1e6:.1f} MHz)") try: - # Run rtl_power for a quick sweep of this band - cmd = [ - rtl_power_path, - '-f', f'{start_freq}:{end_freq}:{bin_size}', - '-g', '40', # Gain - '-i', '1', # Integration interval (1 second) - '-1', # Single shot mode - '-c', '20%', # Crop 20% of edges - ] + device_arg + [tmp_path] + # Build sweep command based on SDR type + if sdr_type == 'hackrf': + cmd = [ + sweep_tool_path, + '-f', f'{int(start_freq / 1e6)}:{int(end_freq / 1e6)}', + '-w', str(bin_size), + '-1', # Single sweep + ] + output_mode = 'stdout' + else: + cmd = [ + sweep_tool_path, + '-f', f'{start_freq}:{end_freq}:{bin_size}', + '-g', '40', # Gain + '-i', '1', # Integration interval (1 second) + '-1', # Single shot mode + '-c', '20%', # Crop 20% of edges + '-d', str(device_idx), + tmp_path, + ] + output_mode = 'file' logger.debug(f"Running: {' '.join(cmd)}") @@ -1478,9 +1501,14 @@ def _scan_rf_signals( ) if result.returncode != 0: - logger.warning(f"rtl_power returned {result.returncode}: {result.stderr}") + logger.warning(f"{os.path.basename(sweep_tool_path)} returned {result.returncode}: {result.stderr}") - # Parse the CSV output + # For HackRF, write stdout CSV data to temp file for unified parsing + if output_mode == 'stdout' and result.stdout: + with open(tmp_path, 'w') as f: + f.write(result.stdout) + + # Parse the CSV output (same format for both rtl_power and hackrf_sweep) if os.path.exists(tmp_path) and os.path.getsize(tmp_path) > 0: with open(tmp_path, 'r') as f: for line in f: @@ -1488,13 +1516,12 @@ def _scan_rf_signals( if len(parts) >= 7: try: # CSV format: date, time, hz_low, hz_high, hz_step, samples, db_values... - hz_low = int(parts[2]) - hz_high = int(parts[3]) - hz_step = float(parts[4]) + hz_low = int(parts[2].strip()) + hz_high = int(parts[3].strip()) + hz_step = float(parts[4].strip()) db_values = [float(x) for x in parts[6:] if x.strip()] # Find peaks above noise floor - # RTL-SDR dongles have higher noise figures, so use permissive thresholds noise_floor = sum(db_values) / len(db_values) if db_values else -100 threshold = noise_floor + 6 # Signal must be 6dB above noise diff --git a/static/js/modes/weather-satellite.js b/static/js/modes/weather-satellite.js index b1c7eee..1416604 100644 --- a/static/js/modes/weather-satellite.js +++ b/static/js/modes/weather-satellite.js @@ -3,14 +3,14 @@ * NOAA APT and Meteor LRPT decoder interface with auto-scheduler, * polar plot, styled real-world map, countdown, and timeline. */ - -const WeatherSat = (function() { - // State - let isRunning = false; - let eventSource = null; - let images = []; - let passes = []; - let selectedPassIndex = -1; + +const WeatherSat = (function() { + // State + let isRunning = false; + let eventSource = null; + let images = []; + let passes = []; + let selectedPassIndex = -1; let currentSatellite = null; let countdownInterval = null; let schedulerEnabled = false; @@ -21,22 +21,22 @@ const WeatherSat = (function() { let satCrosshairMarker = null; let observerMarker = null; let consoleEntries = []; - let consoleCollapsed = false; - let currentPhase = 'idle'; + let consoleCollapsed = false; + let currentPhase = 'idle'; let consoleAutoHideTimer = null; let currentModalFilename = null; let locationListenersAttached = false; - - /** - * Initialize the Weather Satellite mode - */ + + /** + * Initialize the Weather Satellite mode + */ function init() { checkStatus(); loadImages(); loadLocationInputs(); loadPasses(); - startCountdownTimer(); - checkSchedulerStatus(); + startCountdownTimer(); + checkSchedulerStatus(); initGroundMap(); } @@ -78,42 +78,42 @@ const WeatherSat = (function() { */ function loadLocationInputs() { const latInput = document.getElementById('wxsatObsLat'); - const lonInput = document.getElementById('wxsatObsLon'); - - let storedLat = localStorage.getItem('observerLat'); - let storedLon = localStorage.getItem('observerLon'); - if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) { - const shared = ObserverLocation.getShared(); - storedLat = shared.lat.toString(); - storedLon = shared.lon.toString(); - } - - if (latInput && storedLat) latInput.value = storedLat; - if (lonInput && storedLon) lonInput.value = storedLon; - - // Only attach listeners once — re-calling init() on mode switch must not - // accumulate duplicate listeners that fire loadPasses() multiple times. - if (!locationListenersAttached) { - if (latInput) latInput.addEventListener('change', saveLocationFromInputs); - if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs); - locationListenersAttached = true; - } - } - - /** - * Save location from inputs and refresh passes - */ - function saveLocationFromInputs() { - const latInput = document.getElementById('wxsatObsLat'); - const lonInput = document.getElementById('wxsatObsLon'); - - const lat = parseFloat(latInput?.value); - const lon = parseFloat(lonInput?.value); - - if (!isNaN(lat) && lat >= -90 && lat <= 90 && - !isNaN(lon) && lon >= -180 && lon <= 180) { - if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) { - ObserverLocation.setShared({ lat, lon }); + const lonInput = document.getElementById('wxsatObsLon'); + + let storedLat = localStorage.getItem('observerLat'); + let storedLon = localStorage.getItem('observerLon'); + if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) { + const shared = ObserverLocation.getShared(); + storedLat = shared.lat.toString(); + storedLon = shared.lon.toString(); + } + + if (latInput && storedLat) latInput.value = storedLat; + if (lonInput && storedLon) lonInput.value = storedLon; + + // Only attach listeners once — re-calling init() on mode switch must not + // accumulate duplicate listeners that fire loadPasses() multiple times. + if (!locationListenersAttached) { + if (latInput) latInput.addEventListener('change', saveLocationFromInputs); + if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs); + locationListenersAttached = true; + } + } + + /** + * Save location from inputs and refresh passes + */ + function saveLocationFromInputs() { + const latInput = document.getElementById('wxsatObsLat'); + const lonInput = document.getElementById('wxsatObsLon'); + + const lat = parseFloat(latInput?.value); + const lon = parseFloat(lonInput?.value); + + if (!isNaN(lat) && lat >= -90 && lat <= 90 && + !isNaN(lon) && lon >= -180 && lon <= 180) { + if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) { + ObserverLocation.setShared({ lat, lon }); } else { localStorage.setItem('observerLat', lat.toString()); localStorage.setItem('observerLon', lon.toString()); @@ -122,419 +122,422 @@ const WeatherSat = (function() { centerGroundMapOnObserver(1); } } - - /** - * Use GPS for location - */ - function useGPS(btn) { - if (!navigator.geolocation) { - showNotification('Weather Sat', 'GPS not available in this browser'); - return; - } - - const originalText = btn.innerHTML; - btn.innerHTML = '...'; - btn.disabled = true; - - navigator.geolocation.getCurrentPosition( - (pos) => { - const latInput = document.getElementById('wxsatObsLat'); - const lonInput = document.getElementById('wxsatObsLon'); - - const lat = pos.coords.latitude.toFixed(4); - const lon = pos.coords.longitude.toFixed(4); - - if (latInput) latInput.value = lat; - if (lonInput) lonInput.value = lon; - - if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) { - ObserverLocation.setShared({ lat: parseFloat(lat), lon: parseFloat(lon) }); - } else { - localStorage.setItem('observerLat', lat); - localStorage.setItem('observerLon', lon); - } - + + /** + * Use GPS for location + */ + function useGPS(btn) { + if (!navigator.geolocation) { + showNotification('Weather Sat', 'GPS not available in this browser'); + return; + } + + const originalText = btn.innerHTML; + btn.innerHTML = '...'; + btn.disabled = true; + + navigator.geolocation.getCurrentPosition( + (pos) => { + const latInput = document.getElementById('wxsatObsLat'); + const lonInput = document.getElementById('wxsatObsLon'); + + const lat = pos.coords.latitude.toFixed(4); + const lon = pos.coords.longitude.toFixed(4); + + if (latInput) latInput.value = lat; + if (lonInput) lonInput.value = lon; + + if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) { + ObserverLocation.setShared({ lat: parseFloat(lat), lon: parseFloat(lon) }); + } else { + localStorage.setItem('observerLat', lat); + localStorage.setItem('observerLon', lon); + } + btn.innerHTML = originalText; btn.disabled = false; showNotification('Weather Sat', 'Location updated'); loadPasses(); centerGroundMapOnObserver(1); }, - (err) => { - btn.innerHTML = originalText; - btn.disabled = false; - showNotification('Weather Sat', 'Failed to get location'); - }, - { enableHighAccuracy: true, timeout: 10000 } - ); - } - - /** - * Check decoder status - */ - async function checkStatus() { - try { - const response = await fetch('/weather-sat/status'); - const data = await response.json(); - - if (!data.available) { - updateStatusUI('unavailable', 'SatDump not installed'); - return; - } - - if (data.running) { - isRunning = true; - currentSatellite = data.satellite; - updateStatusUI('capturing', `Capturing ${data.satellite}...`); - startStream(); - } else { - updateStatusUI('idle', 'Idle'); - } - } catch (err) { - console.error('Failed to check weather sat status:', err); - } - } - - /** - * Start capture - */ - async function start() { - const satSelect = document.getElementById('weatherSatSelect'); - const gainInput = document.getElementById('weatherSatGain'); - const biasTInput = document.getElementById('weatherSatBiasT'); - const deviceSelect = document.getElementById('deviceSelect'); - - const satellite = satSelect?.value || 'METEOR-M2-3'; - const gain = parseFloat(gainInput?.value || '40'); - const biasT = biasTInput?.checked || false; - const device = parseInt(deviceSelect?.value || '0', 10); - - clearConsole(); - showConsole(true); - updatePhaseIndicator('tuning'); - addConsoleEntry('Starting capture...', 'info'); - updateStatusUI('connecting', 'Starting...'); - - try { - const response = await fetch('/weather-sat/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - satellite, - device, - gain, - bias_t: biasT, - }) - }); - - const data = await response.json(); - - if (data.status === 'started' || data.status === 'already_running') { - isRunning = true; - currentSatellite = data.satellite || satellite; - updateStatusUI('capturing', `${data.satellite} ${data.frequency} MHz`); - updateFreqDisplay(data.frequency, data.mode); - startStream(); - showNotification('Weather Sat', `Capturing ${data.satellite} on ${data.frequency} MHz`); - } else { - updateStatusUI('idle', 'Start failed'); - showNotification('Weather Sat', data.message || 'Failed to start'); - } - } catch (err) { - console.error('Failed to start weather sat:', err); - updateStatusUI('idle', 'Error'); - showNotification('Weather Sat', 'Connection error'); - } - } - - /** - * Start capture for a specific pass - */ - function startPass(satellite) { - const satSelect = document.getElementById('weatherSatSelect'); - if (satSelect) { - satSelect.value = satellite; - } - start(); - } - - /** - * Stop capture - */ - async function stop() { - try { - await fetch('/weather-sat/stop', { method: 'POST' }); - isRunning = false; - stopStream(); - updateStatusUI('idle', 'Stopped'); - showNotification('Weather Sat', 'Capture stopped'); - } catch (err) { - console.error('Failed to stop weather sat:', err); - } - } - - /** - * 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 || 'METEOR-M2-3'; - 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 - */ - function updateStatusUI(status, text) { - const dot = document.getElementById('wxsatStripDot'); - const statusText = document.getElementById('wxsatStripStatus'); - const startBtn = document.getElementById('wxsatStartBtn'); - const stopBtn = document.getElementById('wxsatStopBtn'); - - if (dot) { - dot.className = 'wxsat-strip-dot'; - if (status === 'capturing') dot.classList.add('capturing'); - else if (status === 'decoding') dot.classList.add('decoding'); - } - - if (statusText) statusText.textContent = text || status; - - if (startBtn && stopBtn) { - if (status === 'capturing' || status === 'decoding') { - startBtn.style.display = 'none'; - stopBtn.style.display = 'inline-block'; - } else { - startBtn.style.display = 'inline-block'; - stopBtn.style.display = 'none'; - } - } - } - - /** - * Update frequency display in strip - */ - function updateFreqDisplay(freq, mode) { - const freqEl = document.getElementById('wxsatStripFreq'); - const modeEl = document.getElementById('wxsatStripMode'); - if (freqEl) freqEl.textContent = freq || '--'; - if (modeEl) modeEl.textContent = mode || '--'; - } - - /** - * Start SSE stream - */ - function startStream() { - if (eventSource) eventSource.close(); - - eventSource = new EventSource('/weather-sat/stream'); - - eventSource.onmessage = (e) => { - try { - const data = JSON.parse(e.data); - if (data.type === 'weather_sat_progress') { - handleProgress(data); - } else if (data.type && data.type.startsWith('schedule_')) { - handleSchedulerSSE(data); - } - } catch (err) { - console.error('Failed to parse SSE:', err); - } - }; - - eventSource.onerror = () => { - setTimeout(() => { - if (isRunning || schedulerEnabled) startStream(); - }, 3000); - }; - } - - /** - * Stop SSE stream - */ - function stopStream() { - if (eventSource) { - eventSource.close(); - eventSource = null; - } - } - - /** - * Handle progress update - */ - function handleProgress(data) { - const captureStatus = document.getElementById('wxsatCaptureStatus'); - const captureMsg = document.getElementById('wxsatCaptureMsg'); - const captureElapsed = document.getElementById('wxsatCaptureElapsed'); - const progressBar = document.getElementById('wxsatProgressFill'); - - if (data.status === 'capturing' || data.status === 'decoding') { - updateStatusUI(data.status, `${data.status === 'decoding' ? 'Decoding' : 'Capturing'} ${data.satellite}...`); - - if (captureStatus) captureStatus.classList.add('active'); - if (captureMsg) captureMsg.textContent = data.message || ''; - if (captureElapsed) captureElapsed.textContent = formatElapsed(data.elapsed_seconds || 0); - if (progressBar) progressBar.style.width = (data.progress || 0) + '%'; - - // Console updates - showConsole(true); - if (data.message) addConsoleEntry(data.message, data.log_type || 'info'); - if (data.capture_phase) updatePhaseIndicator(data.capture_phase); - - } else if (data.status === 'complete') { - if (data.image) { - images.unshift(data.image); - updateImageCount(images.length); - renderGallery(); - showNotification('Weather Sat', `New image: ${data.image.product || data.image.satellite}`); - } - - if (!data.image) { - // Capture ended - isRunning = false; - if (!schedulerEnabled) stopStream(); - updateStatusUI('idle', 'Capture complete'); - if (captureStatus) captureStatus.classList.remove('active'); - - addConsoleEntry('Capture complete', 'signal'); - updatePhaseIndicator('complete'); - if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer); - consoleAutoHideTimer = setTimeout(() => showConsole(false), 30000); - } - - } else if (data.status === 'error') { - isRunning = false; - if (!schedulerEnabled) stopStream(); - updateStatusUI('idle', 'Error'); - showNotification('Weather Sat', data.message || 'Capture error'); - if (captureStatus) captureStatus.classList.remove('active'); - - if (data.message) addConsoleEntry(data.message, 'error'); - updatePhaseIndicator('error'); - if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer); - consoleAutoHideTimer = setTimeout(() => showConsole(false), 15000); - } - } - - /** - * Handle scheduler SSE events - */ - function handleSchedulerSSE(data) { - if (data.type === 'schedule_capture_start') { - isRunning = true; - const p = data.pass || {}; - currentSatellite = p.satellite; - updateStatusUI('capturing', `Auto: ${p.name || p.satellite} ${p.frequency} MHz`); - showNotification('Weather Sat', `Auto-capture started: ${p.name || p.satellite}`); - } else if (data.type === 'schedule_capture_complete') { - const p = data.pass || {}; - showNotification('Weather Sat', `Auto-capture complete: ${p.name || ''}`); - // Reset UI — the decoder's stop() doesn't emit a progress complete event - // when called internally by the scheduler, so we handle it here. - isRunning = false; - updateStatusUI('idle', 'Auto-capture complete'); - const captureStatus = document.getElementById('wxsatCaptureStatus'); - if (captureStatus) captureStatus.classList.remove('active'); - updatePhaseIndicator('complete'); - loadImages(); - loadPasses(); - } else if (data.type === 'schedule_capture_skipped') { - const reason = data.reason || 'unknown'; - const p = data.pass || {}; - showNotification('Weather Sat', `Pass skipped (${reason}): ${p.name || p.satellite}`); - } - } - - /** - * Format elapsed seconds - */ - function formatElapsed(seconds) { - const m = Math.floor(seconds / 60); - const s = seconds % 60; - return `${m}:${s.toString().padStart(2, '0')}`; - } - - /** - * Parse pass timestamps, accepting legacy malformed UTC strings (+00:00Z). - */ - function parsePassDate(value) { - if (!value || typeof value !== 'string') return null; - - let parsed = new Date(value); - if (!Number.isNaN(parsed.getTime())) { - return parsed; - } - - // Backward-compatible cleanup for accidentally double-suffixed UTC timestamps. - parsed = new Date(value.replace(/\+00:00Z$/, 'Z')); - if (!Number.isNaN(parsed.getTime())) { - return parsed; - } - - return null; - } - - /** - * Load pass predictions (with trajectory + ground track) - */ + (err) => { + btn.innerHTML = originalText; + btn.disabled = false; + showNotification('Weather Sat', 'Failed to get location'); + }, + { enableHighAccuracy: true, timeout: 10000 } + ); + } + + /** + * Check decoder status + */ + async function checkStatus() { + try { + const response = await fetch('/weather-sat/status'); + const data = await response.json(); + + if (!data.available) { + updateStatusUI('unavailable', 'SatDump not installed'); + return; + } + + if (data.running) { + isRunning = true; + currentSatellite = data.satellite; + updateStatusUI('capturing', `Capturing ${data.satellite}...`); + startStream(); + } else { + updateStatusUI('idle', 'Idle'); + } + } catch (err) { + console.error('Failed to check weather sat status:', err); + } + } + + /** + * Start capture + */ + async function start() { + const satSelect = document.getElementById('weatherSatSelect'); + const gainInput = document.getElementById('weatherSatGain'); + const biasTInput = document.getElementById('weatherSatBiasT'); + const deviceSelect = document.getElementById('deviceSelect'); + + const satellite = satSelect?.value || 'METEOR-M2-3'; + const gain = parseFloat(gainInput?.value || '40'); + const biasT = biasTInput?.checked || false; + const device = parseInt(deviceSelect?.value || '0', 10); + + clearConsole(); + showConsole(true); + updatePhaseIndicator('tuning'); + addConsoleEntry('Starting capture...', 'info'); + updateStatusUI('connecting', 'Starting...'); + + try { + const response = await fetch('/weather-sat/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + satellite, + device, + gain, + bias_t: biasT, + }) + }); + + const data = await response.json(); + + if (data.status === 'started' || data.status === 'already_running') { + isRunning = true; + currentSatellite = data.satellite || satellite; + updateStatusUI('capturing', `${data.satellite} ${data.frequency} MHz`); + updateFreqDisplay(data.frequency, data.mode); + startStream(); + showNotification('Weather Sat', `Capturing ${data.satellite} on ${data.frequency} MHz`); + } else { + updateStatusUI('idle', 'Start failed'); + showNotification('Weather Sat', data.message || 'Failed to start'); + } + } catch (err) { + console.error('Failed to start weather sat:', err); + updateStatusUI('idle', 'Error'); + showNotification('Weather Sat', 'Connection error'); + } + } + + /** + * Start capture for a specific pass + */ + function startPass(satellite) { + const satSelect = document.getElementById('weatherSatSelect'); + if (satSelect) { + satSelect.value = satellite; + } + start(); + } + + /** + * Stop capture + */ + async function stop() { + // Optimistically update UI immediately so stop feels responsive, + // even if the server takes time to terminate the process. + isRunning = false; + stopStream(); + updateStatusUI('idle', 'Stopping...'); + try { + await fetch('/weather-sat/stop', { method: 'POST' }); + updateStatusUI('idle', 'Stopped'); + showNotification('Weather Sat', 'Capture stopped'); + } catch (err) { + console.error('Failed to stop weather sat:', err); + } + } + + /** + * 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 || 'METEOR-M2-3'; + 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 + */ + function updateStatusUI(status, text) { + const dot = document.getElementById('wxsatStripDot'); + const statusText = document.getElementById('wxsatStripStatus'); + const startBtn = document.getElementById('wxsatStartBtn'); + const stopBtn = document.getElementById('wxsatStopBtn'); + + if (dot) { + dot.className = 'wxsat-strip-dot'; + if (status === 'capturing') dot.classList.add('capturing'); + else if (status === 'decoding') dot.classList.add('decoding'); + } + + if (statusText) statusText.textContent = text || status; + + if (startBtn && stopBtn) { + if (status === 'capturing' || status === 'decoding') { + startBtn.style.display = 'none'; + stopBtn.style.display = 'inline-block'; + } else { + startBtn.style.display = 'inline-block'; + stopBtn.style.display = 'none'; + } + } + } + + /** + * Update frequency display in strip + */ + function updateFreqDisplay(freq, mode) { + const freqEl = document.getElementById('wxsatStripFreq'); + const modeEl = document.getElementById('wxsatStripMode'); + if (freqEl) freqEl.textContent = freq || '--'; + if (modeEl) modeEl.textContent = mode || '--'; + } + + /** + * Start SSE stream + */ + function startStream() { + if (eventSource) eventSource.close(); + + eventSource = new EventSource('/weather-sat/stream'); + + eventSource.onmessage = (e) => { + try { + const data = JSON.parse(e.data); + if (data.type === 'weather_sat_progress') { + handleProgress(data); + } else if (data.type && data.type.startsWith('schedule_')) { + handleSchedulerSSE(data); + } + } catch (err) { + console.error('Failed to parse SSE:', err); + } + }; + + eventSource.onerror = () => { + setTimeout(() => { + if (isRunning || schedulerEnabled) startStream(); + }, 3000); + }; + } + + /** + * Stop SSE stream + */ + function stopStream() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + } + + /** + * Handle progress update + */ + function handleProgress(data) { + const captureStatus = document.getElementById('wxsatCaptureStatus'); + const captureMsg = document.getElementById('wxsatCaptureMsg'); + const captureElapsed = document.getElementById('wxsatCaptureElapsed'); + const progressBar = document.getElementById('wxsatProgressFill'); + + if (data.status === 'capturing' || data.status === 'decoding') { + updateStatusUI(data.status, `${data.status === 'decoding' ? 'Decoding' : 'Capturing'} ${data.satellite}...`); + + if (captureStatus) captureStatus.classList.add('active'); + if (captureMsg) captureMsg.textContent = data.message || ''; + if (captureElapsed) captureElapsed.textContent = formatElapsed(data.elapsed_seconds || 0); + if (progressBar) progressBar.style.width = (data.progress || 0) + '%'; + + // Console updates + showConsole(true); + if (data.message) addConsoleEntry(data.message, data.log_type || 'info'); + if (data.capture_phase) updatePhaseIndicator(data.capture_phase); + + } else if (data.status === 'complete') { + if (data.image) { + images.unshift(data.image); + updateImageCount(images.length); + renderGallery(); + showNotification('Weather Sat', `New image: ${data.image.product || data.image.satellite}`); + } + + if (!data.image) { + // Capture ended + isRunning = false; + if (!schedulerEnabled) stopStream(); + updateStatusUI('idle', 'Capture complete'); + if (captureStatus) captureStatus.classList.remove('active'); + + addConsoleEntry('Capture complete', 'signal'); + updatePhaseIndicator('complete'); + if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer); + consoleAutoHideTimer = setTimeout(() => showConsole(false), 30000); + } + + } else if (data.status === 'error') { + isRunning = false; + if (!schedulerEnabled) stopStream(); + updateStatusUI('idle', 'Error'); + showNotification('Weather Sat', data.message || 'Capture error'); + if (captureStatus) captureStatus.classList.remove('active'); + + if (data.message) addConsoleEntry(data.message, 'error'); + updatePhaseIndicator('error'); + if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer); + consoleAutoHideTimer = setTimeout(() => showConsole(false), 15000); + } + } + + /** + * Handle scheduler SSE events + */ + function handleSchedulerSSE(data) { + if (data.type === 'schedule_capture_start') { + isRunning = true; + const p = data.pass || {}; + currentSatellite = p.satellite; + updateStatusUI('capturing', `Auto: ${p.name || p.satellite} ${p.frequency} MHz`); + showNotification('Weather Sat', `Auto-capture started: ${p.name || p.satellite}`); + } else if (data.type === 'schedule_capture_complete') { + const p = data.pass || {}; + showNotification('Weather Sat', `Auto-capture complete: ${p.name || ''}`); + // Reset UI — the decoder's stop() doesn't emit a progress complete event + // when called internally by the scheduler, so we handle it here. + isRunning = false; + updateStatusUI('idle', 'Auto-capture complete'); + const captureStatus = document.getElementById('wxsatCaptureStatus'); + if (captureStatus) captureStatus.classList.remove('active'); + updatePhaseIndicator('complete'); + loadImages(); + loadPasses(); + } else if (data.type === 'schedule_capture_skipped') { + const reason = data.reason || 'unknown'; + const p = data.pass || {}; + showNotification('Weather Sat', `Pass skipped (${reason}): ${p.name || p.satellite}`); + } + } + + /** + * Format elapsed seconds + */ + function formatElapsed(seconds) { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${s.toString().padStart(2, '0')}`; + } + + /** + * Parse pass timestamps, accepting legacy malformed UTC strings (+00:00Z). + */ + function parsePassDate(value) { + if (!value || typeof value !== 'string') return null; + + let parsed = new Date(value); + if (!Number.isNaN(parsed.getTime())) { + return parsed; + } + + // Backward-compatible cleanup for accidentally double-suffixed UTC timestamps. + parsed = new Date(value.replace(/\+00:00Z$/, 'Z')); + if (!Number.isNaN(parsed.getTime())) { + return parsed; + } + + return null; + } + + /** + * Load pass predictions (with trajectory + ground track) + */ async function loadPasses() { - let storedLat, storedLon; - - // Use ObserverLocation if available, otherwise fall back to localStorage - if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) { - const shared = ObserverLocation.getShared(); - storedLat = shared?.lat?.toString(); - storedLon = shared?.lon?.toString(); - } else { - storedLat = localStorage.getItem('observerLat'); - storedLon = localStorage.getItem('observerLon'); - } - + let storedLat, storedLon; + + // Use ObserverLocation if available, otherwise fall back to localStorage + if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) { + const shared = ObserverLocation.getShared(); + storedLat = shared?.lat?.toString(); + storedLon = shared?.lon?.toString(); + } else { + storedLat = localStorage.getItem('observerLat'); + storedLon = localStorage.getItem('observerLon'); + } + if (!storedLat || !storedLon) { passes = []; selectedPassIndex = -1; @@ -544,12 +547,12 @@ const WeatherSat = (function() { updateGroundTrack(null); return; } - - try { - const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=24&min_elevation=15&trajectory=true&ground_track=true`; - const response = await fetch(url); - const data = await response.json(); - + + try { + const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=24&min_elevation=15&trajectory=true&ground_track=true`; + const response = await fetch(url); + const data = await response.json(); + if (data.status === 'ok') { passes = data.passes || []; selectedPassIndex = -1; @@ -567,224 +570,224 @@ const WeatherSat = (function() { } catch (err) { console.error('Failed to load passes:', err); } - } - - /** - * Select a pass to display in polar plot and map - */ - function selectPass(index) { - if (index < 0 || index >= passes.length) return; - selectedPassIndex = index; - const pass = passes[index]; - - // Highlight active card - document.querySelectorAll('.wxsat-pass-card').forEach((card, i) => { - card.classList.toggle('selected', i === index); - }); - - // Update polar plot - drawPolarPlot(pass); - - // Update ground track - updateGroundTrack(pass); - - // Update polar panel subtitle - const polarSat = document.getElementById('wxsatPolarSat'); - if (polarSat) polarSat.textContent = `${pass.name} ${pass.maxEl}\u00b0`; - } - - /** - * Render pass predictions list - */ - function renderPasses(passList) { - const container = document.getElementById('wxsatPassesList'); - const countEl = document.getElementById('wxsatPassesCount'); - - if (countEl) countEl.textContent = passList.length; - - if (!container) return; - - if (passList.length === 0) { - const hasLocation = localStorage.getItem('observerLat') !== null; - container.innerHTML = ` - - `; - return; - } - - container.innerHTML = passList.map((pass, idx) => { - const modeClass = pass.mode === 'APT' ? 'apt' : 'lrpt'; - const timeStr = pass.startTime || '--'; - const now = new Date(); - const passStart = parsePassDate(pass.startTimeISO); - const diffMs = passStart ? passStart - now : NaN; - const diffMins = Number.isFinite(diffMs) ? Math.floor(diffMs / 60000) : NaN; - const isSelected = idx === selectedPassIndex; - - let countdown = '--'; - if (!Number.isFinite(diffMs)) { - countdown = '--'; - } else if (diffMs < 0) { - countdown = 'NOW'; - } else if (diffMins < 60) { - countdown = `in ${diffMins}m`; - } else { - const hrs = Math.floor(diffMins / 60); - const mins = diffMins % 60; - countdown = `in ${hrs}h${mins}m`; - } - - return ` -
-
- ${escapeHtml(pass.name)} - ${escapeHtml(pass.mode)} -
-
- Time - ${escapeHtml(timeStr)} - Max El - ${pass.maxEl}° - Duration - ${pass.duration} min - Freq - ${pass.frequency} MHz -
-
- ${pass.quality} - ${countdown} -
-
- -
-
- `; - }).join(''); - } - - // ======================== - // Polar Plot - // ======================== - - /** - * Draw polar plot for a pass trajectory - */ - function drawPolarPlot(pass) { - const canvas = document.getElementById('wxsatPolarCanvas'); - if (!canvas) return; - - const ctx = canvas.getContext('2d'); - const w = canvas.width; - const h = canvas.height; - const cx = w / 2; - const cy = h / 2; - const r = Math.min(cx, cy) - 20; - - ctx.clearRect(0, 0, w, h); - - // Background - ctx.fillStyle = '#0d1117'; - ctx.fillRect(0, 0, w, h); - - // Grid circles (30, 60, 90 deg elevation) - ctx.strokeStyle = '#2a3040'; - ctx.lineWidth = 0.5; - [90, 60, 30].forEach((el, i) => { - const gr = r * (1 - el / 90); - ctx.beginPath(); - ctx.arc(cx, cy, gr, 0, Math.PI * 2); - ctx.stroke(); - // Label - ctx.fillStyle = '#555'; - ctx.font = '9px Roboto Condensed, monospace'; - ctx.textAlign = 'left'; - ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2); - }); - - // Horizon circle - ctx.strokeStyle = '#3a4050'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.arc(cx, cy, r, 0, Math.PI * 2); - ctx.stroke(); - - // Cardinal directions - ctx.fillStyle = '#666'; - ctx.font = '10px Roboto Condensed, monospace'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText('N', cx, cy - r - 10); - ctx.fillText('S', cx, cy + r + 10); - ctx.fillText('E', cx + r + 10, cy); - ctx.fillText('W', cx - r - 10, cy); - - // Cross hairs - ctx.strokeStyle = '#2a3040'; - ctx.lineWidth = 0.5; - ctx.beginPath(); - ctx.moveTo(cx, cy - r); - ctx.lineTo(cx, cy + r); - ctx.moveTo(cx - r, cy); - ctx.lineTo(cx + r, cy); - ctx.stroke(); - - // Trajectory - const trajectory = pass.trajectory; - if (!trajectory || trajectory.length === 0) return; - - const color = pass.mode === 'LRPT' ? '#00ff88' : '#00d4ff'; - - ctx.beginPath(); - ctx.strokeStyle = color; - ctx.lineWidth = 2; - - trajectory.forEach((pt, i) => { - const elRad = (90 - pt.el) / 90; - const azRad = (pt.az - 90) * Math.PI / 180; // offset: N is up - const px = cx + r * elRad * Math.cos(azRad); - const py = cy + r * elRad * Math.sin(azRad); - - if (i === 0) ctx.moveTo(px, py); - else ctx.lineTo(px, py); - }); - ctx.stroke(); - - // Start point (green dot) - const start = trajectory[0]; - const startR = (90 - start.el) / 90; - const startAz = (start.az - 90) * Math.PI / 180; - ctx.fillStyle = '#00ff88'; - ctx.beginPath(); - ctx.arc(cx + r * startR * Math.cos(startAz), cy + r * startR * Math.sin(startAz), 4, 0, Math.PI * 2); - ctx.fill(); - - // End point (red dot) - const end = trajectory[trajectory.length - 1]; - const endR = (90 - end.el) / 90; - const endAz = (end.az - 90) * Math.PI / 180; - ctx.fillStyle = '#ff4444'; - ctx.beginPath(); - ctx.arc(cx + r * endR * Math.cos(endAz), cy + r * endR * Math.sin(endAz), 4, 0, Math.PI * 2); - ctx.fill(); - - // Max elevation marker - let maxEl = 0; - let maxPt = trajectory[0]; - trajectory.forEach(pt => { if (pt.el > maxEl) { maxEl = pt.el; maxPt = pt; } }); - const maxR = (90 - maxPt.el) / 90; - const maxAz = (maxPt.az - 90) * Math.PI / 180; - ctx.fillStyle = color; - ctx.beginPath(); - ctx.arc(cx + r * maxR * Math.cos(maxAz), cy + r * maxR * Math.sin(maxAz), 3, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = color; - ctx.font = '9px Roboto Condensed, monospace'; - ctx.textAlign = 'center'; - ctx.fillText(Math.round(maxEl) + '\u00b0', cx + r * maxR * Math.cos(maxAz), cy + r * maxR * Math.sin(maxAz) - 8); - } - + } + + /** + * Select a pass to display in polar plot and map + */ + function selectPass(index) { + if (index < 0 || index >= passes.length) return; + selectedPassIndex = index; + const pass = passes[index]; + + // Highlight active card + document.querySelectorAll('.wxsat-pass-card').forEach((card, i) => { + card.classList.toggle('selected', i === index); + }); + + // Update polar plot + drawPolarPlot(pass); + + // Update ground track + updateGroundTrack(pass); + + // Update polar panel subtitle + const polarSat = document.getElementById('wxsatPolarSat'); + if (polarSat) polarSat.textContent = `${pass.name} ${pass.maxEl}\u00b0`; + } + + /** + * Render pass predictions list + */ + function renderPasses(passList) { + const container = document.getElementById('wxsatPassesList'); + const countEl = document.getElementById('wxsatPassesCount'); + + if (countEl) countEl.textContent = passList.length; + + if (!container) return; + + if (passList.length === 0) { + const hasLocation = localStorage.getItem('observerLat') !== null; + container.innerHTML = ` + + `; + return; + } + + container.innerHTML = passList.map((pass, idx) => { + const modeClass = pass.mode === 'APT' ? 'apt' : 'lrpt'; + const timeStr = pass.startTime || '--'; + const now = new Date(); + const passStart = parsePassDate(pass.startTimeISO); + const diffMs = passStart ? passStart - now : NaN; + const diffMins = Number.isFinite(diffMs) ? Math.floor(diffMs / 60000) : NaN; + const isSelected = idx === selectedPassIndex; + + let countdown = '--'; + if (!Number.isFinite(diffMs)) { + countdown = '--'; + } else if (diffMs < 0) { + countdown = 'NOW'; + } else if (diffMins < 60) { + countdown = `in ${diffMins}m`; + } else { + const hrs = Math.floor(diffMins / 60); + const mins = diffMins % 60; + countdown = `in ${hrs}h${mins}m`; + } + + return ` +
+
+ ${escapeHtml(pass.name)} + ${escapeHtml(pass.mode)} +
+
+ Time + ${escapeHtml(timeStr)} + Max El + ${pass.maxEl}° + Duration + ${pass.duration} min + Freq + ${pass.frequency} MHz +
+
+ ${pass.quality} + ${countdown} +
+
+ +
+
+ `; + }).join(''); + } + + // ======================== + // Polar Plot + // ======================== + + /** + * Draw polar plot for a pass trajectory + */ + function drawPolarPlot(pass) { + const canvas = document.getElementById('wxsatPolarCanvas'); + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + const w = canvas.width; + const h = canvas.height; + const cx = w / 2; + const cy = h / 2; + const r = Math.min(cx, cy) - 20; + + ctx.clearRect(0, 0, w, h); + + // Background + ctx.fillStyle = '#0d1117'; + ctx.fillRect(0, 0, w, h); + + // Grid circles (30, 60, 90 deg elevation) + ctx.strokeStyle = '#2a3040'; + ctx.lineWidth = 0.5; + [90, 60, 30].forEach((el, i) => { + const gr = r * (1 - el / 90); + ctx.beginPath(); + ctx.arc(cx, cy, gr, 0, Math.PI * 2); + ctx.stroke(); + // Label + ctx.fillStyle = '#555'; + ctx.font = '9px Roboto Condensed, monospace'; + ctx.textAlign = 'left'; + ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2); + }); + + // Horizon circle + ctx.strokeStyle = '#3a4050'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.stroke(); + + // Cardinal directions + ctx.fillStyle = '#666'; + ctx.font = '10px Roboto Condensed, monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('N', cx, cy - r - 10); + ctx.fillText('S', cx, cy + r + 10); + ctx.fillText('E', cx + r + 10, cy); + ctx.fillText('W', cx - r - 10, cy); + + // Cross hairs + ctx.strokeStyle = '#2a3040'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(cx, cy - r); + ctx.lineTo(cx, cy + r); + ctx.moveTo(cx - r, cy); + ctx.lineTo(cx + r, cy); + ctx.stroke(); + + // Trajectory + const trajectory = pass.trajectory; + if (!trajectory || trajectory.length === 0) return; + + const color = pass.mode === 'LRPT' ? '#00ff88' : '#00d4ff'; + + ctx.beginPath(); + ctx.strokeStyle = color; + ctx.lineWidth = 2; + + trajectory.forEach((pt, i) => { + const elRad = (90 - pt.el) / 90; + const azRad = (pt.az - 90) * Math.PI / 180; // offset: N is up + const px = cx + r * elRad * Math.cos(azRad); + const py = cy + r * elRad * Math.sin(azRad); + + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + }); + ctx.stroke(); + + // Start point (green dot) + const start = trajectory[0]; + const startR = (90 - start.el) / 90; + const startAz = (start.az - 90) * Math.PI / 180; + ctx.fillStyle = '#00ff88'; + ctx.beginPath(); + ctx.arc(cx + r * startR * Math.cos(startAz), cy + r * startR * Math.sin(startAz), 4, 0, Math.PI * 2); + ctx.fill(); + + // End point (red dot) + const end = trajectory[trajectory.length - 1]; + const endR = (90 - end.el) / 90; + const endAz = (end.az - 90) * Math.PI / 180; + ctx.fillStyle = '#ff4444'; + ctx.beginPath(); + ctx.arc(cx + r * endR * Math.cos(endAz), cy + r * endR * Math.sin(endAz), 4, 0, Math.PI * 2); + ctx.fill(); + + // Max elevation marker + let maxEl = 0; + let maxPt = trajectory[0]; + trajectory.forEach(pt => { if (pt.el > maxEl) { maxEl = pt.el; maxPt = pt; } }); + const maxR = (90 - maxPt.el) / 90; + const maxAz = (maxPt.az - 90) * Math.PI / 180; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(cx + r * maxR * Math.cos(maxAz), cy + r * maxR * Math.sin(maxAz), 3, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = color; + ctx.font = '9px Roboto Condensed, monospace'; + ctx.textAlign = 'center'; + ctx.fillText(Math.round(maxEl) + '\u00b0', cx + r * maxR * Math.cos(maxAz), cy + r * maxR * Math.sin(maxAz) - 8); + } + // ======================== // Ground Track Map // ======================== @@ -1121,230 +1124,230 @@ const WeatherSat = (function() { satCrosshairMarker.setTooltipContent(infoText); } } - - // ======================== - // Countdown - // ======================== - - /** - * Start the countdown interval timer - */ - function startCountdownTimer() { - if (countdownInterval) clearInterval(countdownInterval); - countdownInterval = setInterval(updateCountdownFromPasses, 1000); - } - - /** - * Update countdown display from passes array - */ - function updateCountdownFromPasses() { - const now = new Date(); - let nextPass = null; - let isActive = false; - - for (const pass of passes) { - const start = parsePassDate(pass.startTimeISO); - const end = parsePassDate(pass.endTimeISO); - if (!start || !end) { - continue; - } - if (end > now) { - nextPass = pass; - isActive = start <= now; - break; - } - } - - const daysEl = document.getElementById('wxsatCdDays'); - const hoursEl = document.getElementById('wxsatCdHours'); - const minsEl = document.getElementById('wxsatCdMins'); - const secsEl = document.getElementById('wxsatCdSecs'); - const satEl = document.getElementById('wxsatCountdownSat'); - const detailEl = document.getElementById('wxsatCountdownDetail'); - const boxes = document.getElementById('wxsatCountdownBoxes'); - - if (!nextPass) { - if (daysEl) daysEl.textContent = '--'; - if (hoursEl) hoursEl.textContent = '--'; - if (minsEl) minsEl.textContent = '--'; - if (secsEl) secsEl.textContent = '--'; - if (satEl) satEl.textContent = '--'; - if (detailEl) detailEl.textContent = 'No passes predicted'; - if (boxes) boxes.querySelectorAll('.wxsat-countdown-box').forEach(b => { - b.classList.remove('imminent', 'active'); - }); - return; - } - - const target = parsePassDate(nextPass.startTimeISO); - if (!target) { - if (daysEl) daysEl.textContent = '--'; - if (hoursEl) hoursEl.textContent = '--'; - if (minsEl) minsEl.textContent = '--'; - if (secsEl) secsEl.textContent = '--'; - if (satEl) satEl.textContent = '--'; - if (detailEl) detailEl.textContent = 'Invalid pass time'; - if (boxes) boxes.querySelectorAll('.wxsat-countdown-box').forEach(b => { - b.classList.remove('imminent', 'active'); - }); - return; - } - let diffMs = target - now; - - if (isActive) { - diffMs = 0; - } - - const totalSec = Math.max(0, Math.floor(diffMs / 1000)); - const d = Math.floor(totalSec / 86400); - const h = Math.floor((totalSec % 86400) / 3600); - const m = Math.floor((totalSec % 3600) / 60); - const s = totalSec % 60; - - if (daysEl) daysEl.textContent = d.toString().padStart(2, '0'); - if (hoursEl) hoursEl.textContent = h.toString().padStart(2, '0'); - if (minsEl) minsEl.textContent = m.toString().padStart(2, '0'); - if (secsEl) secsEl.textContent = s.toString().padStart(2, '0'); - if (satEl) satEl.textContent = `${nextPass.name} ${nextPass.frequency} MHz`; - if (detailEl) { - if (isActive) { - detailEl.textContent = `ACTIVE - ${nextPass.maxEl}\u00b0 max el`; - } else { - detailEl.textContent = `${nextPass.maxEl}\u00b0 max el / ${nextPass.duration} min`; - } - } - - // Countdown box states - if (boxes) { - const isImminent = totalSec < 600 && totalSec > 0; // < 10 min - boxes.querySelectorAll('.wxsat-countdown-box').forEach(b => { - b.classList.toggle('imminent', isImminent); - b.classList.toggle('active', isActive); - }); - } - + + // ======================== + // Countdown + // ======================== + + /** + * Start the countdown interval timer + */ + function startCountdownTimer() { + if (countdownInterval) clearInterval(countdownInterval); + countdownInterval = setInterval(updateCountdownFromPasses, 1000); + } + + /** + * Update countdown display from passes array + */ + function updateCountdownFromPasses() { + const now = new Date(); + let nextPass = null; + let isActive = false; + + for (const pass of passes) { + const start = parsePassDate(pass.startTimeISO); + const end = parsePassDate(pass.endTimeISO); + if (!start || !end) { + continue; + } + if (end > now) { + nextPass = pass; + isActive = start <= now; + break; + } + } + + const daysEl = document.getElementById('wxsatCdDays'); + const hoursEl = document.getElementById('wxsatCdHours'); + const minsEl = document.getElementById('wxsatCdMins'); + const secsEl = document.getElementById('wxsatCdSecs'); + const satEl = document.getElementById('wxsatCountdownSat'); + const detailEl = document.getElementById('wxsatCountdownDetail'); + const boxes = document.getElementById('wxsatCountdownBoxes'); + + if (!nextPass) { + if (daysEl) daysEl.textContent = '--'; + if (hoursEl) hoursEl.textContent = '--'; + if (minsEl) minsEl.textContent = '--'; + if (secsEl) secsEl.textContent = '--'; + if (satEl) satEl.textContent = '--'; + if (detailEl) detailEl.textContent = 'No passes predicted'; + if (boxes) boxes.querySelectorAll('.wxsat-countdown-box').forEach(b => { + b.classList.remove('imminent', 'active'); + }); + return; + } + + const target = parsePassDate(nextPass.startTimeISO); + if (!target) { + if (daysEl) daysEl.textContent = '--'; + if (hoursEl) hoursEl.textContent = '--'; + if (minsEl) minsEl.textContent = '--'; + if (secsEl) secsEl.textContent = '--'; + if (satEl) satEl.textContent = '--'; + if (detailEl) detailEl.textContent = 'Invalid pass time'; + if (boxes) boxes.querySelectorAll('.wxsat-countdown-box').forEach(b => { + b.classList.remove('imminent', 'active'); + }); + return; + } + let diffMs = target - now; + + if (isActive) { + diffMs = 0; + } + + const totalSec = Math.max(0, Math.floor(diffMs / 1000)); + const d = Math.floor(totalSec / 86400); + const h = Math.floor((totalSec % 86400) / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + + if (daysEl) daysEl.textContent = d.toString().padStart(2, '0'); + if (hoursEl) hoursEl.textContent = h.toString().padStart(2, '0'); + if (minsEl) minsEl.textContent = m.toString().padStart(2, '0'); + if (secsEl) secsEl.textContent = s.toString().padStart(2, '0'); + if (satEl) satEl.textContent = `${nextPass.name} ${nextPass.frequency} MHz`; + if (detailEl) { + if (isActive) { + detailEl.textContent = `ACTIVE - ${nextPass.maxEl}\u00b0 max el`; + } else { + detailEl.textContent = `${nextPass.maxEl}\u00b0 max el / ${nextPass.duration} min`; + } + } + + // Countdown box states + if (boxes) { + const isImminent = totalSec < 600 && totalSec > 0; // < 10 min + boxes.querySelectorAll('.wxsat-countdown-box').forEach(b => { + b.classList.toggle('imminent', isImminent); + b.classList.toggle('active', isActive); + }); + } + // Keep timeline cursor in sync updateTimelineCursor(); // Keep selected satellite marker synchronized with time progression. updateSatelliteCrosshair(getSelectedPass()); } - - // ======================== - // Timeline - // ======================== - - /** - * Render 24h timeline with pass markers - */ - function renderTimeline(passList) { - const track = document.getElementById('wxsatTimelineTrack'); - const cursor = document.getElementById('wxsatTimelineCursor'); - if (!track) return; - - // Clear existing pass markers - track.querySelectorAll('.wxsat-timeline-pass').forEach(el => el.remove()); - - const now = new Date(); - const dayStart = new Date(now); - dayStart.setHours(0, 0, 0, 0); - const dayMs = 24 * 60 * 60 * 1000; - - passList.forEach((pass, idx) => { - const start = parsePassDate(pass.startTimeISO); - const end = parsePassDate(pass.endTimeISO); - if (!start || !end) return; - - const startPct = Math.max(0, Math.min(100, ((start - dayStart) / dayMs) * 100)); - const endPct = Math.max(0, Math.min(100, ((end - dayStart) / dayMs) * 100)); - const widthPct = Math.max(0.5, endPct - startPct); - - const marker = document.createElement('div'); - marker.className = `wxsat-timeline-pass ${pass.mode === 'LRPT' ? 'lrpt' : 'apt'}`; - marker.style.left = startPct + '%'; - marker.style.width = widthPct + '%'; - marker.title = `${pass.name} ${pass.startTime} (${pass.maxEl}\u00b0)`; - marker.onclick = () => selectPass(idx); - track.appendChild(marker); - }); - - // Update cursor position - updateTimelineCursor(); - } - - /** - * Update timeline cursor to current time - */ - function updateTimelineCursor() { - const cursor = document.getElementById('wxsatTimelineCursor'); - if (!cursor) return; - - const now = new Date(); - const dayStart = new Date(now); - dayStart.setHours(0, 0, 0, 0); - const pct = ((now - dayStart) / (24 * 60 * 60 * 1000)) * 100; - cursor.style.left = pct + '%'; - } - - // ======================== - // Auto-Scheduler - // ======================== - - /** - * Toggle auto-scheduler - */ - async function toggleScheduler(source) { - const checked = source?.checked ?? false; - - const stripCheckbox = document.getElementById('wxsatAutoSchedule'); - const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule'); - - // Sync both checkboxes to the source of truth - if (stripCheckbox) stripCheckbox.checked = checked; - if (sidebarCheckbox) sidebarCheckbox.checked = checked; - - if (checked) { - await enableScheduler(); - } else { - await disableScheduler(); - } - } - - /** - * Enable auto-scheduler - */ + + // ======================== + // Timeline + // ======================== + + /** + * Render 24h timeline with pass markers + */ + function renderTimeline(passList) { + const track = document.getElementById('wxsatTimelineTrack'); + const cursor = document.getElementById('wxsatTimelineCursor'); + if (!track) return; + + // Clear existing pass markers + track.querySelectorAll('.wxsat-timeline-pass').forEach(el => el.remove()); + + const now = new Date(); + const dayStart = new Date(now); + dayStart.setHours(0, 0, 0, 0); + const dayMs = 24 * 60 * 60 * 1000; + + passList.forEach((pass, idx) => { + const start = parsePassDate(pass.startTimeISO); + const end = parsePassDate(pass.endTimeISO); + if (!start || !end) return; + + const startPct = Math.max(0, Math.min(100, ((start - dayStart) / dayMs) * 100)); + const endPct = Math.max(0, Math.min(100, ((end - dayStart) / dayMs) * 100)); + const widthPct = Math.max(0.5, endPct - startPct); + + const marker = document.createElement('div'); + marker.className = `wxsat-timeline-pass ${pass.mode === 'LRPT' ? 'lrpt' : 'apt'}`; + marker.style.left = startPct + '%'; + marker.style.width = widthPct + '%'; + marker.title = `${pass.name} ${pass.startTime} (${pass.maxEl}\u00b0)`; + marker.onclick = () => selectPass(idx); + track.appendChild(marker); + }); + + // Update cursor position + updateTimelineCursor(); + } + + /** + * Update timeline cursor to current time + */ + function updateTimelineCursor() { + const cursor = document.getElementById('wxsatTimelineCursor'); + if (!cursor) return; + + const now = new Date(); + const dayStart = new Date(now); + dayStart.setHours(0, 0, 0, 0); + const pct = ((now - dayStart) / (24 * 60 * 60 * 1000)) * 100; + cursor.style.left = pct + '%'; + } + + // ======================== + // Auto-Scheduler + // ======================== + + /** + * Toggle auto-scheduler + */ + async function toggleScheduler(source) { + const checked = source?.checked ?? false; + + const stripCheckbox = document.getElementById('wxsatAutoSchedule'); + const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule'); + + // Sync both checkboxes to the source of truth + if (stripCheckbox) stripCheckbox.checked = checked; + if (sidebarCheckbox) sidebarCheckbox.checked = checked; + + if (checked) { + await enableScheduler(); + } else { + await disableScheduler(); + } + } + + /** + * Enable auto-scheduler + */ async function enableScheduler() { - let lat, lon; - if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) { - const shared = ObserverLocation.getShared(); - lat = shared?.lat; - lon = shared?.lon; - } else { - lat = parseFloat(localStorage.getItem('observerLat')); - lon = parseFloat(localStorage.getItem('observerLon')); - } - - if (isNaN(lat) || isNaN(lon)) { - showNotification('Weather Sat', 'Set observer location first'); - const stripCheckbox = document.getElementById('wxsatAutoSchedule'); - const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule'); - if (stripCheckbox) stripCheckbox.checked = false; - if (sidebarCheckbox) sidebarCheckbox.checked = false; - return; - } - - const deviceSelect = document.getElementById('deviceSelect'); - const gainInput = document.getElementById('weatherSatGain'); - const biasTInput = document.getElementById('weatherSatBiasT'); - + let lat, lon; + if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) { + const shared = ObserverLocation.getShared(); + lat = shared?.lat; + lon = shared?.lon; + } else { + lat = parseFloat(localStorage.getItem('observerLat')); + lon = parseFloat(localStorage.getItem('observerLon')); + } + + if (isNaN(lat) || isNaN(lon)) { + showNotification('Weather Sat', 'Set observer location first'); + const stripCheckbox = document.getElementById('wxsatAutoSchedule'); + const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule'); + if (stripCheckbox) stripCheckbox.checked = false; + if (sidebarCheckbox) sidebarCheckbox.checked = false; + return; + } + + const deviceSelect = document.getElementById('deviceSelect'); + const gainInput = document.getElementById('weatherSatGain'); + const biasTInput = document.getElementById('weatherSatBiasT'); + try { const response = await fetch('/weather-sat/schedule/enable', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - latitude: lat, - longitude: lon, - device: parseInt(deviceSelect?.value || '0', 10), - gain: parseFloat(gainInput?.value || '40'), + latitude: lat, + longitude: lon, + device: parseInt(deviceSelect?.value || '0', 10), + gain: parseFloat(gainInput?.value || '40'), bias_t: biasTInput?.checked || false, }), }); @@ -1374,10 +1377,10 @@ const WeatherSat = (function() { showNotification('Weather Sat', 'Failed to enable auto-scheduler'); } } - - /** - * Disable auto-scheduler - */ + + /** + * Disable auto-scheduler + */ async function disableScheduler() { try { const response = await fetch('/weather-sat/schedule/disable', { method: 'POST' }); @@ -1390,13 +1393,13 @@ const WeatherSat = (function() { if (!isRunning) stopStream(); showNotification('Weather Sat', 'Auto-scheduler disabled'); } catch (err) { - console.error('Failed to disable scheduler:', err); - } - } - - /** - * Check current scheduler status - */ + console.error('Failed to disable scheduler:', err); + } + } + + /** + * Check current scheduler status + */ async function checkSchedulerStatus() { try { const response = await fetch('/weather-sat/schedule/status'); @@ -1406,249 +1409,249 @@ const WeatherSat = (function() { updateSchedulerUI(data); if (schedulerEnabled) startStream(); } catch (err) { - // Scheduler endpoint may not exist yet - } - } - - /** - * Update scheduler UI elements - */ - function updateSchedulerUI(data) { - const stripCheckbox = document.getElementById('wxsatAutoSchedule'); - const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule'); - const statusEl = document.getElementById('wxsatSchedulerStatus'); - - if (stripCheckbox) stripCheckbox.checked = data.enabled; - if (sidebarCheckbox) sidebarCheckbox.checked = data.enabled; - if (statusEl) { - if (data.enabled) { - statusEl.textContent = `Active: ${data.scheduled_count || 0} passes queued`; - statusEl.style.color = '#00ff88'; - } else { - statusEl.textContent = 'Disabled'; - statusEl.style.color = ''; - } - } - } - - // ======================== - // Images - // ======================== - - /** - * Load decoded images - */ - async function loadImages() { - try { - const response = await fetch('/weather-sat/images'); - const data = await response.json(); - - if (data.status === 'ok') { - images = data.images || []; - updateImageCount(images.length); - renderGallery(); - } - } catch (err) { - console.error('Failed to load weather sat images:', err); - } - } - - /** - * Update image count - */ - function updateImageCount(count) { - const countEl = document.getElementById('wxsatImageCount'); - const stripCount = document.getElementById('wxsatStripImageCount'); - if (countEl) countEl.textContent = count; - if (stripCount) stripCount.textContent = count; - } - - /** - * Render image gallery grouped by date - */ - function renderGallery() { - const gallery = document.getElementById('wxsatGallery'); - if (!gallery) return; - - if (images.length === 0) { - gallery.innerHTML = ` - - `; - return; - } - - // Sort by timestamp descending - const sorted = [...images].sort((a, b) => { - return new Date(b.timestamp || 0) - new Date(a.timestamp || 0); - }); - - // Group by date - const groups = {}; - sorted.forEach(img => { - const dateKey = img.timestamp - ? new Date(img.timestamp).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) - : 'Unknown Date'; - if (!groups[dateKey]) groups[dateKey] = []; - groups[dateKey].push(img); - }); - - let html = ''; - for (const [date, imgs] of Object.entries(groups)) { - html += `
${escapeHtml(date)}
`; - html += imgs.map(img => { - const fn = escapeHtml(img.filename || img.url.split('/').pop()); - return ` -
-
- ${escapeHtml(img.satellite)} ${escapeHtml(img.product)} -
-
${escapeHtml(img.satellite)}
-
${escapeHtml(img.product || img.mode)}
-
${formatTimestamp(img.timestamp)}
-
-
-
- -
-
`; - }).join(''); - } - - gallery.innerHTML = html; - } - - /** - * Show full-size image - */ - function showImage(url, satellite, product, filename) { - currentModalFilename = filename || null; - - let modal = document.getElementById('wxsatImageModal'); - if (!modal) { - modal = document.createElement('div'); - modal.id = 'wxsatImageModal'; - modal.className = 'wxsat-image-modal'; - modal.innerHTML = ` -
- -
- - Weather Satellite Image -
- `; - modal.addEventListener('click', (e) => { - if (e.target === modal) closeImage(); - }); - document.body.appendChild(modal); - } - - modal.querySelector('img').src = url; - const info = modal.querySelector('.wxsat-modal-info'); - if (info) { - info.textContent = `${satellite || ''} ${product ? '// ' + product : ''}`; - } - modal.classList.add('show'); - } - - /** - * Close image modal - */ - function closeImage() { - const modal = document.getElementById('wxsatImageModal'); - if (modal) modal.classList.remove('show'); - } - - /** - * Delete a single image - */ - async function deleteImage(filename) { - if (!filename) return; - if (!confirm(`Delete this image?`)) return; - - try { - const response = await fetch(`/weather-sat/images/${encodeURIComponent(filename)}`, { method: 'DELETE' }); - const data = await response.json(); - - if (data.status === 'deleted') { - images = images.filter(img => { - const imgFn = img.filename || img.url.split('/').pop(); - return imgFn !== filename; - }); - updateImageCount(images.length); - renderGallery(); - closeImage(); - } else { - showNotification('Weather Sat', data.message || 'Failed to delete image'); - } - } catch (err) { - console.error('Failed to delete image:', err); - showNotification('Weather Sat', 'Failed to delete image'); - } - } - - /** - * Delete all images - */ - async function deleteAllImages() { - if (images.length === 0) return; - if (!confirm(`Delete all ${images.length} decoded images?`)) return; - - try { - const response = await fetch('/weather-sat/images', { method: 'DELETE' }); - const data = await response.json(); - - if (data.status === 'ok') { - images = []; - updateImageCount(0); - renderGallery(); - showNotification('Weather Sat', `Deleted ${data.deleted} images`); - } else { - showNotification('Weather Sat', 'Failed to delete images'); - } - } catch (err) { - console.error('Failed to delete all images:', err); - showNotification('Weather Sat', 'Failed to delete images'); - } - } - - /** - * Format timestamp - */ - function formatTimestamp(isoString) { - if (!isoString) return '--'; - try { - return new Date(isoString).toLocaleString(); - } catch { - return isoString; - } - } - - /** - * Escape HTML - */ - function escapeHtml(text) { - if (!text) return ''; - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - + // Scheduler endpoint may not exist yet + } + } + + /** + * Update scheduler UI elements + */ + function updateSchedulerUI(data) { + const stripCheckbox = document.getElementById('wxsatAutoSchedule'); + const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule'); + const statusEl = document.getElementById('wxsatSchedulerStatus'); + + if (stripCheckbox) stripCheckbox.checked = data.enabled; + if (sidebarCheckbox) sidebarCheckbox.checked = data.enabled; + if (statusEl) { + if (data.enabled) { + statusEl.textContent = `Active: ${data.scheduled_count || 0} passes queued`; + statusEl.style.color = '#00ff88'; + } else { + statusEl.textContent = 'Disabled'; + statusEl.style.color = ''; + } + } + } + + // ======================== + // Images + // ======================== + + /** + * Load decoded images + */ + async function loadImages() { + try { + const response = await fetch('/weather-sat/images'); + const data = await response.json(); + + if (data.status === 'ok') { + images = data.images || []; + updateImageCount(images.length); + renderGallery(); + } + } catch (err) { + console.error('Failed to load weather sat images:', err); + } + } + + /** + * Update image count + */ + function updateImageCount(count) { + const countEl = document.getElementById('wxsatImageCount'); + const stripCount = document.getElementById('wxsatStripImageCount'); + if (countEl) countEl.textContent = count; + if (stripCount) stripCount.textContent = count; + } + + /** + * Render image gallery grouped by date + */ + function renderGallery() { + const gallery = document.getElementById('wxsatGallery'); + if (!gallery) return; + + if (images.length === 0) { + gallery.innerHTML = ` + + `; + return; + } + + // Sort by timestamp descending + const sorted = [...images].sort((a, b) => { + return new Date(b.timestamp || 0) - new Date(a.timestamp || 0); + }); + + // Group by date + const groups = {}; + sorted.forEach(img => { + const dateKey = img.timestamp + ? new Date(img.timestamp).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) + : 'Unknown Date'; + if (!groups[dateKey]) groups[dateKey] = []; + groups[dateKey].push(img); + }); + + let html = ''; + for (const [date, imgs] of Object.entries(groups)) { + html += `
${escapeHtml(date)}
`; + html += imgs.map(img => { + const fn = escapeHtml(img.filename || img.url.split('/').pop()); + return ` +
+
+ ${escapeHtml(img.satellite)} ${escapeHtml(img.product)} +
+
${escapeHtml(img.satellite)}
+
${escapeHtml(img.product || img.mode)}
+
${formatTimestamp(img.timestamp)}
+
+
+
+ +
+
`; + }).join(''); + } + + gallery.innerHTML = html; + } + + /** + * Show full-size image + */ + function showImage(url, satellite, product, filename) { + currentModalFilename = filename || null; + + let modal = document.getElementById('wxsatImageModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'wxsatImageModal'; + modal.className = 'wxsat-image-modal'; + modal.innerHTML = ` +
+ +
+ + Weather Satellite Image +
+ `; + modal.addEventListener('click', (e) => { + if (e.target === modal) closeImage(); + }); + document.body.appendChild(modal); + } + + modal.querySelector('img').src = url; + const info = modal.querySelector('.wxsat-modal-info'); + if (info) { + info.textContent = `${satellite || ''} ${product ? '// ' + product : ''}`; + } + modal.classList.add('show'); + } + + /** + * Close image modal + */ + function closeImage() { + const modal = document.getElementById('wxsatImageModal'); + if (modal) modal.classList.remove('show'); + } + + /** + * Delete a single image + */ + async function deleteImage(filename) { + if (!filename) return; + if (!confirm(`Delete this image?`)) return; + + try { + const response = await fetch(`/weather-sat/images/${encodeURIComponent(filename)}`, { method: 'DELETE' }); + const data = await response.json(); + + if (data.status === 'deleted') { + images = images.filter(img => { + const imgFn = img.filename || img.url.split('/').pop(); + return imgFn !== filename; + }); + updateImageCount(images.length); + renderGallery(); + closeImage(); + } else { + showNotification('Weather Sat', data.message || 'Failed to delete image'); + } + } catch (err) { + console.error('Failed to delete image:', err); + showNotification('Weather Sat', 'Failed to delete image'); + } + } + + /** + * Delete all images + */ + async function deleteAllImages() { + if (images.length === 0) return; + if (!confirm(`Delete all ${images.length} decoded images?`)) return; + + try { + const response = await fetch('/weather-sat/images', { method: 'DELETE' }); + const data = await response.json(); + + if (data.status === 'ok') { + images = []; + updateImageCount(0); + renderGallery(); + showNotification('Weather Sat', `Deleted ${data.deleted} images`); + } else { + showNotification('Weather Sat', 'Failed to delete images'); + } + } catch (err) { + console.error('Failed to delete all images:', err); + showNotification('Weather Sat', 'Failed to delete images'); + } + } + + /** + * Format timestamp + */ + function formatTimestamp(isoString) { + if (!isoString) return '--'; + try { + return new Date(isoString).toLocaleString(); + } catch { + return isoString; + } + } + + /** + * Escape HTML + */ + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + /** * Invalidate ground map size (call after container becomes visible) */ @@ -1662,151 +1665,151 @@ const WeatherSat = (function() { updateGroundTrack(getSelectedPass()); }, 100); } - - // ======================== - // Decoder Console - // ======================== - - /** - * Add an entry to the decoder console log - */ - function addConsoleEntry(message, logType) { - const log = document.getElementById('wxsatConsoleLog'); - if (!log) return; - - const entry = document.createElement('div'); - entry.className = `wxsat-console-entry wxsat-log-${logType || 'info'}`; - entry.textContent = message; - log.appendChild(entry); - - consoleEntries.push(entry); - - // Cap at 200 entries - while (consoleEntries.length > 200) { - const old = consoleEntries.shift(); - if (old.parentNode) old.parentNode.removeChild(old); - } - - // Auto-scroll to bottom - log.scrollTop = log.scrollHeight; - } - - /** - * Update the phase indicator steps - */ - function updatePhaseIndicator(phase) { - if (!phase || phase === currentPhase) return; - currentPhase = phase; - - const phases = ['tuning', 'listening', 'signal_detected', 'decoding', 'complete']; - const phaseIndex = phases.indexOf(phase); - const isError = phase === 'error'; - - document.querySelectorAll('#wxsatPhaseIndicator .wxsat-phase-step').forEach(step => { - const stepPhase = step.dataset.phase; - const stepIndex = phases.indexOf(stepPhase); - - step.classList.remove('active', 'completed', 'error'); - - if (isError) { - if (stepPhase === currentPhase || stepIndex === phaseIndex) { - step.classList.add('error'); - } - } else if (stepIndex === phaseIndex) { - step.classList.add('active'); - } else if (stepIndex < phaseIndex && phaseIndex >= 0) { - step.classList.add('completed'); - } - }); - } - - /** - * Show or hide the decoder console - */ - function showConsole(visible) { - const el = document.getElementById('wxsatSignalConsole'); - if (el) el.classList.toggle('active', visible); - - if (consoleAutoHideTimer) { - clearTimeout(consoleAutoHideTimer); - consoleAutoHideTimer = null; - } - } - - /** - * Toggle console body collapsed state - */ - function toggleConsole() { - const body = document.getElementById('wxsatConsoleBody'); - const btn = document.getElementById('wxsatConsoleToggle'); - if (!body) return; - - consoleCollapsed = !consoleCollapsed; - body.classList.toggle('collapsed', consoleCollapsed); - if (btn) btn.classList.toggle('collapsed', consoleCollapsed); - } - - /** - * Clear console entries and reset phase indicator - */ - function clearConsole() { - const log = document.getElementById('wxsatConsoleLog'); - if (log) log.innerHTML = ''; - consoleEntries = []; - currentPhase = 'idle'; - - document.querySelectorAll('#wxsatPhaseIndicator .wxsat-phase-step').forEach(step => { - step.classList.remove('active', 'completed', 'error'); - }); - - if (consoleAutoHideTimer) { - clearTimeout(consoleAutoHideTimer); - consoleAutoHideTimer = null; - } - } - - /** - * Suspend background activity when leaving the mode. - * Closes the SSE stream and stops the countdown interval so they don't - * keep running while another mode is active. The stream is re-opened - * by init() or startStream() when the mode is next entered. - */ - function suspend() { - if (countdownInterval) { - clearInterval(countdownInterval); - countdownInterval = null; - } - // Only close the stream if nothing is actively capturing/scheduling — - // if a capture or scheduler is running we want it to continue on the - // server and the stream will reconnect on next init(). - if (!isRunning && !schedulerEnabled) { - stopStream(); - } - } - - // Public API - return { - init, - suspend, - start, - stop, - startPass, - selectPass, - testDecode, - loadImages, - loadPasses, - showImage, - closeImage, - deleteImage, - deleteAllImages, - useGPS, - toggleScheduler, - invalidateMap, - toggleConsole, - _getModalFilename: () => currentModalFilename, - }; -})(); - -document.addEventListener('DOMContentLoaded', function() { - // Initialization happens via selectMode when weather-satellite mode is activated -}); + + // ======================== + // Decoder Console + // ======================== + + /** + * Add an entry to the decoder console log + */ + function addConsoleEntry(message, logType) { + const log = document.getElementById('wxsatConsoleLog'); + if (!log) return; + + const entry = document.createElement('div'); + entry.className = `wxsat-console-entry wxsat-log-${logType || 'info'}`; + entry.textContent = message; + log.appendChild(entry); + + consoleEntries.push(entry); + + // Cap at 200 entries + while (consoleEntries.length > 200) { + const old = consoleEntries.shift(); + if (old.parentNode) old.parentNode.removeChild(old); + } + + // Auto-scroll to bottom + log.scrollTop = log.scrollHeight; + } + + /** + * Update the phase indicator steps + */ + function updatePhaseIndicator(phase) { + if (!phase || phase === currentPhase) return; + currentPhase = phase; + + const phases = ['tuning', 'listening', 'signal_detected', 'decoding', 'complete']; + const phaseIndex = phases.indexOf(phase); + const isError = phase === 'error'; + + document.querySelectorAll('#wxsatPhaseIndicator .wxsat-phase-step').forEach(step => { + const stepPhase = step.dataset.phase; + const stepIndex = phases.indexOf(stepPhase); + + step.classList.remove('active', 'completed', 'error'); + + if (isError) { + if (stepPhase === currentPhase || stepIndex === phaseIndex) { + step.classList.add('error'); + } + } else if (stepIndex === phaseIndex) { + step.classList.add('active'); + } else if (stepIndex < phaseIndex && phaseIndex >= 0) { + step.classList.add('completed'); + } + }); + } + + /** + * Show or hide the decoder console + */ + function showConsole(visible) { + const el = document.getElementById('wxsatSignalConsole'); + if (el) el.classList.toggle('active', visible); + + if (consoleAutoHideTimer) { + clearTimeout(consoleAutoHideTimer); + consoleAutoHideTimer = null; + } + } + + /** + * Toggle console body collapsed state + */ + function toggleConsole() { + const body = document.getElementById('wxsatConsoleBody'); + const btn = document.getElementById('wxsatConsoleToggle'); + if (!body) return; + + consoleCollapsed = !consoleCollapsed; + body.classList.toggle('collapsed', consoleCollapsed); + if (btn) btn.classList.toggle('collapsed', consoleCollapsed); + } + + /** + * Clear console entries and reset phase indicator + */ + function clearConsole() { + const log = document.getElementById('wxsatConsoleLog'); + if (log) log.innerHTML = ''; + consoleEntries = []; + currentPhase = 'idle'; + + document.querySelectorAll('#wxsatPhaseIndicator .wxsat-phase-step').forEach(step => { + step.classList.remove('active', 'completed', 'error'); + }); + + if (consoleAutoHideTimer) { + clearTimeout(consoleAutoHideTimer); + consoleAutoHideTimer = null; + } + } + + /** + * Suspend background activity when leaving the mode. + * Closes the SSE stream and stops the countdown interval so they don't + * keep running while another mode is active. The stream is re-opened + * by init() or startStream() when the mode is next entered. + */ + function suspend() { + if (countdownInterval) { + clearInterval(countdownInterval); + countdownInterval = null; + } + // Only close the stream if nothing is actively capturing/scheduling — + // if a capture or scheduler is running we want it to continue on the + // server and the stream will reconnect on next init(). + if (!isRunning && !schedulerEnabled) { + stopStream(); + } + } + + // Public API + return { + init, + suspend, + start, + stop, + startPass, + selectPass, + testDecode, + loadImages, + loadPasses, + showImage, + closeImage, + deleteImage, + deleteAllImages, + useGPS, + toggleScheduler, + invalidateMap, + toggleConsole, + _getModalFilename: () => currentModalFilename, + }; +})(); + +document.addEventListener('DOMContentLoaded', function() { + // Initialization happens via selectMode when weather-satellite mode is activated +}); diff --git a/tests/test_subghz.py b/tests/test_subghz.py index 6e73ba8..5de373f 100644 --- a/tests/test_subghz.py +++ b/tests/test_subghz.py @@ -76,12 +76,12 @@ class TestReceive: mock_proc.stderr = MagicMock() mock_proc.stderr.readline = MagicMock(return_value=b'') - with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \ - patch('subprocess.Popen', return_value=mock_proc), \ - patch.object(manager, 'check_hackrf_device', return_value=True), \ - patch('utils.subghz.register_process'): - manager._hackrf_available = None - result = manager.start_receive( + with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \ + patch('subprocess.Popen', return_value=mock_proc), \ + patch.object(manager, 'check_hackrf_device', return_value=True), \ + patch('utils.subghz.register_process'): + manager._hackrf_available = None + result = manager.start_receive( frequency_hz=433920000, sample_rate=2000000, lna_gain=32, @@ -92,9 +92,14 @@ class TestReceive: assert manager.active_mode == 'rx' def test_start_receive_already_running(self, manager): + import time as _time mock_proc = MagicMock() mock_proc.poll.return_value = None manager._rx_process = mock_proc + # Pre-lock device checks now run before active_mode guard + manager._hackrf_available = True + manager._hackrf_device_cache = True + manager._hackrf_device_cache_ts = _time.time() result = manager.start_receive(frequency_hz=433920000) assert result['status'] == 'error' @@ -104,10 +109,10 @@ class TestReceive: result = manager.stop_receive() assert result['status'] == 'not_running' - def test_stop_receive_creates_metadata(self, manager, tmp_data_dir): - # Create a fake IQ file - iq_file = tmp_data_dir / 'captures' / 'test.iq' - iq_file.write_bytes(b'\x00' * 1024) + def test_stop_receive_creates_metadata(self, manager, tmp_data_dir): + # Create a fake IQ file + iq_file = tmp_data_dir / 'captures' / 'test.iq' + iq_file.write_bytes(b'\x00' * 1024) mock_proc = MagicMock() mock_proc.poll.return_value = None @@ -115,10 +120,10 @@ class TestReceive: manager._rx_file = iq_file manager._rx_frequency_hz = 433920000 manager._rx_sample_rate = 2000000 - manager._rx_lna_gain = 32 - manager._rx_vga_gain = 20 - manager._rx_start_time = 1000.0 - manager._rx_bursts = [{'start_seconds': 1.23, 'duration_seconds': 0.15, 'peak_level': 42}] + manager._rx_lna_gain = 32 + manager._rx_vga_gain = 20 + manager._rx_start_time = 1000.0 + manager._rx_bursts = [{'start_seconds': 1.23, 'duration_seconds': 0.15, 'peak_level': 42}] with patch('utils.subghz.safe_terminate'), \ patch('time.time', return_value=1005.0): @@ -131,10 +136,10 @@ class TestReceive: # Verify JSON sidecar was written meta_path = iq_file.with_suffix('.json') assert meta_path.exists() - meta = json.loads(meta_path.read_text()) - assert meta['frequency_hz'] == 433920000 - assert isinstance(meta.get('bursts'), list) - assert meta['bursts'][0]['peak_level'] == 42 + meta = json.loads(meta_path.read_text()) + assert meta['frequency_hz'] == 433920000 + assert isinstance(meta.get('bursts'), list) + assert meta['bursts'][0]['peak_level'] == 42 class TestTxSafety: @@ -165,13 +170,13 @@ class TestTxSafety: result = manager.transmit(capture_id='abc123') assert result['status'] == 'error' - def test_transmit_capture_not_found(self, manager): - with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \ - patch.object(manager, 'check_hackrf_device', return_value=True): - manager._hackrf_available = None - result = manager.transmit(capture_id='nonexistent') - assert result['status'] == 'error' - assert 'not found' in result['message'] + def test_transmit_capture_not_found(self, manager): + with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \ + patch.object(manager, 'check_hackrf_device', return_value=True): + manager._hackrf_available = None + result = manager.transmit(capture_id='nonexistent') + assert result['status'] == 'error' + assert 'not found' in result['message'] def test_transmit_out_of_band_rejected(self, manager, tmp_data_dir): # Create a capture with out-of-band frequency @@ -188,64 +193,79 @@ class TestTxSafety: meta_path.write_text(json.dumps(meta)) (tmp_data_dir / 'captures' / 'test.iq').write_bytes(b'\x00' * 100) - with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \ - patch.object(manager, 'check_hackrf_device', return_value=True): - manager._hackrf_available = None - result = manager.transmit(capture_id='test123') - assert result['status'] == 'error' + with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \ + patch.object(manager, 'check_hackrf_device', return_value=True): + manager._hackrf_available = None + result = manager.transmit(capture_id='test123') + assert result['status'] == 'error' assert 'outside allowed TX bands' in result['message'] - def test_transmit_already_running(self, manager): - mock_proc = MagicMock() - mock_proc.poll.return_value = None - manager._rx_process = mock_proc - - result = manager.transmit(capture_id='test123') - assert result['status'] == 'error' - assert 'Already running' in result['message'] - - def test_transmit_segment_extracts_range(self, manager, tmp_data_dir): - meta = { - 'id': 'seg001', - 'filename': 'seg.iq', - 'frequency_hz': 433920000, - 'sample_rate': 1000, - 'lna_gain': 24, - 'vga_gain': 20, - 'timestamp': '2026-01-01T00:00:00Z', - 'duration_seconds': 1.0, - 'size_bytes': 2000, - } - (tmp_data_dir / 'captures' / 'seg.json').write_text(json.dumps(meta)) - (tmp_data_dir / 'captures' / 'seg.iq').write_bytes(bytes(range(200)) * 10) - - mock_proc = MagicMock() - mock_proc.poll.return_value = None - mock_timer = MagicMock() - mock_timer.start = MagicMock() - - with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \ - patch.object(manager, 'check_hackrf_device', return_value=True), \ - patch('subprocess.Popen', return_value=mock_proc), \ - patch('utils.subghz.register_process'), \ - patch('threading.Timer', return_value=mock_timer), \ - patch('threading.Thread') as mock_thread_cls: - mock_thread = MagicMock() - mock_thread.start = MagicMock() - mock_thread_cls.return_value = mock_thread - - manager._hackrf_available = None - result = manager.transmit( - capture_id='seg001', - start_seconds=0.2, - duration_seconds=0.3, - ) - - assert result['status'] == 'transmitting' - assert result['segment'] is not None - assert result['segment']['duration_seconds'] == pytest.approx(0.3, abs=0.01) - assert manager._tx_temp_file is not None - assert manager._tx_temp_file.exists() + def test_transmit_already_running(self, manager, tmp_data_dir): + import time as _time + mock_proc = MagicMock() + mock_proc.poll.return_value = None + manager._rx_process = mock_proc + # Pre-lock device checks now run before active_mode guard + manager._hackrf_available = True + manager._hackrf_device_cache = True + manager._hackrf_device_cache_ts = _time.time() + # Capture lookup also runs pre-lock now; provide a valid capture + IQ file + meta = { + 'id': 'test123', + 'filename': 'test.iq', + 'frequency_hz': 433920000, + 'sample_rate': 2000000, + 'timestamp': '2025-01-01T00:00:00', + } + (tmp_data_dir / 'captures' / 'test.json').write_text(json.dumps(meta)) + (tmp_data_dir / 'captures' / 'test.iq').write_bytes(b'\x00' * 64) + + result = manager.transmit(capture_id='test123') + assert result['status'] == 'error' + assert 'Already running' in result['message'] + + def test_transmit_segment_extracts_range(self, manager, tmp_data_dir): + meta = { + 'id': 'seg001', + 'filename': 'seg.iq', + 'frequency_hz': 433920000, + 'sample_rate': 1000, + 'lna_gain': 24, + 'vga_gain': 20, + 'timestamp': '2026-01-01T00:00:00Z', + 'duration_seconds': 1.0, + 'size_bytes': 2000, + } + (tmp_data_dir / 'captures' / 'seg.json').write_text(json.dumps(meta)) + (tmp_data_dir / 'captures' / 'seg.iq').write_bytes(bytes(range(200)) * 10) + + mock_proc = MagicMock() + mock_proc.poll.return_value = None + mock_timer = MagicMock() + mock_timer.start = MagicMock() + + with patch('shutil.which', return_value='/usr/bin/hackrf_transfer'), \ + patch.object(manager, 'check_hackrf_device', return_value=True), \ + patch('subprocess.Popen', return_value=mock_proc), \ + patch('utils.subghz.register_process'), \ + patch('threading.Timer', return_value=mock_timer), \ + patch('threading.Thread') as mock_thread_cls: + mock_thread = MagicMock() + mock_thread.start = MagicMock() + mock_thread_cls.return_value = mock_thread + + manager._hackrf_available = None + result = manager.transmit( + capture_id='seg001', + start_seconds=0.2, + duration_seconds=0.3, + ) + + assert result['status'] == 'transmitting' + assert result['segment'] is not None + assert result['segment']['duration_seconds'] == pytest.approx(0.3, abs=0.01) + assert manager._tx_temp_file is not None + assert manager._tx_temp_file.exists() class TestCaptureLibrary: @@ -311,11 +331,11 @@ class TestCaptureLibrary: def test_delete_capture_not_found(self, manager): assert manager.delete_capture('nonexistent') is False - def test_update_label(self, manager, tmp_data_dir): - meta = { - 'id': 'lbl001', - 'filename': 'label_test.iq', - 'frequency_hz': 433920000, + def test_update_label(self, manager, tmp_data_dir): + meta = { + 'id': 'lbl001', + 'filename': 'label_test.iq', + 'frequency_hz': 433920000, 'sample_rate': 2000000, 'timestamp': '2026-01-01T00:00:00Z', 'label': '', @@ -324,10 +344,10 @@ class TestCaptureLibrary: meta_path.write_text(json.dumps(meta)) assert manager.update_capture_label('lbl001', 'Garage Remote') is True - - updated = json.loads(meta_path.read_text()) - assert updated['label'] == 'Garage Remote' - assert updated['label_source'] == 'manual' + + updated = json.loads(meta_path.read_text()) + assert updated['label'] == 'Garage Remote' + assert updated['label_source'] == 'manual' def test_update_label_not_found(self, manager): assert manager.update_capture_label('nonexistent', 'test') is False @@ -348,100 +368,100 @@ class TestCaptureLibrary: assert path is not None assert path.name == 'path_test.iq' - def test_get_capture_path_not_found(self, manager): - assert manager.get_capture_path('nonexistent') is None - - def test_trim_capture_manual_segment(self, manager, tmp_data_dir): - captures_dir = tmp_data_dir / 'captures' - iq_path = captures_dir / 'trim_src.iq' - iq_path.write_bytes(bytes(range(200)) * 20) # 4000 bytes at 1000 sps => 2.0s - (captures_dir / 'trim_src.json').write_text(json.dumps({ - 'id': 'trim001', - 'filename': 'trim_src.iq', - 'frequency_hz': 433920000, - 'sample_rate': 1000, - 'lna_gain': 24, - 'vga_gain': 20, - 'timestamp': '2026-01-01T00:00:00Z', - 'duration_seconds': 2.0, - 'size_bytes': 4000, - 'label': 'Weather Burst', - 'bursts': [ - { - 'start_seconds': 0.55, - 'duration_seconds': 0.2, - 'peak_level': 67, - 'fingerprint': 'abc123', - 'modulation_hint': 'OOK/ASK', - 'modulation_confidence': 0.9, - } - ], - })) - - result = manager.trim_capture( - capture_id='trim001', - start_seconds=0.5, - duration_seconds=0.4, - ) - - assert result['status'] == 'ok' - assert result['capture']['id'] != 'trim001' - assert result['capture']['size_bytes'] == 800 - assert result['capture']['label'].endswith('(Trim)') - trimmed_iq = captures_dir / result['capture']['filename'] - assert trimmed_iq.exists() - trimmed_meta = trimmed_iq.with_suffix('.json') - assert trimmed_meta.exists() - - def test_trim_capture_auto_burst(self, manager, tmp_data_dir): - captures_dir = tmp_data_dir / 'captures' - iq_path = captures_dir / 'auto_src.iq' - iq_path.write_bytes(bytes(range(100)) * 40) # 4000 bytes - (captures_dir / 'auto_src.json').write_text(json.dumps({ - 'id': 'trim002', - 'filename': 'auto_src.iq', - 'frequency_hz': 433920000, - 'sample_rate': 1000, - 'lna_gain': 24, - 'vga_gain': 20, - 'timestamp': '2026-01-01T00:00:00Z', - 'duration_seconds': 2.0, - 'size_bytes': 4000, - 'bursts': [ - {'start_seconds': 0.2, 'duration_seconds': 0.1, 'peak_level': 12}, - {'start_seconds': 1.2, 'duration_seconds': 0.25, 'peak_level': 88}, - ], - })) - - result = manager.trim_capture(capture_id='trim002') - assert result['status'] == 'ok' - assert result['segment']['auto_selected'] is True - assert result['capture']['duration_seconds'] > 0.25 - - def test_list_captures_groups_same_fingerprint(self, manager, tmp_data_dir): - cap_a = { - 'id': 'grp001', - 'filename': 'a.iq', - 'frequency_hz': 433920000, - 'sample_rate': 2000000, - 'timestamp': '2026-01-01T00:00:00Z', - 'dominant_fingerprint': 'deadbeefcafebabe', - } - cap_b = { - 'id': 'grp002', - 'filename': 'b.iq', - 'frequency_hz': 433920000, - 'sample_rate': 2000000, - 'timestamp': '2026-01-01T00:01:00Z', - 'dominant_fingerprint': 'deadbeefcafebabe', - } - (tmp_data_dir / 'captures' / 'a.json').write_text(json.dumps(cap_a)) - (tmp_data_dir / 'captures' / 'b.json').write_text(json.dumps(cap_b)) - - captures = manager.list_captures() - assert len(captures) == 2 - assert all(c.fingerprint_group.startswith('SIG-') for c in captures) - assert all(c.fingerprint_group_size == 2 for c in captures) + def test_get_capture_path_not_found(self, manager): + assert manager.get_capture_path('nonexistent') is None + + def test_trim_capture_manual_segment(self, manager, tmp_data_dir): + captures_dir = tmp_data_dir / 'captures' + iq_path = captures_dir / 'trim_src.iq' + iq_path.write_bytes(bytes(range(200)) * 20) # 4000 bytes at 1000 sps => 2.0s + (captures_dir / 'trim_src.json').write_text(json.dumps({ + 'id': 'trim001', + 'filename': 'trim_src.iq', + 'frequency_hz': 433920000, + 'sample_rate': 1000, + 'lna_gain': 24, + 'vga_gain': 20, + 'timestamp': '2026-01-01T00:00:00Z', + 'duration_seconds': 2.0, + 'size_bytes': 4000, + 'label': 'Weather Burst', + 'bursts': [ + { + 'start_seconds': 0.55, + 'duration_seconds': 0.2, + 'peak_level': 67, + 'fingerprint': 'abc123', + 'modulation_hint': 'OOK/ASK', + 'modulation_confidence': 0.9, + } + ], + })) + + result = manager.trim_capture( + capture_id='trim001', + start_seconds=0.5, + duration_seconds=0.4, + ) + + assert result['status'] == 'ok' + assert result['capture']['id'] != 'trim001' + assert result['capture']['size_bytes'] == 800 + assert result['capture']['label'].endswith('(Trim)') + trimmed_iq = captures_dir / result['capture']['filename'] + assert trimmed_iq.exists() + trimmed_meta = trimmed_iq.with_suffix('.json') + assert trimmed_meta.exists() + + def test_trim_capture_auto_burst(self, manager, tmp_data_dir): + captures_dir = tmp_data_dir / 'captures' + iq_path = captures_dir / 'auto_src.iq' + iq_path.write_bytes(bytes(range(100)) * 40) # 4000 bytes + (captures_dir / 'auto_src.json').write_text(json.dumps({ + 'id': 'trim002', + 'filename': 'auto_src.iq', + 'frequency_hz': 433920000, + 'sample_rate': 1000, + 'lna_gain': 24, + 'vga_gain': 20, + 'timestamp': '2026-01-01T00:00:00Z', + 'duration_seconds': 2.0, + 'size_bytes': 4000, + 'bursts': [ + {'start_seconds': 0.2, 'duration_seconds': 0.1, 'peak_level': 12}, + {'start_seconds': 1.2, 'duration_seconds': 0.25, 'peak_level': 88}, + ], + })) + + result = manager.trim_capture(capture_id='trim002') + assert result['status'] == 'ok' + assert result['segment']['auto_selected'] is True + assert result['capture']['duration_seconds'] > 0.25 + + def test_list_captures_groups_same_fingerprint(self, manager, tmp_data_dir): + cap_a = { + 'id': 'grp001', + 'filename': 'a.iq', + 'frequency_hz': 433920000, + 'sample_rate': 2000000, + 'timestamp': '2026-01-01T00:00:00Z', + 'dominant_fingerprint': 'deadbeefcafebabe', + } + cap_b = { + 'id': 'grp002', + 'filename': 'b.iq', + 'frequency_hz': 433920000, + 'sample_rate': 2000000, + 'timestamp': '2026-01-01T00:01:00Z', + 'dominant_fingerprint': 'deadbeefcafebabe', + } + (tmp_data_dir / 'captures' / 'a.json').write_text(json.dumps(cap_a)) + (tmp_data_dir / 'captures' / 'b.json').write_text(json.dumps(cap_b)) + + captures = manager.list_captures() + assert len(captures) == 2 + assert all(c.fingerprint_group.startswith('SIG-') for c in captures) + assert all(c.fingerprint_group_size == 2 for c in captures) class TestSweep: @@ -452,6 +472,7 @@ class TestSweep: assert result['status'] == 'error' def test_start_sweep_success(self, manager): + import time as _time mock_proc = MagicMock() mock_proc.poll.return_value = None mock_proc.stdout = MagicMock() @@ -460,6 +481,8 @@ class TestSweep: patch('subprocess.Popen', return_value=mock_proc), \ patch('utils.subghz.register_process'): manager._sweep_available = None + manager._hackrf_device_cache = True + manager._hackrf_device_cache_ts = _time.time() result = manager.start_sweep(freq_start_mhz=300, freq_end_mhz=928) assert result['status'] == 'started' @@ -517,8 +540,11 @@ class TestDecode: with patch('shutil.which', return_value='/usr/bin/tool'), \ patch('subprocess.Popen', side_effect=popen_side_effect) as mock_popen, \ patch('utils.subghz.register_process'): + import time as _time manager._hackrf_available = None manager._rtl433_available = None + manager._hackrf_device_cache = True + manager._hackrf_device_cache_ts = _time.time() result = manager.start_decode( frequency_hz=433920000, sample_rate=2000000, @@ -536,10 +562,10 @@ class TestDecode: assert '-r' in hackrf_cmd # Verify rtl_433 command - rtl433_cmd = mock_popen.call_args_list[1][0][0] - assert rtl433_cmd[0] == 'rtl_433' - assert '-r' in rtl433_cmd - assert 'cs8:-' in rtl433_cmd + rtl433_cmd = mock_popen.call_args_list[1][0][0] + assert rtl433_cmd[0] == 'rtl_433' + assert '-r' in rtl433_cmd + assert 'cs8:-' in rtl433_cmd # Both processes tracked assert manager._decode_hackrf_process is mock_hackrf_proc diff --git a/tests/test_weather_sat_decoder.py b/tests/test_weather_sat_decoder.py index 1f48642..45f975d 100644 --- a/tests/test_weather_sat_decoder.py +++ b/tests/test_weather_sat_decoder.py @@ -138,7 +138,8 @@ class TestWeatherSatDecoder: @patch('pty.openpty') def test_start_already_running(self, mock_pty, mock_popen): """start() should return True when already running.""" - with patch('shutil.which', return_value='/usr/bin/satdump'): + with patch('shutil.which', return_value='/usr/bin/satdump'), \ + patch('utils.weather_sat.WeatherSatDecoder._resolve_device_id', return_value='0'): decoder = WeatherSatDecoder() decoder._running = True diff --git a/utils/meshtastic.py b/utils/meshtastic.py index 7cebcbc..4df4dac 100644 --- a/utils/meshtastic.py +++ b/utils/meshtastic.py @@ -376,63 +376,82 @@ class MeshtasticClient: self._error = "Meshtastic SDK not installed. Install with: pip install meshtastic" return False + # Quick check under lock — bail if already running with self._lock: if self._running: return True - try: - # Subscribe to message events before connecting - pub.subscribe(self._on_receive, "meshtastic.receive") - pub.subscribe(self._on_connection, "meshtastic.connection.established") - pub.subscribe(self._on_disconnect, "meshtastic.connection.lost") + # Create interface outside lock (blocking I/O: serial/TCP connect) + new_interface = None + new_device_path = None + new_connection_type = None + try: + # Subscribe to message events before connecting + pub.subscribe(self._on_receive, "meshtastic.receive") + pub.subscribe(self._on_connection, "meshtastic.connection.established") + pub.subscribe(self._on_disconnect, "meshtastic.connection.lost") - # Connect based on connection type - if connection_type == 'tcp': - if not hostname: - self._error = "Hostname is required for TCP connections" - self._cleanup_subscriptions() - return False - self._interface = meshtastic.tcp_interface.TCPInterface(hostname=hostname) - self._device_path = hostname - self._connection_type = 'tcp' - logger.info(f"Connected to Meshtastic device via TCP: {hostname}") + if connection_type == 'tcp': + if not hostname: + self._error = "Hostname is required for TCP connections" + self._cleanup_subscriptions() + return False + new_interface = meshtastic.tcp_interface.TCPInterface(hostname=hostname) + new_device_path = hostname + new_connection_type = 'tcp' + logger.info(f"Connected to Meshtastic device via TCP: {hostname}") + else: + if device: + new_interface = meshtastic.serial_interface.SerialInterface(device) + new_device_path = device else: - # Serial connection (default) - if device: - self._interface = meshtastic.serial_interface.SerialInterface(device) - self._device_path = device - else: - # Auto-discover - self._interface = meshtastic.serial_interface.SerialInterface() - self._device_path = "auto" - self._connection_type = 'serial' - logger.info(f"Connected to Meshtastic device via serial: {self._device_path}") + new_interface = meshtastic.serial_interface.SerialInterface() + new_device_path = "auto" + new_connection_type = 'serial' + logger.info(f"Connected to Meshtastic device via serial: {new_device_path}") + except Exception as e: + self._error = str(e) + logger.error(f"Failed to connect to Meshtastic: {e}") + self._cleanup_subscriptions() + return False - self._running = True - self._error = None + # Install interface under lock + with self._lock: + if self._running: + # Another thread connected while we were connecting — discard ours + if new_interface: + try: + new_interface.close() + except Exception: + pass return True - except Exception as e: - self._error = str(e) - logger.error(f"Failed to connect to Meshtastic: {e}") - self._cleanup_subscriptions() - return False + self._interface = new_interface + self._device_path = new_device_path + self._connection_type = new_connection_type + self._running = True + self._error = None + return True def disconnect(self) -> None: """Disconnect from the Meshtastic device.""" + iface_to_close = None with self._lock: - if self._interface: - try: - self._interface.close() - except Exception as e: - logger.warning(f"Error closing Meshtastic interface: {e}") - self._interface = None - + iface_to_close = self._interface + self._interface = None self._cleanup_subscriptions() self._running = False self._device_path = None self._connection_type = None - logger.info("Disconnected from Meshtastic device") + + # Close interface outside lock (blocking I/O) + if iface_to_close: + try: + iface_to_close.close() + except Exception as e: + logger.warning(f"Error closing Meshtastic interface: {e}") + + logger.info("Disconnected from Meshtastic device") def _cleanup_subscriptions(self) -> None: """Unsubscribe from pubsub topics.""" diff --git a/utils/process_monitor.py b/utils/process_monitor.py index 4cd5ff4..ecfe786 100644 --- a/utils/process_monitor.py +++ b/utils/process_monitor.py @@ -112,6 +112,8 @@ class ProcessMonitor: def _check_all_processes(self) -> None: """Check health of all registered processes.""" + # Collect crashed processes under lock, handle restarts outside + crashed: list[tuple[str, ProcessInfo]] = [] with self._lock: for name, info in list(self.processes.items()): if not info.enabled: @@ -126,10 +128,14 @@ class ProcessMonitor: logger.warning( f"Process '{name}' terminated with code {return_code}" ) - self._handle_crash(name, info) + crashed.append((name, info)) + + # Handle restarts outside lock (involves sleeps and callbacks) + for name, info in crashed: + self._handle_crash(name, info) def _handle_crash(self, name: str, info: ProcessInfo) -> None: - """Handle a crashed process.""" + """Handle a crashed process. Must be called WITHOUT holding self._lock.""" if info.restart_callback is None: logger.info(f"No restart callback for '{name}', skipping auto-restart") return @@ -139,7 +145,8 @@ class ProcessMonitor: f"Process '{name}' exceeded max restarts ({info.max_restarts}), " "disabling auto-restart" ) - info.enabled = False + with self._lock: + info.enabled = False return # Calculate backoff with exponential increase @@ -149,18 +156,20 @@ class ProcessMonitor: f"(attempt {info.restart_count + 1}/{info.max_restarts})" ) - # Wait for backoff period + # Wait for backoff period outside lock time.sleep(backoff) # Attempt restart try: info.restart_callback() - info.restart_count += 1 - info.last_restart = datetime.now() + with self._lock: + info.restart_count += 1 + info.last_restart = datetime.now() logger.info(f"Successfully restarted '{name}'") except Exception as e: logger.error(f"Failed to restart '{name}': {e}") - info.restart_count += 1 + with self._lock: + info.restart_count += 1 def get_status(self) -> Dict[str, Any]: """ diff --git a/utils/sstv/sstv_decoder.py b/utils/sstv/sstv_decoder.py index 322458b..078781c 100644 --- a/utils/sstv/sstv_decoder.py +++ b/utils/sstv/sstv_decoder.py @@ -552,15 +552,20 @@ class SSTVDecoder: # Clean up if the thread exits while we thought we were running. # This prevents a "ghost running" state where is_running is True # but the thread has already died (e.g. rtl_fm exited). + orphan_proc = None with self._lock: was_running = self._running self._running = False if was_running and self._rtl_process: - with contextlib.suppress(Exception): - self._rtl_process.terminate() - self._rtl_process.wait(timeout=2) + orphan_proc = self._rtl_process self._rtl_process = None + # Terminate outside lock to avoid blocking other operations + if orphan_proc: + with contextlib.suppress(Exception): + orphan_proc.terminate() + orphan_proc.wait(timeout=2) + if was_running: logger.warning("Audio decode thread stopped unexpectedly") err_detail = rtl_fm_error.split('\n')[-1] if rtl_fm_error else '' @@ -661,38 +666,52 @@ class SSTVDecoder: def _retune_rtl_fm(self, new_freq_hz: int) -> None: """Retune rtl_fm to a new frequency by restarting the process.""" + old_proc = None with self._lock: if not self._running: return + old_proc = self._rtl_process + self._rtl_process = None - if self._rtl_process: - try: - self._rtl_process.terminate() - self._rtl_process.wait(timeout=2) - except Exception: - with contextlib.suppress(Exception): - self._rtl_process.kill() + # Terminate old process outside lock + if old_proc: + try: + old_proc.terminate() + old_proc.wait(timeout=2) + except Exception: + with contextlib.suppress(Exception): + old_proc.kill() - rtl_cmd = [ - 'rtl_fm', - '-d', str(self._device_index), - '-f', str(new_freq_hz), - '-M', self._modulation, - '-s', str(SAMPLE_RATE), - '-r', str(SAMPLE_RATE), - '-l', '0', - '-' - ] + # Build and start new process outside lock + rtl_cmd = [ + 'rtl_fm', + '-d', str(self._device_index), + '-f', str(new_freq_hz), + '-M', self._modulation, + '-s', str(SAMPLE_RATE), + '-r', str(SAMPLE_RATE), + '-l', '0', + '-' + ] - logger.debug(f"Restarting rtl_fm: {' '.join(rtl_cmd)}") + logger.debug(f"Restarting rtl_fm: {' '.join(rtl_cmd)}") - self._rtl_process = subprocess.Popen( - rtl_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) + new_proc = subprocess.Popen( + rtl_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) - self._current_tuned_freq_hz = new_freq_hz + # Re-acquire lock to install new process + with self._lock: + if self._running: + self._rtl_process = new_proc + self._current_tuned_freq_hz = new_freq_hz + else: + # stop() was called during retune — clean up new process + with contextlib.suppress(Exception): + new_proc.terminate() + new_proc.wait(timeout=2) @property def last_doppler_info(self) -> DopplerInfo | None: @@ -706,19 +725,22 @@ class SSTVDecoder: def stop(self) -> None: """Stop SSTV decoder.""" + proc_to_terminate = None with self._lock: self._running = False + proc_to_terminate = self._rtl_process + self._rtl_process = None - if self._rtl_process: - try: - self._rtl_process.terminate() - self._rtl_process.wait(timeout=5) - except Exception: - with contextlib.suppress(Exception): - self._rtl_process.kill() - self._rtl_process = None + # Terminate outside lock to avoid blocking other operations + if proc_to_terminate: + try: + proc_to_terminate.terminate() + proc_to_terminate.wait(timeout=5) + except Exception: + with contextlib.suppress(Exception): + proc_to_terminate.kill() - logger.info("SSTV decoder stopped") + logger.info("SSTV decoder stopped") def get_images(self) -> list[SSTVImage]: """Get list of decoded images.""" diff --git a/utils/subghz.py b/utils/subghz.py index adb82d3..99686d9 100644 --- a/utils/subghz.py +++ b/utils/subghz.py @@ -7,19 +7,19 @@ sweeps via hackrf_sweep. from __future__ import annotations -import json -import hashlib -import os -import queue -import shutil -import subprocess -import threading -import time -import uuid -from dataclasses import dataclass, field -from datetime import datetime, timezone -from pathlib import Path -from typing import BinaryIO, Callable +import json +import hashlib +import os +import queue +import shutil +import subprocess +import threading +import time +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import BinaryIO, Callable import numpy as np @@ -42,7 +42,7 @@ logger = get_logger('intercept.subghz') @dataclass -class SubGhzCapture: +class SubGhzCapture: """Metadata for a saved IQ capture.""" capture_id: str filename: str @@ -51,47 +51,47 @@ class SubGhzCapture: lna_gain: int vga_gain: int timestamp: str - duration_seconds: float = 0.0 - size_bytes: int = 0 - label: str = '' - label_source: str = '' - decoded_protocols: list[str] = field(default_factory=list) - bursts: list[dict] = field(default_factory=list) - modulation_hint: str = '' - modulation_confidence: float = 0.0 - protocol_hint: str = '' - dominant_fingerprint: str = '' - fingerprint_group: str = '' - fingerprint_group_size: int = 0 - trigger_enabled: bool = False - trigger_pre_seconds: float = 0.0 - trigger_post_seconds: float = 0.0 - - def to_dict(self) -> dict: - return { - 'id': self.capture_id, - 'filename': self.filename, + duration_seconds: float = 0.0 + size_bytes: int = 0 + label: str = '' + label_source: str = '' + decoded_protocols: list[str] = field(default_factory=list) + bursts: list[dict] = field(default_factory=list) + modulation_hint: str = '' + modulation_confidence: float = 0.0 + protocol_hint: str = '' + dominant_fingerprint: str = '' + fingerprint_group: str = '' + fingerprint_group_size: int = 0 + trigger_enabled: bool = False + trigger_pre_seconds: float = 0.0 + trigger_post_seconds: float = 0.0 + + def to_dict(self) -> dict: + return { + 'id': self.capture_id, + 'filename': self.filename, 'frequency_hz': self.frequency_hz, 'sample_rate': self.sample_rate, 'lna_gain': self.lna_gain, 'vga_gain': self.vga_gain, 'timestamp': self.timestamp, - 'duration_seconds': self.duration_seconds, - 'size_bytes': self.size_bytes, - 'label': self.label, - 'label_source': self.label_source, - 'decoded_protocols': self.decoded_protocols, - 'bursts': self.bursts, - 'modulation_hint': self.modulation_hint, - 'modulation_confidence': self.modulation_confidence, - 'protocol_hint': self.protocol_hint, - 'dominant_fingerprint': self.dominant_fingerprint, - 'fingerprint_group': self.fingerprint_group, - 'fingerprint_group_size': self.fingerprint_group_size, - 'trigger_enabled': self.trigger_enabled, - 'trigger_pre_seconds': self.trigger_pre_seconds, - 'trigger_post_seconds': self.trigger_post_seconds, - } + 'duration_seconds': self.duration_seconds, + 'size_bytes': self.size_bytes, + 'label': self.label, + 'label_source': self.label_source, + 'decoded_protocols': self.decoded_protocols, + 'bursts': self.bursts, + 'modulation_hint': self.modulation_hint, + 'modulation_confidence': self.modulation_confidence, + 'protocol_hint': self.protocol_hint, + 'dominant_fingerprint': self.dominant_fingerprint, + 'fingerprint_group': self.fingerprint_group, + 'fingerprint_group_size': self.fingerprint_group_size, + 'trigger_enabled': self.trigger_enabled, + 'trigger_pre_seconds': self.trigger_pre_seconds, + 'trigger_post_seconds': self.trigger_post_seconds, + } @dataclass @@ -126,52 +126,52 @@ class SubGhzManager: self._lock = threading.RLock() self._callback: Callable[[dict], None] | None = None - # RX state - self._rx_start_time: float = 0 - self._rx_frequency_hz: int = 0 - self._rx_sample_rate: int = 0 - self._rx_lna_gain: int = 0 - self._rx_vga_gain: int = 0 - self._rx_file: Path | None = None - self._rx_file_handle: BinaryIO | None = None - self._rx_thread: threading.Thread | None = None - self._rx_stop = False - self._rx_bytes_written = 0 - self._rx_bursts: list[dict] = [] - self._rx_trigger_enabled = False - self._rx_trigger_pre_s = 0.35 - self._rx_trigger_post_s = 0.7 - self._rx_trigger_first_burst_start: float | None = None - self._rx_trigger_last_burst_end: float | None = None - self._rx_autostop_pending = False - self._rx_modulation_hint = '' - self._rx_modulation_confidence = 0.0 - self._rx_protocol_hint = '' - self._rx_fingerprint_counts: dict[str, int] = {} + # RX state + self._rx_start_time: float = 0 + self._rx_frequency_hz: int = 0 + self._rx_sample_rate: int = 0 + self._rx_lna_gain: int = 0 + self._rx_vga_gain: int = 0 + self._rx_file: Path | None = None + self._rx_file_handle: BinaryIO | None = None + self._rx_thread: threading.Thread | None = None + self._rx_stop = False + self._rx_bytes_written = 0 + self._rx_bursts: list[dict] = [] + self._rx_trigger_enabled = False + self._rx_trigger_pre_s = 0.35 + self._rx_trigger_post_s = 0.7 + self._rx_trigger_first_burst_start: float | None = None + self._rx_trigger_last_burst_end: float | None = None + self._rx_autostop_pending = False + self._rx_modulation_hint = '' + self._rx_modulation_confidence = 0.0 + self._rx_protocol_hint = '' + self._rx_fingerprint_counts: dict[str, int] = {} - # Decode state - self._decode_start_time: float = 0 - self._decode_frequency_hz: int = 0 - self._decode_sample_rate: int = 0 - self._decode_stop = False + # Decode state + self._decode_start_time: float = 0 + self._decode_frequency_hz: int = 0 + self._decode_sample_rate: int = 0 + self._decode_stop = False # TX state - self._tx_start_time: float = 0 - self._tx_watchdog: threading.Timer | None = None - self._tx_capture_id: str = '' - self._tx_temp_file: Path | None = None + self._tx_start_time: float = 0 + self._tx_watchdog: threading.Timer | None = None + self._tx_capture_id: str = '' + self._tx_temp_file: Path | None = None # Sweep state self._sweep_running = False self._sweep_thread: threading.Thread | None = None - # Tool availability - self._hackrf_available: bool | None = None - self._hackrf_info_available: bool | None = None - self._hackrf_device_cache: bool | None = None - self._hackrf_device_cache_ts: float = 0.0 - self._rtl433_available: bool | None = None - self._sweep_available: bool | None = None + # Tool availability + self._hackrf_available: bool | None = None + self._hackrf_info_available: bool | None = None + self._hackrf_device_cache: bool | None = None + self._hackrf_device_cache_ts: float = 0.0 + self._rtl433_available: bool | None = None + self._sweep_available: bool | None = None @property def data_dir(self) -> Path: @@ -191,42 +191,42 @@ class SubGhzManager: # Tool detection # ------------------------------------------------------------------ - def check_hackrf(self) -> bool: - if self._hackrf_available is None: - self._hackrf_available = shutil.which('hackrf_transfer') is not None - return self._hackrf_available - - def check_hackrf_info(self) -> bool: - if self._hackrf_info_available is None: - self._hackrf_info_available = shutil.which('hackrf_info') is not None - return self._hackrf_info_available - - def check_hackrf_device(self) -> bool | None: - """Return True if a HackRF device is detected, False if not, or None if detection unavailable.""" - if not self.check_hackrf_info(): - return None - - now = time.time() - if self._hackrf_device_cache is not None and (now - self._hackrf_device_cache_ts) < 2.0: - return self._hackrf_device_cache - - try: - from utils.sdr.detection import detect_hackrf_devices - connected = len(detect_hackrf_devices()) > 0 - except Exception as exc: - logger.debug(f"HackRF device detection failed: {exc}") - connected = False - - self._hackrf_device_cache = connected - self._hackrf_device_cache_ts = now - return connected - - def _require_hackrf_device(self) -> str | None: - """Return an error string if HackRF is explicitly not detected.""" - detected = self.check_hackrf_device() - if detected is False: - return 'HackRF device not detected' - return None + def check_hackrf(self) -> bool: + if self._hackrf_available is None: + self._hackrf_available = shutil.which('hackrf_transfer') is not None + return self._hackrf_available + + def check_hackrf_info(self) -> bool: + if self._hackrf_info_available is None: + self._hackrf_info_available = shutil.which('hackrf_info') is not None + return self._hackrf_info_available + + def check_hackrf_device(self) -> bool | None: + """Return True if a HackRF device is detected, False if not, or None if detection unavailable.""" + if not self.check_hackrf_info(): + return None + + now = time.time() + if self._hackrf_device_cache is not None and (now - self._hackrf_device_cache_ts) < 2.0: + return self._hackrf_device_cache + + try: + from utils.sdr.detection import detect_hackrf_devices + connected = len(detect_hackrf_devices()) > 0 + except Exception as exc: + logger.debug(f"HackRF device detection failed: {exc}") + connected = False + + self._hackrf_device_cache = connected + self._hackrf_device_cache_ts = now + return connected + + def _require_hackrf_device(self) -> str | None: + """Return an error string if HackRF is explicitly not detected.""" + detected = self.check_hackrf_device() + if detected is False: + return 'HackRF device not detected' + return None def check_rtl433(self) -> bool: if self._rtl433_available is None: @@ -256,45 +256,45 @@ class SubGhzManager: return 'sweep' return 'idle' - def get_status(self) -> dict: - mode = self.active_mode - hackrf_info_available = self.check_hackrf_info() - detect_paused = mode in {'rx', 'decode', 'tx', 'sweep'} - if detect_paused: - # Avoid probing HackRF while a stream is active. A fresh "disconnected" - # cache result should still surface to the UI, otherwise mark unknown. - if self._hackrf_device_cache is False and (time.time() - self._hackrf_device_cache_ts) < 15.0: - hackrf_connected: bool | None = False - else: - hackrf_connected = None - else: - hackrf_connected = self.check_hackrf_device() - status: dict = { - 'mode': mode, - 'hackrf_available': self.check_hackrf(), - 'hackrf_info_available': hackrf_info_available, - 'hackrf_connected': hackrf_connected, - 'hackrf_detection_paused': detect_paused, - 'rtl433_available': self.check_rtl433(), - 'sweep_available': self.check_sweep(), - } - if mode == 'rx': - elapsed = time.time() - self._rx_start_time if self._rx_start_time else 0 - status.update({ - 'frequency_hz': self._rx_frequency_hz, - 'sample_rate': self._rx_sample_rate, - 'elapsed_seconds': round(elapsed, 1), - 'trigger_enabled': self._rx_trigger_enabled, - 'trigger_pre_seconds': round(self._rx_trigger_pre_s, 3), - 'trigger_post_seconds': round(self._rx_trigger_post_s, 3), - }) - elif mode == 'decode': - elapsed = time.time() - self._decode_start_time if self._decode_start_time else 0 - status.update({ - 'frequency_hz': self._decode_frequency_hz, - 'sample_rate': self._decode_sample_rate, - 'elapsed_seconds': round(elapsed, 1), - }) + def get_status(self) -> dict: + mode = self.active_mode + hackrf_info_available = self.check_hackrf_info() + detect_paused = mode in {'rx', 'decode', 'tx', 'sweep'} + if detect_paused: + # Avoid probing HackRF while a stream is active. A fresh "disconnected" + # cache result should still surface to the UI, otherwise mark unknown. + if self._hackrf_device_cache is False and (time.time() - self._hackrf_device_cache_ts) < 15.0: + hackrf_connected: bool | None = False + else: + hackrf_connected = None + else: + hackrf_connected = self.check_hackrf_device() + status: dict = { + 'mode': mode, + 'hackrf_available': self.check_hackrf(), + 'hackrf_info_available': hackrf_info_available, + 'hackrf_connected': hackrf_connected, + 'hackrf_detection_paused': detect_paused, + 'rtl433_available': self.check_rtl433(), + 'sweep_available': self.check_sweep(), + } + if mode == 'rx': + elapsed = time.time() - self._rx_start_time if self._rx_start_time else 0 + status.update({ + 'frequency_hz': self._rx_frequency_hz, + 'sample_rate': self._rx_sample_rate, + 'elapsed_seconds': round(elapsed, 1), + 'trigger_enabled': self._rx_trigger_enabled, + 'trigger_pre_seconds': round(self._rx_trigger_pre_s, 3), + 'trigger_post_seconds': round(self._rx_trigger_post_s, 3), + }) + elif mode == 'decode': + elapsed = time.time() - self._decode_start_time if self._decode_start_time else 0 + status.update({ + 'frequency_hz': self._decode_frequency_hz, + 'sample_rate': self._decode_sample_rate, + 'elapsed_seconds': round(elapsed, 1), + }) elif mode == 'tx': elapsed = time.time() - self._tx_start_time if self._tx_start_time else 0 status.update({ @@ -304,30 +304,31 @@ class SubGhzManager: return status # ------------------------------------------------------------------ - # RECEIVE (IQ capture via hackrf_transfer -r) - # ------------------------------------------------------------------ - - def start_receive( - self, - frequency_hz: int, - sample_rate: int = 2000000, - lna_gain: int = 32, - vga_gain: int = 20, - trigger_enabled: bool = False, - trigger_pre_ms: int = 350, - trigger_post_ms: int = 700, - device_serial: str | None = None, - ) -> dict: + # RECEIVE (IQ capture via hackrf_transfer -r) + # ------------------------------------------------------------------ + + def start_receive( + self, + frequency_hz: int, + sample_rate: int = 2000000, + lna_gain: int = 32, + vga_gain: int = 20, + trigger_enabled: bool = False, + trigger_pre_ms: int = 350, + trigger_post_ms: int = 700, + device_serial: str | None = None, + ) -> dict: + # Pre-lock: tool availability & device detection (blocking I/O) + if not self.check_hackrf(): + return {'status': 'error', 'message': 'hackrf_transfer not found'} + device_err = self._require_hackrf_device() + if device_err: + return {'status': 'error', 'message': device_err} + with self._lock: if self.active_mode != 'idle': return {'status': 'error', 'message': f'Already running: {self.active_mode}'} - if not self.check_hackrf(): - return {'status': 'error', 'message': 'hackrf_transfer not found'} - device_err = self._require_hackrf_device() - if device_err: - return {'status': 'error', 'message': device_err} - # Validate gains lna_gain = max(SUBGHZ_LNA_GAIN_MIN, min(SUBGHZ_LNA_GAIN_MAX, lna_gain)) vga_gain = max(SUBGHZ_VGA_GAIN_MIN, min(SUBGHZ_VGA_GAIN_MAX, vga_gain)) @@ -335,1063 +336,1068 @@ class SubGhzManager: # Generate filename ts = datetime.now().strftime('%Y%m%d_%H%M%S') freq_mhz = frequency_hz / 1_000_000 - basename = f"{freq_mhz:.3f}MHz_{ts}" - iq_file = self._captures_dir / f"{basename}.iq" - - cmd = [ - 'hackrf_transfer', - '-r', str(iq_file), - '-f', str(frequency_hz), - '-s', str(sample_rate), - '-l', str(lna_gain), - '-g', str(vga_gain), - ] - if device_serial: - cmd.extend(['-d', device_serial]) - - logger.info(f"SubGHz RX: {' '.join(cmd)}") - - try: - try: - iq_file.touch(exist_ok=True) - except OSError as e: - logger.error(f"Failed to create RX file: {e}") - return {'status': 'error', 'message': 'Failed to create capture file'} - - self._rx_process = subprocess.Popen( - cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - ) - register_process(self._rx_process) - - try: - self._rx_file_handle = open(iq_file, 'rb', buffering=0) - except OSError as e: - safe_terminate(self._rx_process) - unregister_process(self._rx_process) - self._rx_process = None - logger.error(f"Failed to open RX file: {e}") - return {'status': 'error', 'message': 'Failed to open capture file'} - - self._rx_start_time = time.time() - self._rx_frequency_hz = frequency_hz - self._rx_sample_rate = sample_rate - self._rx_lna_gain = lna_gain - self._rx_vga_gain = vga_gain - self._rx_file = iq_file - self._rx_stop = False - self._rx_bytes_written = 0 - self._rx_bursts = [] - self._rx_trigger_enabled = bool(trigger_enabled) - self._rx_trigger_pre_s = max(0.05, min(5.0, float(trigger_pre_ms) / 1000.0)) - self._rx_trigger_post_s = max(0.10, min(10.0, float(trigger_post_ms) / 1000.0)) - self._rx_trigger_first_burst_start = None - self._rx_trigger_last_burst_end = None - self._rx_autostop_pending = False - self._rx_modulation_hint = '' - self._rx_modulation_confidence = 0.0 - self._rx_protocol_hint = '' - self._rx_fingerprint_counts = {} - - # Start capture stream reader - self._rx_thread = threading.Thread( - target=self._rx_capture_loop, - daemon=True, - ) - self._rx_thread.start() - - # Monitor stderr in background - threading.Thread( - target=self._monitor_rx_stderr, - daemon=True, + basename = f"{freq_mhz:.3f}MHz_{ts}" + iq_file = self._captures_dir / f"{basename}.iq" + + cmd = [ + 'hackrf_transfer', + '-r', str(iq_file), + '-f', str(frequency_hz), + '-s', str(sample_rate), + '-l', str(lna_gain), + '-g', str(vga_gain), + ] + if device_serial: + cmd.extend(['-d', device_serial]) + + logger.info(f"SubGHz RX: {' '.join(cmd)}") + + try: + try: + iq_file.touch(exist_ok=True) + except OSError as e: + logger.error(f"Failed to create RX file: {e}") + return {'status': 'error', 'message': 'Failed to create capture file'} + + self._rx_process = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + register_process(self._rx_process) + + try: + self._rx_file_handle = open(iq_file, 'rb', buffering=0) + except OSError as e: + safe_terminate(self._rx_process) + unregister_process(self._rx_process) + self._rx_process = None + logger.error(f"Failed to open RX file: {e}") + return {'status': 'error', 'message': 'Failed to open capture file'} + + self._rx_start_time = time.time() + self._rx_frequency_hz = frequency_hz + self._rx_sample_rate = sample_rate + self._rx_lna_gain = lna_gain + self._rx_vga_gain = vga_gain + self._rx_file = iq_file + self._rx_stop = False + self._rx_bytes_written = 0 + self._rx_bursts = [] + self._rx_trigger_enabled = bool(trigger_enabled) + self._rx_trigger_pre_s = max(0.05, min(5.0, float(trigger_pre_ms) / 1000.0)) + self._rx_trigger_post_s = max(0.10, min(10.0, float(trigger_post_ms) / 1000.0)) + self._rx_trigger_first_burst_start = None + self._rx_trigger_last_burst_end = None + self._rx_autostop_pending = False + self._rx_modulation_hint = '' + self._rx_modulation_confidence = 0.0 + self._rx_protocol_hint = '' + self._rx_fingerprint_counts = {} + + # Start capture stream reader + self._rx_thread = threading.Thread( + target=self._rx_capture_loop, + daemon=True, + ) + self._rx_thread.start() + + # Monitor stderr in background + threading.Thread( + target=self._monitor_rx_stderr, + daemon=True, ).start() self._emit({ 'type': 'status', 'mode': 'rx', - 'status': 'started', - 'frequency_hz': frequency_hz, - 'sample_rate': sample_rate, - 'trigger_enabled': self._rx_trigger_enabled, - 'trigger_pre_seconds': round(self._rx_trigger_pre_s, 3), - 'trigger_post_seconds': round(self._rx_trigger_post_s, 3), - }) - - if self._rx_trigger_enabled: - self._emit({ - 'type': 'info', - 'text': ( - f'[rx] Smart trigger armed ' - f'(pre {self._rx_trigger_pre_s:.2f}s, post {self._rx_trigger_post_s:.2f}s)' - ), - }) - - return { - 'status': 'started', - 'frequency_hz': frequency_hz, - 'sample_rate': sample_rate, - 'file': iq_file.name, - 'trigger_enabled': self._rx_trigger_enabled, - 'trigger_pre_seconds': round(self._rx_trigger_pre_s, 3), - 'trigger_post_seconds': round(self._rx_trigger_post_s, 3), - } + 'status': 'started', + 'frequency_hz': frequency_hz, + 'sample_rate': sample_rate, + 'trigger_enabled': self._rx_trigger_enabled, + 'trigger_pre_seconds': round(self._rx_trigger_pre_s, 3), + 'trigger_post_seconds': round(self._rx_trigger_post_s, 3), + }) - except FileNotFoundError: - return {'status': 'error', 'message': 'hackrf_transfer not found'} - except Exception as e: - logger.error(f"Failed to start RX: {e}") - return {'status': 'error', 'message': str(e)} - - def _estimate_modulation_hint( - self, - data: bytes, - ) -> tuple[str, float, str]: - """Estimate coarse modulation family from raw IQ characteristics.""" - if not data: - return 'Unknown', 0.0, 'No samples' - try: - raw = np.frombuffer(data, dtype=np.int8).astype(np.float32) - if raw.size < 2048: - return 'Unknown', 0.0, 'Insufficient samples' - - i_vals = raw[0::2] - q_vals = raw[1::2] - if i_vals.size == 0 or q_vals.size == 0: - return 'Unknown', 0.0, 'Invalid IQ frame' - - # Light decimation for lower CPU while preserving burst shape. - i_vals = i_vals[::4] - q_vals = q_vals[::4] - if i_vals.size < 256 or q_vals.size < 256: - return 'Unknown', 0.0, 'Short frame' - - iq = i_vals + 1j * q_vals - amp = np.abs(iq) - mean_amp = float(np.mean(amp)) - std_amp = float(np.std(amp)) - amp_cv = std_amp / max(mean_amp, 1.0) - - phase_step = np.angle(iq[1:] * np.conj(iq[:-1])) - phase_var = float(np.std(phase_step)) - - # Simple pulse run-length profile on envelope. - envelope = amp - float(np.median(amp)) - env_scale = float(np.percentile(np.abs(envelope), 92)) - if env_scale <= 1e-6: - pulse_density = 0.0 - mean_run = 0.0 - else: - norm = np.clip(envelope / env_scale, -1.0, 1.0) - high = norm > 0.25 - pulse_density = float(np.mean(high)) - changes = np.where(np.diff(high.astype(np.int8)) != 0)[0] - if changes.size >= 2: - runs = np.diff(np.concatenate(([0], changes, [high.size - 1]))) - mean_run = float(np.mean(runs)) - else: - mean_run = float(high.size) - - scores = { - 'OOK/ASK': 0.0, - 'FSK/GFSK': 0.0, - 'PWM/PPM': 0.0, - } - - # OOK: stronger amplitude contrast and moderate pulse occupancy. - scores['OOK/ASK'] += max(0.0, min(1.0, (amp_cv - 0.22) / 0.35)) - scores['OOK/ASK'] += max(0.0, 1.0 - abs(pulse_density - 0.4) / 0.4) * 0.35 - - # FSK: flatter amplitude, more phase movement. - scores['FSK/GFSK'] += max(0.0, min(1.0, (phase_var - 0.45) / 0.9)) - scores['FSK/GFSK'] += max(0.0, min(1.0, (0.33 - amp_cv) / 0.28)) * 0.45 - - # PWM/PPM: high edge density with short run lengths. - edge_density = 0.0 if mean_run <= 0 else min(1.0, 28.0 / max(mean_run, 1.0)) - scores['PWM/PPM'] += max(0.0, min(1.0, (amp_cv - 0.28) / 0.45)) - scores['PWM/PPM'] += edge_density * 0.6 - - best_family = max(scores, key=scores.get) - best_score = float(scores[best_family]) - confidence = max(0.0, min(0.97, best_score)) - if confidence < 0.25: - return 'Unknown', confidence, 'No clear modulation signature' - - reason = ( - f'amp_cv={amp_cv:.2f} phase_var={phase_var:.2f} ' - f'pulse_density={pulse_density:.2f}' - ) - return best_family, confidence, reason - except Exception: - return 'Unknown', 0.0, 'Modulation analysis failed' - - def _fingerprint_burst_bytes( - self, - data: bytes, - sample_rate: int, - duration_seconds: float, - ) -> str: - """Create a stable burst fingerprint for grouping similar signals.""" - if not data: - return '' - try: - raw = np.frombuffer(data, dtype=np.int8).astype(np.float32) - if raw.size < 512: - return '' - - i_vals = raw[0::2] - q_vals = raw[1::2] - if i_vals.size == 0 or q_vals.size == 0: - return '' - - amp = np.sqrt(i_vals * i_vals + q_vals * q_vals) - if amp.size < 64: - return '' - - # Normalize and downsample envelope into a fixed-size shape vector. - amp = amp - float(np.median(amp)) - scale = float(np.percentile(np.abs(amp), 95)) - if scale <= 1e-6: - scale = 1.0 - amp = np.clip(amp / scale, -1.0, 1.0) - target = 128 - if amp.size != target: - idx = np.linspace(0, amp.size - 1, target).astype(int) - amp = amp[idx] - quant = np.round((amp + 1.0) * 7.5).astype(np.uint8) - - # Include coarse timing and center-energy traits. - burst_ms = int(max(1, round(duration_seconds * 1000))) - sr_khz = int(max(1, round(sample_rate / 1000))) - payload = ( - quant.tobytes() - + burst_ms.to_bytes(2, 'little', signed=False) - + sr_khz.to_bytes(2, 'little', signed=False) - ) - return hashlib.sha1(payload).hexdigest()[:16] - except Exception: - return '' - - def _protocol_hint_from_capture( - self, - frequency_hz: int, - modulation_hint: str, - burst_count: int, - ) -> str: - freq = frequency_hz / 1_000_000 - mod = (modulation_hint or '').upper() - if burst_count <= 0: - return 'No burst activity' - if 433.70 <= freq <= 434.10 and 'OOK' in mod and burst_count >= 2: - return 'Likely weather sensor / simple remote telemetry' - if 868.0 <= freq <= 870.0 and 'OOK' in mod: - return 'Likely EU ISM OOK sensor/remote' - if 902.0 <= freq <= 928.0 and 'FSK' in mod: - return 'Likely ISM telemetry (FSK/GFSK)' - if 'PWM' in mod: - return 'Likely pulse-width/distance keyed remote' - if 'FSK' in mod: - return 'Likely continuous-tone telemetry' - if 'OOK' in mod: - return 'Likely OOK keyed burst transmitter' - return 'Unknown protocol family' - - def _auto_capture_label( - self, - frequency_hz: int, - burst_count: int, - modulation_hint: str, - protocol_hint: str, - ) -> str: - freq = frequency_hz / 1_000_000 - mod = (modulation_hint or '').upper() - if burst_count <= 0: - return f'Raw Capture {freq:.3f} MHz' - if 'weather' in protocol_hint.lower(): - return f'Weather-like Burst ({burst_count})' - if 'OOK' in mod: - return f'OOK Burst Cluster ({burst_count})' - if 'FSK' in mod: - return f'FSK Telemetry Burst ({burst_count})' - if 'PWM' in mod: - return f'PWM/PPM Burst ({burst_count})' - return f'RF Burst Capture ({burst_count})' - - def _trim_capture_to_trigger_window( - self, - iq_file: Path, - sample_rate: int, - duration_seconds: float, - bursts: list[dict], - ) -> tuple[float, list[dict]]: - """Trim a full capture to trigger window using configured pre/post roll.""" - if not self._rx_trigger_enabled or not bursts or sample_rate <= 0: - return duration_seconds, bursts - - first_start = min(float(b.get('start_seconds', 0.0)) for b in bursts) - last_end = max( - float(b.get('start_seconds', 0.0)) + float(b.get('duration_seconds', 0.0)) - for b in bursts - ) - start_s = max(0.0, first_start - self._rx_trigger_pre_s) - end_s = min(duration_seconds, last_end + self._rx_trigger_post_s) - if end_s <= start_s: - return duration_seconds, bursts - if start_s <= 0.001 and (duration_seconds - end_s) <= 0.001: - return duration_seconds, bursts - - bytes_per_second = max(2, int(sample_rate) * 2) - start_byte = int(start_s * bytes_per_second) & ~1 - end_byte = int(end_s * bytes_per_second) & ~1 - if end_byte <= start_byte: - return duration_seconds, bursts - - tmp_path = iq_file.with_suffix('.trimtmp') - try: - with open(iq_file, 'rb') as src, open(tmp_path, 'wb') as dst: - src.seek(start_byte) - remaining = end_byte - start_byte - while remaining > 0: - chunk = src.read(min(262144, remaining)) - if not chunk: - break - dst.write(chunk) - remaining -= len(chunk) - os.replace(tmp_path, iq_file) - except OSError as exc: - logger.error(f"Failed trimming trigger capture: {exc}") - try: - if tmp_path.exists(): - tmp_path.unlink() - except OSError: - pass - return duration_seconds, bursts - - trimmed_duration = max(0.0, float(end_byte - start_byte) / float(bytes_per_second)) - adjusted_bursts: list[dict] = [] - for burst in bursts: - raw_start = float(burst.get('start_seconds', 0.0)) - raw_dur = max(0.0, float(burst.get('duration_seconds', 0.0))) - raw_end = raw_start + raw_dur - if raw_end < start_s or raw_start > end_s: - continue - adjusted = dict(burst) - adjusted['start_seconds'] = round(max(0.0, raw_start - start_s), 3) - adjusted['duration_seconds'] = round(raw_dur, 3) - adjusted_bursts.append(adjusted) - return trimmed_duration, adjusted_bursts if adjusted_bursts else bursts - - def _rx_capture_loop(self) -> None: - """Read IQ data from the capture file and emit UI metrics.""" - process = self._rx_process - file_handle = self._rx_file_handle - - if not process or not file_handle: - logger.error("RX capture loop missing process/file handle") - return - - CHUNK = 262144 # 256 KB (~64 ms @ 2 Msps complex int8 IQ) - LEVEL_INTERVAL = 0.05 - WAVE_INTERVAL = 0.25 - SPECTRUM_INTERVAL = 0.25 - STATS_INTERVAL = 1.0 - HINT_EVAL_INTERVAL = 0.25 - HINT_EMIT_INTERVAL = 1.5 - - last_level = 0.0 - last_wave = 0.0 - last_spectrum = 0.0 - last_stats = time.time() - last_log = time.time() - last_hint_eval = 0.0 - last_hint_emit = 0.0 - bytes_since_stats = 0 - first_chunk = True - burst_active = False - burst_start = 0.0 - burst_last_high = 0.0 - burst_peak = 0 - burst_bytes = bytearray() - burst_hint_family = 'Unknown' - burst_hint_conf = 0.0 - BURST_OFF_HOLD = 0.18 - BURST_MIN_DURATION = 0.04 - MAX_BURST_BYTES = max(262144, int(max(1, self._rx_sample_rate) * 2 * 2)) - smooth_level = 0.0 - prev_smooth_level = 0.0 - noise_floor = 0.0 - peak_tracker = 0.0 - on_threshold = 0.0 - warmup_until = time.time() + 1.0 - modulation_scores: dict[str, float] = { - 'OOK/ASK': 0.0, - 'FSK/GFSK': 0.0, - 'PWM/PPM': 0.0, - } - last_hint_reason = '' - - try: - fd = file_handle.fileno() - if not isinstance(fd, int) or fd < 0: - logger.error("Invalid file descriptor from RX file handle") - return - except (OSError, ValueError, TypeError): - logger.error("Failed to obtain RX file descriptor") - return - - try: - while not self._rx_stop: - try: - data = os.read(fd, CHUNK) - except OSError: - break - if not data: - if process.poll() is not None: - break - time.sleep(0.05) - continue - - self._rx_bytes_written += len(data) - bytes_since_stats += len(data) - if burst_active and len(burst_bytes) < MAX_BURST_BYTES: - room = MAX_BURST_BYTES - len(burst_bytes) - burst_bytes.extend(data[:room]) - - if first_chunk: - first_chunk = False - self._emit({'type': 'info', 'text': '[rx] Receiving IQ data...'}) - - now = time.time() - if now - last_hint_eval >= HINT_EVAL_INTERVAL: - for key in modulation_scores: - modulation_scores[key] *= 0.97 - hint_family, hint_conf, hint_reason = self._estimate_modulation_hint(data) - if hint_family in modulation_scores: - modulation_scores[hint_family] += max(0.05, hint_conf) - last_hint_reason = hint_reason - last_hint_eval = now - - if now - last_level >= LEVEL_INTERVAL: - level = float(self._compute_rx_level(data)) - prev_smooth_level = smooth_level - if smooth_level <= 0: - smooth_level = level - else: - smooth_level = (smooth_level * 0.72) + (level * 0.28) - - if noise_floor <= 0: - noise_floor = smooth_level - elif not burst_active: - # Track receiver noise floor when we are not inside a burst. - noise_floor = (noise_floor * 0.94) + (smooth_level * 0.06) - - peak_tracker = max(smooth_level, peak_tracker * 0.985) - spread = max(2.0, peak_tracker - noise_floor) - on_delta = max(2.8, spread * 0.52) - off_delta = max(1.2, spread * 0.24) - on_threshold = min(95.0, noise_floor + on_delta) - off_threshold = max(0.8, min(on_threshold - 0.5, noise_floor + off_delta)) - rising = smooth_level - prev_smooth_level - - self._emit({'type': 'rx_level', 'level': int(round(smooth_level))}) - - if not burst_active: - if now >= warmup_until and smooth_level >= on_threshold and rising >= 0.35: - burst_active = True - burst_start = now - burst_last_high = now - burst_peak = int(round(smooth_level)) - burst_bytes = bytearray(data[: min(len(data), MAX_BURST_BYTES)]) - burst_hint_family = 'Unknown' - burst_hint_conf = 0.0 - if self._rx_trigger_enabled and self._rx_trigger_first_burst_start is None: - self._rx_trigger_first_burst_start = max( - 0.0, now - self._rx_start_time - ) - self._emit({ - 'type': 'info', - 'text': '[rx] Trigger fired - capturing burst window', - }) - self._emit({ - 'type': 'rx_burst', - 'mode': 'rx', - 'event': 'start', - 'start_offset_s': round( - max(0.0, now - self._rx_start_time), 3 - ), - 'level': int(round(smooth_level)), - }) - else: - if smooth_level >= off_threshold: - burst_last_high = now - burst_peak = max(burst_peak, int(round(smooth_level))) - elif (now - burst_last_high) >= BURST_OFF_HOLD: - duration = now - burst_start - if duration >= BURST_MIN_DURATION: - fp = self._fingerprint_burst_bytes( - bytes(burst_bytes), - self._rx_sample_rate, - duration, - ) - if fp: - self._rx_fingerprint_counts[fp] = ( - self._rx_fingerprint_counts.get(fp, 0) + 1 - ) - burst_hint_family, burst_hint_conf, burst_reason = self._estimate_modulation_hint( - bytes(burst_bytes) - ) - if burst_hint_family in modulation_scores and burst_hint_conf > 0: - modulation_scores[burst_hint_family] += burst_hint_conf * 1.8 - last_hint_reason = burst_reason - burst_data = { - 'start_seconds': round( - max(0.0, burst_start - self._rx_start_time), 3 - ), - 'duration_seconds': round(duration, 3), - 'peak_level': int(burst_peak), - 'fingerprint': fp, - 'modulation_hint': burst_hint_family, - 'modulation_confidence': round(float(burst_hint_conf), 3), - } - if len(self._rx_bursts) < 512: - self._rx_bursts.append(burst_data) - self._rx_trigger_last_burst_end = max( - 0.0, now - self._rx_start_time - ) - self._emit({ - 'type': 'rx_burst', - 'mode': 'rx', - 'event': 'end', - 'start_offset_s': burst_data['start_seconds'], - 'duration_ms': int(duration * 1000), - 'peak_level': int(burst_peak), - 'fingerprint': fp, - 'modulation_hint': burst_hint_family, - 'modulation_confidence': round(float(burst_hint_conf), 3), - }) - burst_active = False - burst_peak = 0 - burst_bytes = bytearray() - last_level = now - - # Emit live modulation/protocol hint periodically. - if now - last_hint_emit >= HINT_EMIT_INTERVAL: - best_family = max(modulation_scores, key=modulation_scores.get) - total_score = sum(max(0.0, v) for v in modulation_scores.values()) - best_score = max(0.0, modulation_scores.get(best_family, 0.0)) - hint_conf = 0.0 if total_score <= 0 else min(0.98, best_score / total_score) - protocol_hint = self._protocol_hint_from_capture( - self._rx_frequency_hz, - best_family if hint_conf >= 0.3 else 'Unknown', - len(self._rx_bursts), - ) - self._rx_protocol_hint = protocol_hint - if hint_conf >= 0.30: - self._rx_modulation_hint = best_family - self._rx_modulation_confidence = hint_conf - self._emit({ - 'type': 'rx_hint', - 'modulation_hint': best_family, - 'confidence': round(hint_conf, 3), - 'protocol_hint': protocol_hint, - 'reason': last_hint_reason, - }) - last_hint_emit = now - - # Smart-trigger auto-stop after quiet post-roll window. - if ( - self._rx_trigger_enabled - and self._rx_trigger_first_burst_start is not None - and not burst_active - and not self._rx_autostop_pending - ): - last_end = self._rx_trigger_last_burst_end - if last_end is not None and (max(0.0, now - self._rx_start_time) - last_end) >= self._rx_trigger_post_s: - self._rx_autostop_pending = True - self._emit({ - 'type': 'info', - 'text': '[rx] Trigger window complete - finalizing capture', - }) - threading.Thread(target=self.stop_receive, daemon=True).start() - break - - if now - last_wave >= WAVE_INTERVAL: - samples = self._extract_waveform(data) - if samples: - self._emit({'type': 'rx_waveform', 'samples': samples}) - last_wave = now - - if now - last_spectrum >= SPECTRUM_INTERVAL: - bins = self._compute_rx_spectrum(data) - if bins: - self._emit({'type': 'rx_spectrum', 'bins': bins}) - last_spectrum = now - - if now - last_stats >= STATS_INTERVAL: - rate_kb = bytes_since_stats / (now - last_stats) / 1024 - file_size = 0 - if self._rx_file and self._rx_file.exists(): - try: - file_size = self._rx_file.stat().st_size - except OSError: - file_size = 0 - self._emit({ - 'type': 'rx_stats', - 'rate_kb': round(rate_kb, 1), - 'file_size': file_size, - 'elapsed_seconds': round(time.time() - self._rx_start_time, 1) if self._rx_start_time else 0, - }) - if now - last_log >= 5.0: - self._emit({ - 'type': 'info', - 'text': ( - f'[rx] IQ: {rate_kb:.0f} KB/s ' - f'(lvl {smooth_level:.1f}, floor {noise_floor:.1f}, thr {on_threshold:.1f})' - ), - }) - last_log = now - bytes_since_stats = 0 - last_stats = now - - if burst_active: - duration = max(0.0, time.time() - burst_start) - if duration >= BURST_MIN_DURATION: - fp = self._fingerprint_burst_bytes( - bytes(burst_bytes), - self._rx_sample_rate, - duration, - ) - if fp: - self._rx_fingerprint_counts[fp] = ( - self._rx_fingerprint_counts.get(fp, 0) + 1 - ) - burst_hint_family, burst_hint_conf, burst_reason = self._estimate_modulation_hint( - bytes(burst_bytes) - ) - if burst_hint_family in modulation_scores and burst_hint_conf > 0: - modulation_scores[burst_hint_family] += burst_hint_conf * 1.8 - last_hint_reason = burst_reason - burst_data = { - 'start_seconds': round( - max(0.0, burst_start - self._rx_start_time), 3 - ), - 'duration_seconds': round(duration, 3), - 'peak_level': int(burst_peak), - 'fingerprint': fp, - 'modulation_hint': burst_hint_family, - 'modulation_confidence': round(float(burst_hint_conf), 3), - } - if len(self._rx_bursts) < 512: - self._rx_bursts.append(burst_data) - self._rx_trigger_last_burst_end = max( - 0.0, time.time() - self._rx_start_time - ) - self._emit({ - 'type': 'rx_burst', - 'mode': 'rx', - 'event': 'end', - 'start_offset_s': burst_data['start_seconds'], - 'duration_ms': int(duration * 1000), - 'peak_level': int(burst_peak), - 'fingerprint': fp, - 'modulation_hint': burst_hint_family, - 'modulation_confidence': round(float(burst_hint_conf), 3), - }) - - # Finalize modulation summary for capture metadata. - if modulation_scores: - best_family = max(modulation_scores, key=modulation_scores.get) - total_score = sum(max(0.0, v) for v in modulation_scores.values()) - best_score = max(0.0, modulation_scores.get(best_family, 0.0)) - hint_conf = 0.0 if total_score <= 0 else min(0.98, best_score / total_score) - if hint_conf >= 0.3: - self._rx_modulation_hint = best_family - self._rx_modulation_confidence = hint_conf - self._rx_protocol_hint = self._protocol_hint_from_capture( - self._rx_frequency_hz, - self._rx_modulation_hint, - len(self._rx_bursts), - ) - finally: - try: - file_handle.close() - except OSError: - pass - with self._lock: - if self._rx_file_handle is file_handle: - self._rx_file_handle = None - - def _compute_rx_level(self, data: bytes) -> int: - """Compute a gain-tolerant 0-100 signal activity score from raw IQ bytes.""" - if not data: - return 0 - try: - samples = np.frombuffer(data, dtype=np.int8).astype(np.float32) - if samples.size < 2: - return 0 - i_vals = samples[0::2] - q_vals = samples[1::2] - if i_vals.size == 0 or q_vals.size == 0: - return 0 - i_vals = i_vals[::4] - q_vals = q_vals[::4] - if i_vals.size == 0 or q_vals.size == 0: - return 0 - mag = np.sqrt(i_vals * i_vals + q_vals * q_vals) - if mag.size == 0: - return 0 - - noise = float(np.percentile(mag, 30)) - signal = float(np.percentile(mag, 90)) - peak = float(np.percentile(mag, 99)) - contrast = max(0.0, signal - noise) - crest = max(0.0, peak - signal) - mean_mag = float(np.mean(mag)) - - # Normalize by local floor so changing gain is less likely to break - # burst visibility (low gain still detectable, high gain not always "on"). - contrast_norm = contrast / max(8.0, noise + 8.0) - crest_norm = crest / max(8.0, signal + 8.0) - energy_norm = mean_mag / 60.0 - level_f = (contrast_norm * 55.0) + (crest_norm * 20.0) + (energy_norm * 10.0) - level = int(max(0, min(100, level_f))) - if level == 0 and contrast > 0.5: - level = 1 - return level - except Exception: - return 0 - - def _extract_waveform(self, data: bytes, points: int = 256) -> list[float]: - """Extract a normalized envelope waveform for UI display.""" - try: - samples = np.frombuffer(data, dtype=np.int8).astype(np.float32) - if samples.size < 2: - return [] - i_vals = samples[0::2] - q_vals = samples[1::2] - if i_vals.size == 0 or q_vals.size == 0: - return [] - mag = np.sqrt(i_vals * i_vals + q_vals * q_vals) - if mag.size == 0: - return [] - step = max(1, mag.size // points) - scoped = mag[::step][:points] - if scoped.size == 0: - return [] - baseline = float(np.median(scoped)) - centered = scoped - baseline - scale = float(np.percentile(np.abs(centered), 95)) - if scale <= 1e-6: - normalized = np.zeros_like(centered) - else: - normalized = np.clip(centered / (scale * 2.5), -1.0, 1.0) - return [round(float(x), 3) for x in normalized.tolist()] - except Exception: - return [] - - def _compute_rx_spectrum(self, data: bytes, bins: int = 256) -> list[int]: - """Compute a simple FFT magnitude slice for waterfall rendering.""" - try: - samples = np.frombuffer(data, dtype=np.int8) - if samples.size < bins * 2: - return [] - fft_size = max(256, bins) - needed = fft_size * 2 - if samples.size < needed: - return [] - samples = samples[:needed].astype(np.float32) - i_vals = samples[0::2] - q_vals = samples[1::2] - iq = i_vals + 1j * q_vals - window = np.hanning(fft_size) - spectrum = np.fft.fftshift(np.fft.fft(iq * window)) - mag = 20 * np.log10(np.abs(spectrum) + 1e-6) - mag -= np.max(mag) - # Map -60..0 dB range to 0..255 - scaled = np.clip((mag + 60.0) / 60.0, 0.0, 1.0) - bins_vals = (scaled * 255).astype(np.uint8) - if bins_vals.size != bins: - idx = np.linspace(0, bins_vals.size - 1, bins).astype(int) - bins_vals = bins_vals[idx] - return bins_vals.tolist() - except Exception: - return [] - - def _monitor_rx_stderr(self) -> None: - process = self._rx_process - if not process or not process.stderr: - return - try: - for line in iter(process.stderr.readline, b''): - text = line.decode('utf-8', errors='replace').strip() - if text: - logger.debug(f"[hackrf_rx] {text}") - if 'error' in text.lower(): - self._emit({'type': 'info', 'text': f'[hackrf_rx] {text}'}) - except Exception: - pass - - def stop_receive(self) -> dict: - thread_to_join: threading.Thread | None = None - file_handle: BinaryIO | None = None - with self._lock: - if not self._rx_process or self._rx_process.poll() is not None: - return {'status': 'not_running'} - - self._rx_stop = True - thread_to_join = self._rx_thread - self._rx_thread = None - file_handle = self._rx_file_handle - - safe_terminate(self._rx_process) - unregister_process(self._rx_process) - self._rx_process = None - - if thread_to_join and thread_to_join.is_alive(): - thread_to_join.join(timeout=2.0) - - if file_handle: - try: - file_handle.close() - except OSError: - pass - with self._lock: - if self._rx_file_handle is file_handle: - self._rx_file_handle = None - - duration = time.time() - self._rx_start_time if self._rx_start_time else 0 - iq_file = self._rx_file - - # Write JSON sidecar metadata - capture = None - if iq_file and iq_file.exists(): - bursts = list(self._rx_bursts) - duration, bursts = self._trim_capture_to_trigger_window( - iq_file=iq_file, - sample_rate=self._rx_sample_rate, - duration_seconds=duration, - bursts=bursts, - ) - size = iq_file.stat().st_size - dominant_fingerprint = '' - dominant_fingerprint_count = 0 - for fp, count in self._rx_fingerprint_counts.items(): - if count > dominant_fingerprint_count: - dominant_fingerprint = fp - dominant_fingerprint_count = count - - modulation_hint = self._rx_modulation_hint - modulation_confidence = float(self._rx_modulation_confidence or 0.0) - if not modulation_hint and bursts: - burst_hint_totals: dict[str, float] = {} - for burst in bursts: - hint_name = str(burst.get('modulation_hint') or '').strip() - hint_conf = float(burst.get('modulation_confidence') or 0.0) - if not hint_name or hint_name.lower() == 'unknown': - continue - burst_hint_totals[hint_name] = burst_hint_totals.get(hint_name, 0.0) + max(0.05, hint_conf) - if burst_hint_totals: - modulation_hint = max(burst_hint_totals, key=burst_hint_totals.get) - total_score = sum(burst_hint_totals.values()) - modulation_confidence = min( - 0.98, - burst_hint_totals[modulation_hint] / max(total_score, 0.001), - ) - - protocol_hint = self._protocol_hint_from_capture( - self._rx_frequency_hz, - modulation_hint, - len(bursts), - ) - label = self._auto_capture_label( - self._rx_frequency_hz, - len(bursts), - modulation_hint, - protocol_hint, - ) - capture_id = uuid.uuid4().hex[:12] - capture = SubGhzCapture( - capture_id=capture_id, - filename=iq_file.name, - frequency_hz=self._rx_frequency_hz, - sample_rate=self._rx_sample_rate, - lna_gain=self._rx_lna_gain, - vga_gain=self._rx_vga_gain, - timestamp=datetime.now(timezone.utc).isoformat(), - duration_seconds=round(duration, 1), - size_bytes=size, - label=label, - label_source='auto', - bursts=bursts, - modulation_hint=modulation_hint, - modulation_confidence=round(modulation_confidence, 3), - protocol_hint=protocol_hint, - dominant_fingerprint=dominant_fingerprint, - trigger_enabled=self._rx_trigger_enabled, - trigger_pre_seconds=round(self._rx_trigger_pre_s, 3), - trigger_post_seconds=round(self._rx_trigger_post_s, 3), - ) - meta_path = iq_file.with_suffix('.json') - try: - meta_path.write_text(json.dumps(capture.to_dict(), indent=2)) - except OSError as e: - logger.error(f"Failed to write capture metadata: {e}") - - with self._lock: - self._rx_file = None - self._rx_start_time = 0 - self._rx_bytes_written = 0 - self._rx_bursts = [] - self._rx_trigger_enabled = False - self._rx_trigger_first_burst_start = None - self._rx_trigger_last_burst_end = None - self._rx_autostop_pending = False - self._rx_modulation_hint = '' - self._rx_modulation_confidence = 0.0 - self._rx_protocol_hint = '' - self._rx_fingerprint_counts = {} - - self._emit({ - 'type': 'status', - 'mode': 'idle', - 'status': 'stopped', - 'duration_seconds': round(duration, 1), - }) - - result = {'status': 'stopped', 'duration_seconds': round(duration, 1)} - if capture: - result['capture'] = capture.to_dict() - return result + if self._rx_trigger_enabled: + self._emit({ + 'type': 'info', + 'text': ( + f'[rx] Smart trigger armed ' + f'(pre {self._rx_trigger_pre_s:.2f}s, post {self._rx_trigger_post_s:.2f}s)' + ), + }) + + return { + 'status': 'started', + 'frequency_hz': frequency_hz, + 'sample_rate': sample_rate, + 'file': iq_file.name, + 'trigger_enabled': self._rx_trigger_enabled, + 'trigger_pre_seconds': round(self._rx_trigger_pre_s, 3), + 'trigger_post_seconds': round(self._rx_trigger_post_s, 3), + } + + except FileNotFoundError: + return {'status': 'error', 'message': 'hackrf_transfer not found'} + except Exception as e: + logger.error(f"Failed to start RX: {e}") + return {'status': 'error', 'message': str(e)} + + def _estimate_modulation_hint( + self, + data: bytes, + ) -> tuple[str, float, str]: + """Estimate coarse modulation family from raw IQ characteristics.""" + if not data: + return 'Unknown', 0.0, 'No samples' + try: + raw = np.frombuffer(data, dtype=np.int8).astype(np.float32) + if raw.size < 2048: + return 'Unknown', 0.0, 'Insufficient samples' + + i_vals = raw[0::2] + q_vals = raw[1::2] + if i_vals.size == 0 or q_vals.size == 0: + return 'Unknown', 0.0, 'Invalid IQ frame' + + # Light decimation for lower CPU while preserving burst shape. + i_vals = i_vals[::4] + q_vals = q_vals[::4] + if i_vals.size < 256 or q_vals.size < 256: + return 'Unknown', 0.0, 'Short frame' + + iq = i_vals + 1j * q_vals + amp = np.abs(iq) + mean_amp = float(np.mean(amp)) + std_amp = float(np.std(amp)) + amp_cv = std_amp / max(mean_amp, 1.0) + + phase_step = np.angle(iq[1:] * np.conj(iq[:-1])) + phase_var = float(np.std(phase_step)) + + # Simple pulse run-length profile on envelope. + envelope = amp - float(np.median(amp)) + env_scale = float(np.percentile(np.abs(envelope), 92)) + if env_scale <= 1e-6: + pulse_density = 0.0 + mean_run = 0.0 + else: + norm = np.clip(envelope / env_scale, -1.0, 1.0) + high = norm > 0.25 + pulse_density = float(np.mean(high)) + changes = np.where(np.diff(high.astype(np.int8)) != 0)[0] + if changes.size >= 2: + runs = np.diff(np.concatenate(([0], changes, [high.size - 1]))) + mean_run = float(np.mean(runs)) + else: + mean_run = float(high.size) + + scores = { + 'OOK/ASK': 0.0, + 'FSK/GFSK': 0.0, + 'PWM/PPM': 0.0, + } + + # OOK: stronger amplitude contrast and moderate pulse occupancy. + scores['OOK/ASK'] += max(0.0, min(1.0, (amp_cv - 0.22) / 0.35)) + scores['OOK/ASK'] += max(0.0, 1.0 - abs(pulse_density - 0.4) / 0.4) * 0.35 + + # FSK: flatter amplitude, more phase movement. + scores['FSK/GFSK'] += max(0.0, min(1.0, (phase_var - 0.45) / 0.9)) + scores['FSK/GFSK'] += max(0.0, min(1.0, (0.33 - amp_cv) / 0.28)) * 0.45 + + # PWM/PPM: high edge density with short run lengths. + edge_density = 0.0 if mean_run <= 0 else min(1.0, 28.0 / max(mean_run, 1.0)) + scores['PWM/PPM'] += max(0.0, min(1.0, (amp_cv - 0.28) / 0.45)) + scores['PWM/PPM'] += edge_density * 0.6 + + best_family = max(scores, key=scores.get) + best_score = float(scores[best_family]) + confidence = max(0.0, min(0.97, best_score)) + if confidence < 0.25: + return 'Unknown', confidence, 'No clear modulation signature' + + reason = ( + f'amp_cv={amp_cv:.2f} phase_var={phase_var:.2f} ' + f'pulse_density={pulse_density:.2f}' + ) + return best_family, confidence, reason + except Exception: + return 'Unknown', 0.0, 'Modulation analysis failed' + + def _fingerprint_burst_bytes( + self, + data: bytes, + sample_rate: int, + duration_seconds: float, + ) -> str: + """Create a stable burst fingerprint for grouping similar signals.""" + if not data: + return '' + try: + raw = np.frombuffer(data, dtype=np.int8).astype(np.float32) + if raw.size < 512: + return '' + + i_vals = raw[0::2] + q_vals = raw[1::2] + if i_vals.size == 0 or q_vals.size == 0: + return '' + + amp = np.sqrt(i_vals * i_vals + q_vals * q_vals) + if amp.size < 64: + return '' + + # Normalize and downsample envelope into a fixed-size shape vector. + amp = amp - float(np.median(amp)) + scale = float(np.percentile(np.abs(amp), 95)) + if scale <= 1e-6: + scale = 1.0 + amp = np.clip(amp / scale, -1.0, 1.0) + target = 128 + if amp.size != target: + idx = np.linspace(0, amp.size - 1, target).astype(int) + amp = amp[idx] + quant = np.round((amp + 1.0) * 7.5).astype(np.uint8) + + # Include coarse timing and center-energy traits. + burst_ms = int(max(1, round(duration_seconds * 1000))) + sr_khz = int(max(1, round(sample_rate / 1000))) + payload = ( + quant.tobytes() + + burst_ms.to_bytes(2, 'little', signed=False) + + sr_khz.to_bytes(2, 'little', signed=False) + ) + return hashlib.sha1(payload).hexdigest()[:16] + except Exception: + return '' + + def _protocol_hint_from_capture( + self, + frequency_hz: int, + modulation_hint: str, + burst_count: int, + ) -> str: + freq = frequency_hz / 1_000_000 + mod = (modulation_hint or '').upper() + if burst_count <= 0: + return 'No burst activity' + if 433.70 <= freq <= 434.10 and 'OOK' in mod and burst_count >= 2: + return 'Likely weather sensor / simple remote telemetry' + if 868.0 <= freq <= 870.0 and 'OOK' in mod: + return 'Likely EU ISM OOK sensor/remote' + if 902.0 <= freq <= 928.0 and 'FSK' in mod: + return 'Likely ISM telemetry (FSK/GFSK)' + if 'PWM' in mod: + return 'Likely pulse-width/distance keyed remote' + if 'FSK' in mod: + return 'Likely continuous-tone telemetry' + if 'OOK' in mod: + return 'Likely OOK keyed burst transmitter' + return 'Unknown protocol family' + + def _auto_capture_label( + self, + frequency_hz: int, + burst_count: int, + modulation_hint: str, + protocol_hint: str, + ) -> str: + freq = frequency_hz / 1_000_000 + mod = (modulation_hint or '').upper() + if burst_count <= 0: + return f'Raw Capture {freq:.3f} MHz' + if 'weather' in protocol_hint.lower(): + return f'Weather-like Burst ({burst_count})' + if 'OOK' in mod: + return f'OOK Burst Cluster ({burst_count})' + if 'FSK' in mod: + return f'FSK Telemetry Burst ({burst_count})' + if 'PWM' in mod: + return f'PWM/PPM Burst ({burst_count})' + return f'RF Burst Capture ({burst_count})' + + def _trim_capture_to_trigger_window( + self, + iq_file: Path, + sample_rate: int, + duration_seconds: float, + bursts: list[dict], + ) -> tuple[float, list[dict]]: + """Trim a full capture to trigger window using configured pre/post roll.""" + if not self._rx_trigger_enabled or not bursts or sample_rate <= 0: + return duration_seconds, bursts + + first_start = min(float(b.get('start_seconds', 0.0)) for b in bursts) + last_end = max( + float(b.get('start_seconds', 0.0)) + float(b.get('duration_seconds', 0.0)) + for b in bursts + ) + start_s = max(0.0, first_start - self._rx_trigger_pre_s) + end_s = min(duration_seconds, last_end + self._rx_trigger_post_s) + if end_s <= start_s: + return duration_seconds, bursts + if start_s <= 0.001 and (duration_seconds - end_s) <= 0.001: + return duration_seconds, bursts + + bytes_per_second = max(2, int(sample_rate) * 2) + start_byte = int(start_s * bytes_per_second) & ~1 + end_byte = int(end_s * bytes_per_second) & ~1 + if end_byte <= start_byte: + return duration_seconds, bursts + + tmp_path = iq_file.with_suffix('.trimtmp') + try: + with open(iq_file, 'rb') as src, open(tmp_path, 'wb') as dst: + src.seek(start_byte) + remaining = end_byte - start_byte + while remaining > 0: + chunk = src.read(min(262144, remaining)) + if not chunk: + break + dst.write(chunk) + remaining -= len(chunk) + os.replace(tmp_path, iq_file) + except OSError as exc: + logger.error(f"Failed trimming trigger capture: {exc}") + try: + if tmp_path.exists(): + tmp_path.unlink() + except OSError: + pass + return duration_seconds, bursts + + trimmed_duration = max(0.0, float(end_byte - start_byte) / float(bytes_per_second)) + adjusted_bursts: list[dict] = [] + for burst in bursts: + raw_start = float(burst.get('start_seconds', 0.0)) + raw_dur = max(0.0, float(burst.get('duration_seconds', 0.0))) + raw_end = raw_start + raw_dur + if raw_end < start_s or raw_start > end_s: + continue + adjusted = dict(burst) + adjusted['start_seconds'] = round(max(0.0, raw_start - start_s), 3) + adjusted['duration_seconds'] = round(raw_dur, 3) + adjusted_bursts.append(adjusted) + return trimmed_duration, adjusted_bursts if adjusted_bursts else bursts + + def _rx_capture_loop(self) -> None: + """Read IQ data from the capture file and emit UI metrics.""" + process = self._rx_process + file_handle = self._rx_file_handle + + if not process or not file_handle: + logger.error("RX capture loop missing process/file handle") + return + + CHUNK = 262144 # 256 KB (~64 ms @ 2 Msps complex int8 IQ) + LEVEL_INTERVAL = 0.05 + WAVE_INTERVAL = 0.25 + SPECTRUM_INTERVAL = 0.25 + STATS_INTERVAL = 1.0 + HINT_EVAL_INTERVAL = 0.25 + HINT_EMIT_INTERVAL = 1.5 + + last_level = 0.0 + last_wave = 0.0 + last_spectrum = 0.0 + last_stats = time.time() + last_log = time.time() + last_hint_eval = 0.0 + last_hint_emit = 0.0 + bytes_since_stats = 0 + first_chunk = True + burst_active = False + burst_start = 0.0 + burst_last_high = 0.0 + burst_peak = 0 + burst_bytes = bytearray() + burst_hint_family = 'Unknown' + burst_hint_conf = 0.0 + BURST_OFF_HOLD = 0.18 + BURST_MIN_DURATION = 0.04 + MAX_BURST_BYTES = max(262144, int(max(1, self._rx_sample_rate) * 2 * 2)) + smooth_level = 0.0 + prev_smooth_level = 0.0 + noise_floor = 0.0 + peak_tracker = 0.0 + on_threshold = 0.0 + warmup_until = time.time() + 1.0 + modulation_scores: dict[str, float] = { + 'OOK/ASK': 0.0, + 'FSK/GFSK': 0.0, + 'PWM/PPM': 0.0, + } + last_hint_reason = '' + + try: + fd = file_handle.fileno() + if not isinstance(fd, int) or fd < 0: + logger.error("Invalid file descriptor from RX file handle") + return + except (OSError, ValueError, TypeError): + logger.error("Failed to obtain RX file descriptor") + return + + try: + while not self._rx_stop: + try: + data = os.read(fd, CHUNK) + except OSError: + break + if not data: + if process.poll() is not None: + break + time.sleep(0.05) + continue + + self._rx_bytes_written += len(data) + bytes_since_stats += len(data) + if burst_active and len(burst_bytes) < MAX_BURST_BYTES: + room = MAX_BURST_BYTES - len(burst_bytes) + burst_bytes.extend(data[:room]) + + if first_chunk: + first_chunk = False + self._emit({'type': 'info', 'text': '[rx] Receiving IQ data...'}) + + now = time.time() + if now - last_hint_eval >= HINT_EVAL_INTERVAL: + for key in modulation_scores: + modulation_scores[key] *= 0.97 + hint_family, hint_conf, hint_reason = self._estimate_modulation_hint(data) + if hint_family in modulation_scores: + modulation_scores[hint_family] += max(0.05, hint_conf) + last_hint_reason = hint_reason + last_hint_eval = now + + if now - last_level >= LEVEL_INTERVAL: + level = float(self._compute_rx_level(data)) + prev_smooth_level = smooth_level + if smooth_level <= 0: + smooth_level = level + else: + smooth_level = (smooth_level * 0.72) + (level * 0.28) + + if noise_floor <= 0: + noise_floor = smooth_level + elif not burst_active: + # Track receiver noise floor when we are not inside a burst. + noise_floor = (noise_floor * 0.94) + (smooth_level * 0.06) + + peak_tracker = max(smooth_level, peak_tracker * 0.985) + spread = max(2.0, peak_tracker - noise_floor) + on_delta = max(2.8, spread * 0.52) + off_delta = max(1.2, spread * 0.24) + on_threshold = min(95.0, noise_floor + on_delta) + off_threshold = max(0.8, min(on_threshold - 0.5, noise_floor + off_delta)) + rising = smooth_level - prev_smooth_level + + self._emit({'type': 'rx_level', 'level': int(round(smooth_level))}) + + if not burst_active: + if now >= warmup_until and smooth_level >= on_threshold and rising >= 0.35: + burst_active = True + burst_start = now + burst_last_high = now + burst_peak = int(round(smooth_level)) + burst_bytes = bytearray(data[: min(len(data), MAX_BURST_BYTES)]) + burst_hint_family = 'Unknown' + burst_hint_conf = 0.0 + if self._rx_trigger_enabled and self._rx_trigger_first_burst_start is None: + self._rx_trigger_first_burst_start = max( + 0.0, now - self._rx_start_time + ) + self._emit({ + 'type': 'info', + 'text': '[rx] Trigger fired - capturing burst window', + }) + self._emit({ + 'type': 'rx_burst', + 'mode': 'rx', + 'event': 'start', + 'start_offset_s': round( + max(0.0, now - self._rx_start_time), 3 + ), + 'level': int(round(smooth_level)), + }) + else: + if smooth_level >= off_threshold: + burst_last_high = now + burst_peak = max(burst_peak, int(round(smooth_level))) + elif (now - burst_last_high) >= BURST_OFF_HOLD: + duration = now - burst_start + if duration >= BURST_MIN_DURATION: + fp = self._fingerprint_burst_bytes( + bytes(burst_bytes), + self._rx_sample_rate, + duration, + ) + if fp: + self._rx_fingerprint_counts[fp] = ( + self._rx_fingerprint_counts.get(fp, 0) + 1 + ) + burst_hint_family, burst_hint_conf, burst_reason = self._estimate_modulation_hint( + bytes(burst_bytes) + ) + if burst_hint_family in modulation_scores and burst_hint_conf > 0: + modulation_scores[burst_hint_family] += burst_hint_conf * 1.8 + last_hint_reason = burst_reason + burst_data = { + 'start_seconds': round( + max(0.0, burst_start - self._rx_start_time), 3 + ), + 'duration_seconds': round(duration, 3), + 'peak_level': int(burst_peak), + 'fingerprint': fp, + 'modulation_hint': burst_hint_family, + 'modulation_confidence': round(float(burst_hint_conf), 3), + } + if len(self._rx_bursts) < 512: + self._rx_bursts.append(burst_data) + self._rx_trigger_last_burst_end = max( + 0.0, now - self._rx_start_time + ) + self._emit({ + 'type': 'rx_burst', + 'mode': 'rx', + 'event': 'end', + 'start_offset_s': burst_data['start_seconds'], + 'duration_ms': int(duration * 1000), + 'peak_level': int(burst_peak), + 'fingerprint': fp, + 'modulation_hint': burst_hint_family, + 'modulation_confidence': round(float(burst_hint_conf), 3), + }) + burst_active = False + burst_peak = 0 + burst_bytes = bytearray() + last_level = now + + # Emit live modulation/protocol hint periodically. + if now - last_hint_emit >= HINT_EMIT_INTERVAL: + best_family = max(modulation_scores, key=modulation_scores.get) + total_score = sum(max(0.0, v) for v in modulation_scores.values()) + best_score = max(0.0, modulation_scores.get(best_family, 0.0)) + hint_conf = 0.0 if total_score <= 0 else min(0.98, best_score / total_score) + protocol_hint = self._protocol_hint_from_capture( + self._rx_frequency_hz, + best_family if hint_conf >= 0.3 else 'Unknown', + len(self._rx_bursts), + ) + self._rx_protocol_hint = protocol_hint + if hint_conf >= 0.30: + self._rx_modulation_hint = best_family + self._rx_modulation_confidence = hint_conf + self._emit({ + 'type': 'rx_hint', + 'modulation_hint': best_family, + 'confidence': round(hint_conf, 3), + 'protocol_hint': protocol_hint, + 'reason': last_hint_reason, + }) + last_hint_emit = now + + # Smart-trigger auto-stop after quiet post-roll window. + if ( + self._rx_trigger_enabled + and self._rx_trigger_first_burst_start is not None + and not burst_active + and not self._rx_autostop_pending + ): + last_end = self._rx_trigger_last_burst_end + if last_end is not None and (max(0.0, now - self._rx_start_time) - last_end) >= self._rx_trigger_post_s: + self._rx_autostop_pending = True + self._emit({ + 'type': 'info', + 'text': '[rx] Trigger window complete - finalizing capture', + }) + threading.Thread(target=self.stop_receive, daemon=True).start() + break + + if now - last_wave >= WAVE_INTERVAL: + samples = self._extract_waveform(data) + if samples: + self._emit({'type': 'rx_waveform', 'samples': samples}) + last_wave = now + + if now - last_spectrum >= SPECTRUM_INTERVAL: + bins = self._compute_rx_spectrum(data) + if bins: + self._emit({'type': 'rx_spectrum', 'bins': bins}) + last_spectrum = now + + if now - last_stats >= STATS_INTERVAL: + rate_kb = bytes_since_stats / (now - last_stats) / 1024 + file_size = 0 + if self._rx_file and self._rx_file.exists(): + try: + file_size = self._rx_file.stat().st_size + except OSError: + file_size = 0 + self._emit({ + 'type': 'rx_stats', + 'rate_kb': round(rate_kb, 1), + 'file_size': file_size, + 'elapsed_seconds': round(time.time() - self._rx_start_time, 1) if self._rx_start_time else 0, + }) + if now - last_log >= 5.0: + self._emit({ + 'type': 'info', + 'text': ( + f'[rx] IQ: {rate_kb:.0f} KB/s ' + f'(lvl {smooth_level:.1f}, floor {noise_floor:.1f}, thr {on_threshold:.1f})' + ), + }) + last_log = now + bytes_since_stats = 0 + last_stats = now + + if burst_active: + duration = max(0.0, time.time() - burst_start) + if duration >= BURST_MIN_DURATION: + fp = self._fingerprint_burst_bytes( + bytes(burst_bytes), + self._rx_sample_rate, + duration, + ) + if fp: + self._rx_fingerprint_counts[fp] = ( + self._rx_fingerprint_counts.get(fp, 0) + 1 + ) + burst_hint_family, burst_hint_conf, burst_reason = self._estimate_modulation_hint( + bytes(burst_bytes) + ) + if burst_hint_family in modulation_scores and burst_hint_conf > 0: + modulation_scores[burst_hint_family] += burst_hint_conf * 1.8 + last_hint_reason = burst_reason + burst_data = { + 'start_seconds': round( + max(0.0, burst_start - self._rx_start_time), 3 + ), + 'duration_seconds': round(duration, 3), + 'peak_level': int(burst_peak), + 'fingerprint': fp, + 'modulation_hint': burst_hint_family, + 'modulation_confidence': round(float(burst_hint_conf), 3), + } + if len(self._rx_bursts) < 512: + self._rx_bursts.append(burst_data) + self._rx_trigger_last_burst_end = max( + 0.0, time.time() - self._rx_start_time + ) + self._emit({ + 'type': 'rx_burst', + 'mode': 'rx', + 'event': 'end', + 'start_offset_s': burst_data['start_seconds'], + 'duration_ms': int(duration * 1000), + 'peak_level': int(burst_peak), + 'fingerprint': fp, + 'modulation_hint': burst_hint_family, + 'modulation_confidence': round(float(burst_hint_conf), 3), + }) + + # Finalize modulation summary for capture metadata. + if modulation_scores: + best_family = max(modulation_scores, key=modulation_scores.get) + total_score = sum(max(0.0, v) for v in modulation_scores.values()) + best_score = max(0.0, modulation_scores.get(best_family, 0.0)) + hint_conf = 0.0 if total_score <= 0 else min(0.98, best_score / total_score) + if hint_conf >= 0.3: + self._rx_modulation_hint = best_family + self._rx_modulation_confidence = hint_conf + self._rx_protocol_hint = self._protocol_hint_from_capture( + self._rx_frequency_hz, + self._rx_modulation_hint, + len(self._rx_bursts), + ) + finally: + try: + file_handle.close() + except OSError: + pass + with self._lock: + if self._rx_file_handle is file_handle: + self._rx_file_handle = None + + def _compute_rx_level(self, data: bytes) -> int: + """Compute a gain-tolerant 0-100 signal activity score from raw IQ bytes.""" + if not data: + return 0 + try: + samples = np.frombuffer(data, dtype=np.int8).astype(np.float32) + if samples.size < 2: + return 0 + i_vals = samples[0::2] + q_vals = samples[1::2] + if i_vals.size == 0 or q_vals.size == 0: + return 0 + i_vals = i_vals[::4] + q_vals = q_vals[::4] + if i_vals.size == 0 or q_vals.size == 0: + return 0 + mag = np.sqrt(i_vals * i_vals + q_vals * q_vals) + if mag.size == 0: + return 0 + + noise = float(np.percentile(mag, 30)) + signal = float(np.percentile(mag, 90)) + peak = float(np.percentile(mag, 99)) + contrast = max(0.0, signal - noise) + crest = max(0.0, peak - signal) + mean_mag = float(np.mean(mag)) + + # Normalize by local floor so changing gain is less likely to break + # burst visibility (low gain still detectable, high gain not always "on"). + contrast_norm = contrast / max(8.0, noise + 8.0) + crest_norm = crest / max(8.0, signal + 8.0) + energy_norm = mean_mag / 60.0 + level_f = (contrast_norm * 55.0) + (crest_norm * 20.0) + (energy_norm * 10.0) + level = int(max(0, min(100, level_f))) + if level == 0 and contrast > 0.5: + level = 1 + return level + except Exception: + return 0 + + def _extract_waveform(self, data: bytes, points: int = 256) -> list[float]: + """Extract a normalized envelope waveform for UI display.""" + try: + samples = np.frombuffer(data, dtype=np.int8).astype(np.float32) + if samples.size < 2: + return [] + i_vals = samples[0::2] + q_vals = samples[1::2] + if i_vals.size == 0 or q_vals.size == 0: + return [] + mag = np.sqrt(i_vals * i_vals + q_vals * q_vals) + if mag.size == 0: + return [] + step = max(1, mag.size // points) + scoped = mag[::step][:points] + if scoped.size == 0: + return [] + baseline = float(np.median(scoped)) + centered = scoped - baseline + scale = float(np.percentile(np.abs(centered), 95)) + if scale <= 1e-6: + normalized = np.zeros_like(centered) + else: + normalized = np.clip(centered / (scale * 2.5), -1.0, 1.0) + return [round(float(x), 3) for x in normalized.tolist()] + except Exception: + return [] + + def _compute_rx_spectrum(self, data: bytes, bins: int = 256) -> list[int]: + """Compute a simple FFT magnitude slice for waterfall rendering.""" + try: + samples = np.frombuffer(data, dtype=np.int8) + if samples.size < bins * 2: + return [] + fft_size = max(256, bins) + needed = fft_size * 2 + if samples.size < needed: + return [] + samples = samples[:needed].astype(np.float32) + i_vals = samples[0::2] + q_vals = samples[1::2] + iq = i_vals + 1j * q_vals + window = np.hanning(fft_size) + spectrum = np.fft.fftshift(np.fft.fft(iq * window)) + mag = 20 * np.log10(np.abs(spectrum) + 1e-6) + mag -= np.max(mag) + # Map -60..0 dB range to 0..255 + scaled = np.clip((mag + 60.0) / 60.0, 0.0, 1.0) + bins_vals = (scaled * 255).astype(np.uint8) + if bins_vals.size != bins: + idx = np.linspace(0, bins_vals.size - 1, bins).astype(int) + bins_vals = bins_vals[idx] + return bins_vals.tolist() + except Exception: + return [] + + def _monitor_rx_stderr(self) -> None: + process = self._rx_process + if not process or not process.stderr: + return + try: + for line in iter(process.stderr.readline, b''): + text = line.decode('utf-8', errors='replace').strip() + if text: + logger.debug(f"[hackrf_rx] {text}") + if 'error' in text.lower(): + self._emit({'type': 'info', 'text': f'[hackrf_rx] {text}'}) + except Exception: + pass + + def stop_receive(self) -> dict: + thread_to_join: threading.Thread | None = None + file_handle: BinaryIO | None = None + proc_to_terminate: subprocess.Popen | None = None + with self._lock: + if not self._rx_process or self._rx_process.poll() is not None: + return {'status': 'not_running'} + + self._rx_stop = True + thread_to_join = self._rx_thread + self._rx_thread = None + file_handle = self._rx_file_handle + proc_to_terminate = self._rx_process + self._rx_process = None + + # Terminate outside lock to avoid blocking other operations + if proc_to_terminate: + safe_terminate(proc_to_terminate) + unregister_process(proc_to_terminate) + + if thread_to_join and thread_to_join.is_alive(): + thread_to_join.join(timeout=2.0) + + if file_handle: + try: + file_handle.close() + except OSError: + pass + with self._lock: + if self._rx_file_handle is file_handle: + self._rx_file_handle = None + + duration = time.time() - self._rx_start_time if self._rx_start_time else 0 + iq_file = self._rx_file + + # Write JSON sidecar metadata + capture = None + if iq_file and iq_file.exists(): + bursts = list(self._rx_bursts) + duration, bursts = self._trim_capture_to_trigger_window( + iq_file=iq_file, + sample_rate=self._rx_sample_rate, + duration_seconds=duration, + bursts=bursts, + ) + size = iq_file.stat().st_size + dominant_fingerprint = '' + dominant_fingerprint_count = 0 + for fp, count in self._rx_fingerprint_counts.items(): + if count > dominant_fingerprint_count: + dominant_fingerprint = fp + dominant_fingerprint_count = count + + modulation_hint = self._rx_modulation_hint + modulation_confidence = float(self._rx_modulation_confidence or 0.0) + if not modulation_hint and bursts: + burst_hint_totals: dict[str, float] = {} + for burst in bursts: + hint_name = str(burst.get('modulation_hint') or '').strip() + hint_conf = float(burst.get('modulation_confidence') or 0.0) + if not hint_name or hint_name.lower() == 'unknown': + continue + burst_hint_totals[hint_name] = burst_hint_totals.get(hint_name, 0.0) + max(0.05, hint_conf) + if burst_hint_totals: + modulation_hint = max(burst_hint_totals, key=burst_hint_totals.get) + total_score = sum(burst_hint_totals.values()) + modulation_confidence = min( + 0.98, + burst_hint_totals[modulation_hint] / max(total_score, 0.001), + ) + + protocol_hint = self._protocol_hint_from_capture( + self._rx_frequency_hz, + modulation_hint, + len(bursts), + ) + label = self._auto_capture_label( + self._rx_frequency_hz, + len(bursts), + modulation_hint, + protocol_hint, + ) + capture_id = uuid.uuid4().hex[:12] + capture = SubGhzCapture( + capture_id=capture_id, + filename=iq_file.name, + frequency_hz=self._rx_frequency_hz, + sample_rate=self._rx_sample_rate, + lna_gain=self._rx_lna_gain, + vga_gain=self._rx_vga_gain, + timestamp=datetime.now(timezone.utc).isoformat(), + duration_seconds=round(duration, 1), + size_bytes=size, + label=label, + label_source='auto', + bursts=bursts, + modulation_hint=modulation_hint, + modulation_confidence=round(modulation_confidence, 3), + protocol_hint=protocol_hint, + dominant_fingerprint=dominant_fingerprint, + trigger_enabled=self._rx_trigger_enabled, + trigger_pre_seconds=round(self._rx_trigger_pre_s, 3), + trigger_post_seconds=round(self._rx_trigger_post_s, 3), + ) + meta_path = iq_file.with_suffix('.json') + try: + meta_path.write_text(json.dumps(capture.to_dict(), indent=2)) + except OSError as e: + logger.error(f"Failed to write capture metadata: {e}") + + with self._lock: + self._rx_file = None + self._rx_start_time = 0 + self._rx_bytes_written = 0 + self._rx_bursts = [] + self._rx_trigger_enabled = False + self._rx_trigger_first_burst_start = None + self._rx_trigger_last_burst_end = None + self._rx_autostop_pending = False + self._rx_modulation_hint = '' + self._rx_modulation_confidence = 0.0 + self._rx_protocol_hint = '' + self._rx_fingerprint_counts = {} + + self._emit({ + 'type': 'status', + 'mode': 'idle', + 'status': 'stopped', + 'duration_seconds': round(duration, 1), + }) + + result = {'status': 'stopped', 'duration_seconds': round(duration, 1)} + if capture: + result['capture'] = capture.to_dict() + return result # ------------------------------------------------------------------ # DECODE (hackrf_transfer piped to rtl_433) # ------------------------------------------------------------------ - def start_decode( - self, - frequency_hz: int, - sample_rate: int = 2_000_000, - lna_gain: int = 32, - vga_gain: int = 20, - decode_profile: str = 'weather', - device_serial: str | None = None, - ) -> dict: + def start_decode( + self, + frequency_hz: int, + sample_rate: int = 2_000_000, + lna_gain: int = 32, + vga_gain: int = 20, + decode_profile: str = 'weather', + device_serial: str | None = None, + ) -> dict: + # Pre-lock: tool availability & device detection (blocking I/O) + if not self.check_hackrf(): + return {'status': 'error', 'message': 'hackrf_transfer not found'} + if not self.check_rtl433(): + return {'status': 'error', 'message': 'rtl_433 not found'} + device_err = self._require_hackrf_device() + if device_err: + return {'status': 'error', 'message': device_err} + with self._lock: if self.active_mode != 'idle': return {'status': 'error', 'message': f'Already running: {self.active_mode}'} - if not self.check_hackrf(): - return {'status': 'error', 'message': 'hackrf_transfer not found'} - if not self.check_rtl433(): - return {'status': 'error', 'message': 'rtl_433 not found'} - device_err = self._require_hackrf_device() - if device_err: - return {'status': 'error', 'message': device_err} - - # Keep decode bandwidth conservative for stability. 2 Msps is enough - # for common SubGHz protocols while staying within HackRF support. - requested_sample_rate = int(sample_rate) - stable_sample_rate = max(2_000_000, min(2_000_000, requested_sample_rate)) - - # Build hackrf_transfer command (producer: raw IQ to stdout) - hackrf_cmd = [ - 'hackrf_transfer', - '-r', '-', - '-f', str(frequency_hz), - '-s', str(stable_sample_rate), - '-l', str(max(SUBGHZ_LNA_GAIN_MIN, min(SUBGHZ_LNA_GAIN_MAX, lna_gain))), - '-g', str(max(SUBGHZ_VGA_GAIN_MIN, min(SUBGHZ_VGA_GAIN_MAX, vga_gain))), - ] - if device_serial: - hackrf_cmd.extend(['-d', device_serial]) + # Keep decode bandwidth conservative for stability. 2 Msps is enough + # for common SubGHz protocols while staying within HackRF support. + requested_sample_rate = int(sample_rate) + stable_sample_rate = max(2_000_000, min(2_000_000, requested_sample_rate)) - # Build rtl_433 command (consumer: reads IQ from stdin) - # Feed signed 8-bit complex IQ directly from hackrf_transfer. - rtl433_cmd = [ - 'rtl_433', - '-r', 'cs8:-', - '-s', str(stable_sample_rate), - '-f', str(frequency_hz), - '-F', 'json', - '-F', 'log', - '-M', 'level', - '-M', 'noise:5', - '-Y', 'autolevel', - '-Y', 'ampest', - '-Y', 'minsnr=2.5', - ] - profile = (decode_profile or 'weather').strip().lower() - if profile == 'weather': - # Limit decoder set to weather/temperature/humidity/rain/wind - # protocols for better sensitivity and lower CPU load. - weather_protocol_ids = [ - 2, 3, 8, 12, 16, 18, 19, 20, 31, 32, 34, 40, 47, 50, 52, - 54, 55, 56, 57, 69, 73, 74, 75, 76, 78, 79, 85, 91, 92, - 108, 109, 111, 112, 113, 119, 120, 124, 127, 132, 133, - 134, 138, 141, 143, 144, 145, 146, 147, 152, 153, 157, - 158, 163, 165, 166, 170, 171, 172, 173, 175, 182, 183, - 184, 194, 195, 196, 205, 206, 213, 214, 215, 217, 219, - 221, 222, - ] - rtl433_cmd.extend(['-R', '0']) - for proto_id in weather_protocol_ids: - rtl433_cmd.extend(['-R', str(proto_id)]) - else: - profile = 'all' + # Build hackrf_transfer command (producer: raw IQ to stdout) + hackrf_cmd = [ + 'hackrf_transfer', + '-r', '-', + '-f', str(frequency_hz), + '-s', str(stable_sample_rate), + '-l', str(max(SUBGHZ_LNA_GAIN_MIN, min(SUBGHZ_LNA_GAIN_MAX, lna_gain))), + '-g', str(max(SUBGHZ_VGA_GAIN_MIN, min(SUBGHZ_VGA_GAIN_MAX, vga_gain))), + ] + if device_serial: + hackrf_cmd.extend(['-d', device_serial]) + + # Build rtl_433 command (consumer: reads IQ from stdin) + # Feed signed 8-bit complex IQ directly from hackrf_transfer. + rtl433_cmd = [ + 'rtl_433', + '-r', 'cs8:-', + '-s', str(stable_sample_rate), + '-f', str(frequency_hz), + '-F', 'json', + '-F', 'log', + '-M', 'level', + '-M', 'noise:5', + '-Y', 'autolevel', + '-Y', 'ampest', + '-Y', 'minsnr=2.5', + ] + profile = (decode_profile or 'weather').strip().lower() + if profile == 'weather': + # Limit decoder set to weather/temperature/humidity/rain/wind + # protocols for better sensitivity and lower CPU load. + weather_protocol_ids = [ + 2, 3, 8, 12, 16, 18, 19, 20, 31, 32, 34, 40, 47, 50, 52, + 54, 55, 56, 57, 69, 73, 74, 75, 76, 78, 79, 85, 91, 92, + 108, 109, 111, 112, 113, 119, 120, 124, 127, 132, 133, + 134, 138, 141, 143, 144, 145, 146, 147, 152, 153, 157, + 158, 163, 165, 166, 170, 171, 172, 173, 175, 182, 183, + 184, 194, 195, 196, 205, 206, 213, 214, 215, 217, 219, + 221, 222, + ] + rtl433_cmd.extend(['-R', '0']) + for proto_id in weather_protocol_ids: + rtl433_cmd.extend(['-R', str(proto_id)]) + else: + profile = 'all' logger.info(f"SubGHz decode: {' '.join(hackrf_cmd)} | {' '.join(rtl433_cmd)}") try: - # Start hackrf_transfer (producer). stderr is consumed by a - # dedicated monitor thread so we can surface stream failures. - hackrf_proc = subprocess.Popen( - hackrf_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - bufsize=0, - ) - register_process(hackrf_proc) - - # Start rtl_433 (consumer) - rtl433_proc = subprocess.Popen( - rtl433_cmd, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - bufsize=0, - ) - register_process(rtl433_proc) + # Start hackrf_transfer (producer). stderr is consumed by a + # dedicated monitor thread so we can surface stream failures. + hackrf_proc = subprocess.Popen( + hackrf_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + register_process(hackrf_proc) - self._decode_hackrf_process = hackrf_proc - self._decode_process = rtl433_proc - self._decode_start_time = time.time() - self._decode_frequency_hz = frequency_hz - self._decode_sample_rate = stable_sample_rate - self._decode_stop = False - self._emit({'type': 'info', 'text': f'[decode] Profile: {profile}'}) - if requested_sample_rate != stable_sample_rate: - self._emit({ - 'type': 'info', - 'text': ( - f'[decode] Using {stable_sample_rate} sps ' - f'(requested {requested_sample_rate}) for stable live decode' - ), - }) - - # Buffered relay: hackrf stdout → queue → rtl_433 stdin - # with auto-restart when HackRF USB disconnects. - iq_queue: queue.Queue[bytes | None] = queue.Queue(maxsize=512) + # Start rtl_433 (consumer) + rtl433_proc = subprocess.Popen( + rtl433_cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + register_process(rtl433_proc) - threading.Thread( - target=self._hackrf_reader, - args=(hackrf_cmd, rtl433_proc, iq_queue), - daemon=True, - ).start() - threading.Thread( - target=self._monitor_decode_hackrf_stderr, - args=(hackrf_proc,), - daemon=True, - ).start() + self._decode_hackrf_process = hackrf_proc + self._decode_process = rtl433_proc + self._decode_start_time = time.time() + self._decode_frequency_hz = frequency_hz + self._decode_sample_rate = stable_sample_rate + self._decode_stop = False + self._emit({'type': 'info', 'text': f'[decode] Profile: {profile}'}) + if requested_sample_rate != stable_sample_rate: + self._emit({ + 'type': 'info', + 'text': ( + f'[decode] Using {stable_sample_rate} sps ' + f'(requested {requested_sample_rate}) for stable live decode' + ), + }) + + # Buffered relay: hackrf stdout → queue → rtl_433 stdin + # with auto-restart when HackRF USB disconnects. + iq_queue: queue.Queue[bytes | None] = queue.Queue(maxsize=512) + + threading.Thread( + target=self._hackrf_reader, + args=(hackrf_cmd, rtl433_proc, iq_queue), + daemon=True, + ).start() + threading.Thread( + target=self._monitor_decode_hackrf_stderr, + args=(hackrf_proc,), + daemon=True, + ).start() threading.Thread( target=self._rtl433_writer, @@ -1411,19 +1417,19 @@ class SubGhzManager: daemon=True, ).start() - self._emit({ - 'type': 'status', - 'mode': 'decode', - 'status': 'started', - 'frequency_hz': frequency_hz, - 'sample_rate': stable_sample_rate, - }) - - return { - 'status': 'started', - 'frequency_hz': frequency_hz, - 'sample_rate': stable_sample_rate, - } + self._emit({ + 'type': 'status', + 'mode': 'decode', + 'status': 'started', + 'frequency_hz': frequency_hz, + 'sample_rate': stable_sample_rate, + }) + + return { + 'status': 'started', + 'frequency_hz': frequency_hz, + 'sample_rate': stable_sample_rate, + } except FileNotFoundError as e: if self._decode_hackrf_process: @@ -1456,17 +1462,17 @@ class SubGhzManager: Uses os.read() on the raw fd to drain the pipe immediately (no Python buffering), minimising backpressure on the USB transfer path. """ - CHUNK = 65536 # 64 KB read size for lower latency - RESTART_DELAY = 0.15 # seconds before restart attempt - MAX_RESTARTS = 3600 # allow longer sessions - MAX_QUICK_RESTARTS = 6 - QUICK_RESTART_WINDOW = 20.0 - - restart_times: list[float] = [] - first_chunk = True - - restarts = 0 - while not self._decode_stop: + CHUNK = 65536 # 64 KB read size for lower latency + RESTART_DELAY = 0.15 # seconds before restart attempt + MAX_RESTARTS = 3600 # allow longer sessions + MAX_QUICK_RESTARTS = 6 + QUICK_RESTART_WINDOW = 20.0 + + restart_times: list[float] = [] + first_chunk = True + + restarts = 0 + while not self._decode_stop: if rtl433_proc.poll() is not None: break if self._decode_process is not rtl433_proc: @@ -1498,48 +1504,48 @@ class SubGhzManager: with self._lock: if self._decode_stop: break - try: - hackrf_proc = subprocess.Popen( - hackrf_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - bufsize=0, - ) - register_process(hackrf_proc) - self._decode_hackrf_process = hackrf_proc - src = hackrf_proc.stdout - restarts += 1 - now = time.time() - restart_times.append(now) - restart_times = [t for t in restart_times if (now - t) <= QUICK_RESTART_WINDOW] - if len(restart_times) >= MAX_QUICK_RESTARTS: - self._emit({ - 'type': 'error', - 'message': ( - 'HackRF stream is unstable (restarting repeatedly). ' - 'Try lower gain/sample-rate or reconnect the device.' - ), - }) - break - logger.info(f"hackrf_transfer restarted ({restarts})") - self._emit({'type': 'info', 'text': f'[decode] HackRF stream restarted ({restarts})'}) - threading.Thread( - target=self._monitor_decode_hackrf_stderr, - args=(hackrf_proc,), - daemon=True, - ).start() - except Exception as e: - logger.error(f"Failed to restart hackrf_transfer: {e}") - self._emit({ - 'type': 'error', - 'message': f'Failed to restart hackrf_transfer: {e}', - }) + try: + hackrf_proc = subprocess.Popen( + hackrf_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + register_process(hackrf_proc) + self._decode_hackrf_process = hackrf_proc + src = hackrf_proc.stdout + restarts += 1 + now = time.time() + restart_times.append(now) + restart_times = [t for t in restart_times if (now - t) <= QUICK_RESTART_WINDOW] + if len(restart_times) >= MAX_QUICK_RESTARTS: + self._emit({ + 'type': 'error', + 'message': ( + 'HackRF stream is unstable (restarting repeatedly). ' + 'Try lower gain/sample-rate or reconnect the device.' + ), + }) + break + logger.info(f"hackrf_transfer restarted ({restarts})") + self._emit({'type': 'info', 'text': f'[decode] HackRF stream restarted ({restarts})'}) + threading.Thread( + target=self._monitor_decode_hackrf_stderr, + args=(hackrf_proc,), + daemon=True, + ).start() + except Exception as e: + logger.error(f"Failed to restart hackrf_transfer: {e}") + self._emit({ + 'type': 'error', + 'message': f'Failed to restart hackrf_transfer: {e}', + }) break - if not src: - break - - # Use raw fd reads to drain the pipe without Python buffering. + if not src: + break + + # Use raw fd reads to drain the pipe without Python buffering. # This returns immediately with whatever bytes are available # (up to CHUNK), avoiding the backpressure that buffered reads # can cause when they block waiting for a full chunk. @@ -1551,19 +1557,19 @@ class SubGhzManager: except (OSError, ValueError, TypeError): break - try: - while not self._decode_stop: - data = os.read(fd, CHUNK) - if not data: - if hackrf_proc and hackrf_proc.poll() is not None: - self._emit({'type': 'info', 'text': '[decode] HackRF stream stopped'}) - break - if first_chunk: - first_chunk = False - self._emit({'type': 'info', 'text': '[decode] IQ source active'}) - try: - iq_queue.put_nowait(data) - except queue.Full: + try: + while not self._decode_stop: + data = os.read(fd, CHUNK) + if not data: + if hackrf_proc and hackrf_proc.poll() is not None: + self._emit({'type': 'info', 'text': '[decode] HackRF stream stopped'}) + break + if first_chunk: + first_chunk = False + self._emit({'type': 'info', 'text': '[decode] IQ source active'}) + try: + iq_queue.put_nowait(data) + except queue.Full: # Drop oldest chunk to prevent backpressure logger.debug("IQ queue full, dropping oldest chunk") try: @@ -1583,156 +1589,156 @@ class SubGhzManager: except queue.Full: pass - def _rtl433_writer( - self, - rtl433_proc: subprocess.Popen, - iq_queue: queue.Queue, - ) -> None: - """Drain the IQ queue into rtl_433 stdin.""" - dst = rtl433_proc.stdin - if not dst: - logger.error("rtl_433 stdin is None — cannot write IQ data") - return - - first_chunk = True - last_level = 0.0 - last_wave = 0.0 - last_spectrum = 0.0 - last_stats = time.time() - bytes_since_stats = 0 - LEVEL_INTERVAL = 0.35 - WAVE_INTERVAL = 0.5 - SPECTRUM_INTERVAL = 0.55 - STATS_INTERVAL = 6.0 - writes_since_flush = 0 - burst_active = False - burst_start = 0.0 - burst_last_high = 0.0 - burst_peak = 0 - BURST_ON_LEVEL = 9 - BURST_OFF_HOLD = 0.45 - BURST_MIN_DURATION = 0.05 - try: - while True: - try: - data = iq_queue.get(timeout=2.0) - except queue.Empty: + def _rtl433_writer( + self, + rtl433_proc: subprocess.Popen, + iq_queue: queue.Queue, + ) -> None: + """Drain the IQ queue into rtl_433 stdin.""" + dst = rtl433_proc.stdin + if not dst: + logger.error("rtl_433 stdin is None — cannot write IQ data") + return + + first_chunk = True + last_level = 0.0 + last_wave = 0.0 + last_spectrum = 0.0 + last_stats = time.time() + bytes_since_stats = 0 + LEVEL_INTERVAL = 0.35 + WAVE_INTERVAL = 0.5 + SPECTRUM_INTERVAL = 0.55 + STATS_INTERVAL = 6.0 + writes_since_flush = 0 + burst_active = False + burst_start = 0.0 + burst_last_high = 0.0 + burst_peak = 0 + BURST_ON_LEVEL = 9 + BURST_OFF_HOLD = 0.45 + BURST_MIN_DURATION = 0.05 + try: + while True: + try: + data = iq_queue.get(timeout=2.0) + except queue.Empty: if rtl433_proc.poll() is not None: break - continue - if data is None: - break - - now = time.time() - bytes_since_stats += len(data) - - if now - last_level >= LEVEL_INTERVAL: - level = self._compute_rx_level(data) - self._emit({'type': 'decode_level', 'level': level}) - if level >= BURST_ON_LEVEL: - burst_last_high = now - if not burst_active: - burst_active = True - burst_start = now - burst_peak = level - self._emit({ - 'type': 'rx_burst', - 'mode': 'decode', - 'event': 'start', - 'start_offset_s': round( - max(0.0, now - self._decode_start_time), 3 - ), - 'level': int(level), - }) - else: - burst_peak = max(burst_peak, level) - elif burst_active and (now - burst_last_high) >= BURST_OFF_HOLD: - duration = now - burst_start - if duration >= BURST_MIN_DURATION: - self._emit({ - 'type': 'rx_burst', - 'mode': 'decode', - 'event': 'end', - 'start_offset_s': round( - max(0.0, burst_start - self._decode_start_time), 3 - ), - 'duration_ms': int(duration * 1000), - 'peak_level': int(burst_peak), - }) - burst_active = False - burst_peak = 0 - last_level = now - - if now - last_wave >= WAVE_INTERVAL: - samples = self._extract_waveform(data, points=160) - if samples: - self._emit({'type': 'decode_waveform', 'samples': samples}) - last_wave = now - - if now - last_spectrum >= SPECTRUM_INTERVAL: - bins = self._compute_rx_spectrum(data, bins=128) - if bins: - self._emit({'type': 'decode_spectrum', 'bins': bins}) - last_spectrum = now - - # Pass HackRF cs8 IQ bytes through directly. - dst.write(data) - writes_since_flush += 1 - if writes_since_flush >= 8: - dst.flush() - writes_since_flush = 0 - - if first_chunk: - first_chunk = False - logger.info(f"IQ data flowing to rtl_433 ({len(data)} bytes)") - self._emit({ - 'type': 'info', - 'text': '[decode] Receiving IQ data from HackRF...', - }) - - elapsed = now - last_stats - if elapsed >= STATS_INTERVAL: - rate_kb = bytes_since_stats / elapsed / 1024 - self._emit({ - 'type': 'info', - 'text': f'[decode] IQ: {rate_kb:.0f} KB/s — listening for signals...', - }) - self._emit({ - 'type': 'decode_raw', - 'text': f'IQ stream active: {rate_kb:.0f} KB/s', - }) - bytes_since_stats = 0 - last_stats = now - - except (BrokenPipeError, OSError) as e: - logger.debug(f"rtl_433 writer pipe closed: {e}") - self._emit({'type': 'info', 'text': f'[decode] Writer pipe closed: {e}'}) - except Exception as e: - logger.error(f"rtl_433 writer error: {e}") - self._emit({'type': 'error', 'message': f'Decode writer error: {e}'}) - finally: - if burst_active: - duration = max(0.0, time.time() - burst_start) - if duration >= BURST_MIN_DURATION: - self._emit({ - 'type': 'rx_burst', - 'mode': 'decode', - 'event': 'end', - 'start_offset_s': round( - max(0.0, burst_start - self._decode_start_time), 3 - ), - 'duration_ms': int(duration * 1000), - 'peak_level': int(burst_peak), - }) - try: - dst.close() - except OSError: - pass + continue + if data is None: + break - def _read_decode_output(self) -> None: - process = self._decode_process - if not process or not process.stdout: - return + now = time.time() + bytes_since_stats += len(data) + + if now - last_level >= LEVEL_INTERVAL: + level = self._compute_rx_level(data) + self._emit({'type': 'decode_level', 'level': level}) + if level >= BURST_ON_LEVEL: + burst_last_high = now + if not burst_active: + burst_active = True + burst_start = now + burst_peak = level + self._emit({ + 'type': 'rx_burst', + 'mode': 'decode', + 'event': 'start', + 'start_offset_s': round( + max(0.0, now - self._decode_start_time), 3 + ), + 'level': int(level), + }) + else: + burst_peak = max(burst_peak, level) + elif burst_active and (now - burst_last_high) >= BURST_OFF_HOLD: + duration = now - burst_start + if duration >= BURST_MIN_DURATION: + self._emit({ + 'type': 'rx_burst', + 'mode': 'decode', + 'event': 'end', + 'start_offset_s': round( + max(0.0, burst_start - self._decode_start_time), 3 + ), + 'duration_ms': int(duration * 1000), + 'peak_level': int(burst_peak), + }) + burst_active = False + burst_peak = 0 + last_level = now + + if now - last_wave >= WAVE_INTERVAL: + samples = self._extract_waveform(data, points=160) + if samples: + self._emit({'type': 'decode_waveform', 'samples': samples}) + last_wave = now + + if now - last_spectrum >= SPECTRUM_INTERVAL: + bins = self._compute_rx_spectrum(data, bins=128) + if bins: + self._emit({'type': 'decode_spectrum', 'bins': bins}) + last_spectrum = now + + # Pass HackRF cs8 IQ bytes through directly. + dst.write(data) + writes_since_flush += 1 + if writes_since_flush >= 8: + dst.flush() + writes_since_flush = 0 + + if first_chunk: + first_chunk = False + logger.info(f"IQ data flowing to rtl_433 ({len(data)} bytes)") + self._emit({ + 'type': 'info', + 'text': '[decode] Receiving IQ data from HackRF...', + }) + + elapsed = now - last_stats + if elapsed >= STATS_INTERVAL: + rate_kb = bytes_since_stats / elapsed / 1024 + self._emit({ + 'type': 'info', + 'text': f'[decode] IQ: {rate_kb:.0f} KB/s — listening for signals...', + }) + self._emit({ + 'type': 'decode_raw', + 'text': f'IQ stream active: {rate_kb:.0f} KB/s', + }) + bytes_since_stats = 0 + last_stats = now + + except (BrokenPipeError, OSError) as e: + logger.debug(f"rtl_433 writer pipe closed: {e}") + self._emit({'type': 'info', 'text': f'[decode] Writer pipe closed: {e}'}) + except Exception as e: + logger.error(f"rtl_433 writer error: {e}") + self._emit({'type': 'error', 'message': f'Decode writer error: {e}'}) + finally: + if burst_active: + duration = max(0.0, time.time() - burst_start) + if duration >= BURST_MIN_DURATION: + self._emit({ + 'type': 'rx_burst', + 'mode': 'decode', + 'event': 'end', + 'start_offset_s': round( + max(0.0, burst_start - self._decode_start_time), 3 + ), + 'duration_ms': int(duration * 1000), + 'peak_level': int(burst_peak), + }) + try: + dst.close() + except OSError: + pass + + def _read_decode_output(self) -> None: + process = self._decode_process + if not process or not process.stdout: + return got_output = False try: for line in iter(process.stdout.readline, b''): @@ -1750,91 +1756,94 @@ class SubGhzManager: self._emit({'type': 'decode_raw', 'text': text}) except Exception as e: logger.error(f"Error reading decode output: {e}") - finally: - rc = process.poll() - unregister_process(process) - if rc is not None and rc != 0 and rc != -15: - logger.warning(f"rtl_433 exited with code {rc}") - self._emit({ - 'type': 'info', - 'text': f'[rtl_433] Exited with code {rc}', - }) - with self._lock: - if self._decode_process is process: - self._decode_process = None - self._decode_frequency_hz = 0 - self._decode_sample_rate = 0 - self._decode_start_time = 0 - self._emit({ - 'type': 'status', - 'mode': 'idle', - 'status': 'decode_stopped', - }) - - def _monitor_decode_hackrf_stderr(self, process: subprocess.Popen) -> None: - if not process or not process.stderr: - return - fatal_disconnect_emitted = False - try: - for line in iter(process.stderr.readline, b''): - text = line.decode('utf-8', errors='replace').strip() - if not text: - continue - logger.debug(f"[hackrf_decode] {text}") - lower = text.lower() - if ( - not fatal_disconnect_emitted - and ( - 'no such device' in lower - or 'device not found' in lower - or 'disconnected' in lower - ) - ): - fatal_disconnect_emitted = True - self._hackrf_device_cache = False - self._hackrf_device_cache_ts = time.time() - self._decode_stop = True - self._emit({ - 'type': 'error', - 'message': ( - 'HackRF disconnected during decode. ' - 'Reconnect the device, then press Start again.' - ), - }) - if ( - 'error' in lower - or 'usb' in lower - or 'overflow' in lower - or 'underflow' in lower - or 'failed' in lower - or 'couldn' in lower - or 'transfer' in lower - ): - self._emit({'type': 'info', 'text': f'[hackrf] {text}'}) - except Exception: - pass - - def _monitor_decode_stderr(self) -> None: - process = self._decode_process - if not process or not process.stderr: - return - decode_keywords = ( - 'pulse', 'sync', 'message', 'decoded', 'snr', 'rssi', - 'level', 'modulation', 'bitbuffer', 'symbol', 'short', - 'noise', 'detected', - ) - try: - for line in iter(process.stderr.readline, b''): - text = line.decode('utf-8', errors='replace').strip() - if text: - logger.debug(f"[rtl_433] {text}") - self._emit({'type': 'info', 'text': f'[rtl_433] {text}'}) - if any(k in text.lower() for k in decode_keywords): - self._emit({'type': 'decode_raw', 'text': text}) - except Exception: - pass + finally: + rc = process.poll() + unregister_process(process) + if rc is not None and rc != 0 and rc != -15: + logger.warning(f"rtl_433 exited with code {rc}") + self._emit({ + 'type': 'info', + 'text': f'[rtl_433] Exited with code {rc}', + }) + with self._lock: + if self._decode_process is process: + self._decode_process = None + self._decode_frequency_hz = 0 + self._decode_sample_rate = 0 + self._decode_start_time = 0 + self._emit({ + 'type': 'status', + 'mode': 'idle', + 'status': 'decode_stopped', + }) + + def _monitor_decode_hackrf_stderr(self, process: subprocess.Popen) -> None: + if not process or not process.stderr: + return + fatal_disconnect_emitted = False + try: + for line in iter(process.stderr.readline, b''): + text = line.decode('utf-8', errors='replace').strip() + if not text: + continue + logger.debug(f"[hackrf_decode] {text}") + lower = text.lower() + if ( + not fatal_disconnect_emitted + and ( + 'no such device' in lower + or 'device not found' in lower + or 'disconnected' in lower + ) + ): + fatal_disconnect_emitted = True + self._hackrf_device_cache = False + self._hackrf_device_cache_ts = time.time() + self._decode_stop = True + self._emit({ + 'type': 'error', + 'message': ( + 'HackRF disconnected during decode. ' + 'Reconnect the device, then press Start again.' + ), + }) + if ( + 'error' in lower + or 'usb' in lower + or 'overflow' in lower + or 'underflow' in lower + or 'failed' in lower + or 'couldn' in lower + or 'transfer' in lower + ): + self._emit({'type': 'info', 'text': f'[hackrf] {text}'}) + except Exception: + pass + + def _monitor_decode_stderr(self) -> None: + process = self._decode_process + if not process or not process.stderr: + return + decode_keywords = ( + 'pulse', 'sync', 'message', 'decoded', 'snr', 'rssi', + 'level', 'modulation', 'bitbuffer', 'symbol', 'short', + 'noise', 'detected', + ) + try: + for line in iter(process.stderr.readline, b''): + text = line.decode('utf-8', errors='replace').strip() + if text: + logger.debug(f"[rtl_433] {text}") + self._emit({'type': 'info', 'text': f'[rtl_433] {text}'}) + if any(k in text.lower() for k in decode_keywords): + self._emit({'type': 'decode_raw', 'text': text}) + except Exception: + pass def stop_decode(self) -> dict: + hackrf_proc: subprocess.Popen | None = None + rtl433_proc: subprocess.Popen | None = None + with self._lock: hackrf_running = ( self._decode_hackrf_process @@ -1852,43 +1861,50 @@ class SubGhzManager: # preventing it from spawning a new hackrf_transfer during cleanup. self._decode_stop = True - # Terminate upstream (hackrf_transfer) first, then consumer (rtl_433) + # Grab process refs and clear state inside lock + hackrf_proc = self._decode_hackrf_process + self._decode_hackrf_process = None + rtl433_proc = self._decode_process + self._decode_process = None + + self._decode_frequency_hz = 0 + self._decode_sample_rate = 0 + self._decode_start_time = 0 + + # Terminate outside lock — upstream (hackrf_transfer) first, then consumer (rtl_433) + if hackrf_proc: + safe_terminate(hackrf_proc) + unregister_process(hackrf_proc) + if rtl433_proc: + safe_terminate(rtl433_proc) + unregister_process(rtl433_proc) + + # Clean up any hackrf_transfer spawned during the race window + time.sleep(0.1) + race_proc: subprocess.Popen | None = None + with self._lock: if self._decode_hackrf_process: - safe_terminate(self._decode_hackrf_process) - unregister_process(self._decode_hackrf_process) + race_proc = self._decode_hackrf_process self._decode_hackrf_process = None + if race_proc: + safe_terminate(race_proc) + unregister_process(race_proc) - if self._decode_process: - safe_terminate(self._decode_process) - unregister_process(self._decode_process) - self._decode_process = None - - self._decode_frequency_hz = 0 - self._decode_sample_rate = 0 - self._decode_start_time = 0 + self._emit({ + 'type': 'status', + 'mode': 'idle', + 'status': 'stopped', + }) - # Clean up any hackrf_transfer spawned during the race window - time.sleep(0.1) - if self._decode_hackrf_process: - safe_terminate(self._decode_hackrf_process) - unregister_process(self._decode_hackrf_process) - self._decode_hackrf_process = None - - self._emit({ - 'type': 'status', - 'mode': 'idle', - 'status': 'stopped', - }) - - return {'status': 'stopped'} + return {'status': 'stopped'} # ------------------------------------------------------------------ # TRANSMIT (replay via hackrf_transfer -t) # ------------------------------------------------------------------ @staticmethod - def validate_tx_frequency(frequency_hz: int) -> str | None: - """Validate that a frequency is within allowed ISM TX bands. + def validate_tx_frequency(frequency_hz: int) -> str | None: + """Validate that a frequency is within allowed ISM TX bands. Returns None if valid, or an error message if invalid. """ @@ -1898,143 +1914,150 @@ class SubGhzManager: return None bands_str = ', '.join( f'{lo}-{hi} MHz' for lo, hi in SUBGHZ_TX_ALLOWED_BANDS - ) - return f'Frequency {freq_mhz:.3f} MHz is outside allowed TX bands: {bands_str}' - - @staticmethod - def _estimate_capture_duration_seconds(capture: SubGhzCapture, file_size: int) -> float: - if capture.duration_seconds and capture.duration_seconds > 0: - return float(capture.duration_seconds) - if capture.sample_rate > 0 and file_size > 0: - return float(file_size) / float(capture.sample_rate * 2) - return 0.0 - - def _cleanup_tx_temp_file(self) -> None: - path = self._tx_temp_file - self._tx_temp_file = None - if not path: - return - try: - if path.exists(): - path.unlink() - except OSError as exc: - logger.debug(f"Failed to remove TX temp file {path}: {exc}") + ) + return f'Frequency {freq_mhz:.3f} MHz is outside allowed TX bands: {bands_str}' - def transmit( - self, - capture_id: str, - tx_gain: int = 20, - max_duration: int = 10, - start_seconds: float | None = None, - duration_seconds: float | None = None, - device_serial: str | None = None, - ) -> dict: - with self._lock: + @staticmethod + def _estimate_capture_duration_seconds(capture: SubGhzCapture, file_size: int) -> float: + if capture.duration_seconds and capture.duration_seconds > 0: + return float(capture.duration_seconds) + if capture.sample_rate > 0 and file_size > 0: + return float(file_size) / float(capture.sample_rate * 2) + return 0.0 + + def _cleanup_tx_temp_file(self) -> None: + path = self._tx_temp_file + self._tx_temp_file = None + if not path: + return + try: + if path.exists(): + path.unlink() + except OSError as exc: + logger.debug(f"Failed to remove TX temp file {path}: {exc}") + + def transmit( + self, + capture_id: str, + tx_gain: int = 20, + max_duration: int = 10, + start_seconds: float | None = None, + duration_seconds: float | None = None, + device_serial: str | None = None, + ) -> dict: + # Pre-lock: tool availability & device detection (blocking I/O) + if not self.check_hackrf(): + return {'status': 'error', 'message': 'hackrf_transfer not found'} + device_err = self._require_hackrf_device() + if device_err: + return {'status': 'error', 'message': device_err} + + # Pre-lock: capture lookup, validation, and segment I/O (can be large) + capture = self._load_capture(capture_id) + if not capture: + return {'status': 'error', 'message': f'Capture not found: {capture_id}'} + + freq_error = self.validate_tx_frequency(capture.frequency_hz) + if freq_error: + return {'status': 'error', 'message': freq_error} + + tx_gain = max(SUBGHZ_TX_VGA_GAIN_MIN, min(SUBGHZ_TX_VGA_GAIN_MAX, tx_gain)) + max_duration = max(1, min(SUBGHZ_TX_MAX_DURATION, max_duration)) + + iq_path = self._captures_dir / capture.filename + if not iq_path.exists(): + return {'status': 'error', 'message': 'IQ file missing'} + + # Build segment file outside lock (potentially megabytes of read/write) + tx_path = iq_path + segment_info = None + segment_path_for_cleanup: Path | None = None + if start_seconds is not None or duration_seconds is not None: + try: + start_s = max(0.0, float(start_seconds or 0.0)) + except (TypeError, ValueError): + return {'status': 'error', 'message': 'Invalid start_seconds'} + try: + seg_s = None if duration_seconds is None else float(duration_seconds) + except (TypeError, ValueError): + return {'status': 'error', 'message': 'Invalid duration_seconds'} + if seg_s is not None and seg_s <= 0: + return {'status': 'error', 'message': 'duration_seconds must be greater than 0'} + + file_size = iq_path.stat().st_size + total_duration = self._estimate_capture_duration_seconds(capture, file_size) + if total_duration <= 0: + return {'status': 'error', 'message': 'Unable to determine capture duration for segment TX'} + if start_s >= total_duration: + return {'status': 'error', 'message': 'start_seconds is beyond end of capture'} + + end_s = total_duration if seg_s is None else min(total_duration, start_s + seg_s) + if end_s <= start_s: + return {'status': 'error', 'message': 'Selected segment is empty'} + + bytes_per_second = max(2, int(capture.sample_rate) * 2) + start_byte = int(start_s * bytes_per_second) & ~1 + end_byte = int(end_s * bytes_per_second) & ~1 + if end_byte <= start_byte: + return {'status': 'error', 'message': 'Selected segment is too short'} + + segment_size = end_byte - start_byte + segment_name = f".txseg_{capture.capture_id}_{uuid.uuid4().hex[:8]}.iq" + segment_path = self._captures_dir / segment_name + segment_path_for_cleanup = segment_path + try: + with open(iq_path, 'rb') as src, open(segment_path, 'wb') as dst: + src.seek(start_byte) + remaining = segment_size + while remaining > 0: + chunk = src.read(min(262144, remaining)) + if not chunk: + break + dst.write(chunk) + remaining -= len(chunk) + written = segment_path.stat().st_size if segment_path.exists() else 0 + except OSError as exc: + logger.error(f"Failed to build TX segment: {exc}") + return {'status': 'error', 'message': 'Failed to create TX segment'} + + if written < 2: + try: + segment_path.unlink(missing_ok=True) # type: ignore[arg-type] + except Exception: + pass + return {'status': 'error', 'message': 'Selected TX segment has no IQ data'} + + tx_path = segment_path + segment_info = { + 'start_seconds': round(start_s, 3), + 'duration_seconds': round(written / bytes_per_second, 3), + 'bytes': int(written), + } + + with self._lock: if self.active_mode != 'idle': + # Clean up segment file if we prepared one + if segment_path_for_cleanup: + try: + segment_path_for_cleanup.unlink(missing_ok=True) # type: ignore[arg-type] + except Exception: + pass return {'status': 'error', 'message': f'Already running: {self.active_mode}'} - if not self.check_hackrf(): - return {'status': 'error', 'message': 'hackrf_transfer not found'} - device_err = self._require_hackrf_device() - if device_err: - return {'status': 'error', 'message': device_err} + # Clear any orphaned temp segment from a previous TX attempt. + self._cleanup_tx_temp_file() + if segment_path_for_cleanup: + self._tx_temp_file = segment_path_for_cleanup - # Look up capture - capture = self._load_capture(capture_id) - if not capture: - return {'status': 'error', 'message': f'Capture not found: {capture_id}'} - - # Validate TX frequency - freq_error = self.validate_tx_frequency(capture.frequency_hz) - if freq_error: - return {'status': 'error', 'message': freq_error} - - # Enforce gain limit - tx_gain = max(SUBGHZ_TX_VGA_GAIN_MIN, min(SUBGHZ_TX_VGA_GAIN_MAX, tx_gain)) - - # Enforce max duration limit - max_duration = max(1, min(SUBGHZ_TX_MAX_DURATION, max_duration)) - - iq_path = self._captures_dir / capture.filename - if not iq_path.exists(): - return {'status': 'error', 'message': 'IQ file missing'} - - # Clear any orphaned temp segment from a previous TX attempt. - self._cleanup_tx_temp_file() - - tx_path = iq_path - segment_info = None - if start_seconds is not None or duration_seconds is not None: - try: - start_s = max(0.0, float(start_seconds or 0.0)) - except (TypeError, ValueError): - return {'status': 'error', 'message': 'Invalid start_seconds'} - try: - seg_s = None if duration_seconds is None else float(duration_seconds) - except (TypeError, ValueError): - return {'status': 'error', 'message': 'Invalid duration_seconds'} - if seg_s is not None and seg_s <= 0: - return {'status': 'error', 'message': 'duration_seconds must be greater than 0'} - - file_size = iq_path.stat().st_size - total_duration = self._estimate_capture_duration_seconds(capture, file_size) - if total_duration <= 0: - return {'status': 'error', 'message': 'Unable to determine capture duration for segment TX'} - if start_s >= total_duration: - return {'status': 'error', 'message': 'start_seconds is beyond end of capture'} - - end_s = total_duration if seg_s is None else min(total_duration, start_s + seg_s) - if end_s <= start_s: - return {'status': 'error', 'message': 'Selected segment is empty'} - - bytes_per_second = max(2, int(capture.sample_rate) * 2) - start_byte = int(start_s * bytes_per_second) & ~1 - end_byte = int(end_s * bytes_per_second) & ~1 - if end_byte <= start_byte: - return {'status': 'error', 'message': 'Selected segment is too short'} - - segment_size = end_byte - start_byte - segment_name = f".txseg_{capture.capture_id}_{uuid.uuid4().hex[:8]}.iq" - segment_path = self._captures_dir / segment_name - try: - with open(iq_path, 'rb') as src, open(segment_path, 'wb') as dst: - src.seek(start_byte) - remaining = segment_size - while remaining > 0: - chunk = src.read(min(262144, remaining)) - if not chunk: - break - dst.write(chunk) - remaining -= len(chunk) - written = segment_path.stat().st_size if segment_path.exists() else 0 - except OSError as exc: - logger.error(f"Failed to build TX segment: {exc}") - return {'status': 'error', 'message': 'Failed to create TX segment'} - - if written < 2: - try: - segment_path.unlink(missing_ok=True) # type: ignore[arg-type] - except Exception: - pass - return {'status': 'error', 'message': 'Selected TX segment has no IQ data'} - - tx_path = segment_path - self._tx_temp_file = segment_path - segment_info = { - 'start_seconds': round(start_s, 3), - 'duration_seconds': round(written / bytes_per_second, 3), - 'bytes': int(written), - } - - cmd = [ - 'hackrf_transfer', - '-t', str(tx_path), - '-f', str(capture.frequency_hz), - '-s', str(capture.sample_rate), - '-x', str(tx_gain), - ] - if device_serial: - cmd.extend(['-d', device_serial]) + cmd = [ + 'hackrf_transfer', + '-t', str(tx_path), + '-f', str(capture.frequency_hz), + '-s', str(capture.sample_rate), + '-x', str(tx_gain), + ] + if device_serial: + cmd.extend(['-d', device_serial]) logger.info(f"SubGHz TX: {' '.join(cmd)}") @@ -2061,95 +2084,100 @@ class SubGhzManager: daemon=True, ).start() - self._emit({ - 'type': 'tx_status', - 'status': 'transmitting', - 'capture_id': capture_id, - 'frequency_hz': capture.frequency_hz, - 'max_duration': max_duration, - 'segment': segment_info, - }) - - return { - 'status': 'transmitting', - 'capture_id': capture_id, - 'frequency_hz': capture.frequency_hz, - 'max_duration': max_duration, - 'segment': segment_info, - } + self._emit({ + 'type': 'tx_status', + 'status': 'transmitting', + 'capture_id': capture_id, + 'frequency_hz': capture.frequency_hz, + 'max_duration': max_duration, + 'segment': segment_info, + }) - except FileNotFoundError: - self._cleanup_tx_temp_file() - return {'status': 'error', 'message': 'hackrf_transfer not found'} - except Exception as e: - self._cleanup_tx_temp_file() - logger.error(f"Failed to start TX: {e}") - return {'status': 'error', 'message': str(e)} + return { + 'status': 'transmitting', + 'capture_id': capture_id, + 'frequency_hz': capture.frequency_hz, + 'max_duration': max_duration, + 'segment': segment_info, + } + + except FileNotFoundError: + self._cleanup_tx_temp_file() + return {'status': 'error', 'message': 'hackrf_transfer not found'} + except Exception as e: + self._cleanup_tx_temp_file() + logger.error(f"Failed to start TX: {e}") + return {'status': 'error', 'message': str(e)} def _tx_watchdog_kill(self) -> None: """Kill TX process when max duration is exceeded.""" logger.warning("SubGHz TX watchdog triggered - killing transmission") self.stop_transmit() - def _monitor_tx(self) -> None: - process = self._tx_process - if not process: - return - try: - returncode = process.wait() - except Exception: - returncode = -1 - with self._lock: - # Only emit if this is still the active TX process - if self._tx_process is not process: - return - unregister_process(process) - duration = time.time() - self._tx_start_time if self._tx_start_time else 0 - if returncode and returncode != 0 and returncode != -15: - # Non-zero exit (not SIGTERM) means unexpected death - logger.warning(f"hackrf_transfer TX exited unexpectedly (rc={returncode})") - self._emit({ - 'type': 'error', - 'message': f'Transmission failed (hackrf_transfer exited with code {returncode})', - }) - self._tx_process = None - self._tx_start_time = 0 - self._tx_capture_id = '' - self._emit({ - 'type': 'tx_status', - 'status': 'tx_complete', - 'duration_seconds': round(duration, 1), - }) - if self._tx_watchdog: - self._tx_watchdog.cancel() - self._tx_watchdog = None - self._cleanup_tx_temp_file() - - def stop_transmit(self) -> dict: - with self._lock: - if self._tx_watchdog: - self._tx_watchdog.cancel() - self._tx_watchdog = None - - if not self._tx_process or self._tx_process.poll() is not None: - self._cleanup_tx_temp_file() - return {'status': 'not_running'} - - safe_terminate(self._tx_process) - unregister_process(self._tx_process) - self._tx_process = None + def _monitor_tx(self) -> None: + process = self._tx_process + if not process: + return + try: + returncode = process.wait() + except Exception: + returncode = -1 + with self._lock: + # Only emit if this is still the active TX process + if self._tx_process is not process: + return + unregister_process(process) duration = time.time() - self._tx_start_time if self._tx_start_time else 0 - self._tx_start_time = 0 - self._tx_capture_id = '' - self._cleanup_tx_temp_file() - + if returncode and returncode != 0 and returncode != -15: + # Non-zero exit (not SIGTERM) means unexpected death + logger.warning(f"hackrf_transfer TX exited unexpectedly (rc={returncode})") + self._emit({ + 'type': 'error', + 'message': f'Transmission failed (hackrf_transfer exited with code {returncode})', + }) + self._tx_process = None + self._tx_start_time = 0 + self._tx_capture_id = '' self._emit({ 'type': 'tx_status', - 'status': 'tx_stopped', + 'status': 'tx_complete', 'duration_seconds': round(duration, 1), }) + if self._tx_watchdog: + self._tx_watchdog.cancel() + self._tx_watchdog = None + self._cleanup_tx_temp_file() - return {'status': 'stopped', 'duration_seconds': round(duration, 1)} + def stop_transmit(self) -> dict: + proc_to_terminate: subprocess.Popen | None = None + with self._lock: + if self._tx_watchdog: + self._tx_watchdog.cancel() + self._tx_watchdog = None + + if not self._tx_process or self._tx_process.poll() is not None: + self._cleanup_tx_temp_file() + return {'status': 'not_running'} + + proc_to_terminate = self._tx_process + self._tx_process = None + duration = time.time() - self._tx_start_time if self._tx_start_time else 0 + self._tx_start_time = 0 + self._tx_capture_id = '' + self._cleanup_tx_temp_file() + + # Terminate outside lock to avoid blocking other operations + if proc_to_terminate: + safe_terminate(proc_to_terminate) + unregister_process(proc_to_terminate) + + self._emit({ + 'type': 'tx_status', + 'status': 'tx_stopped', + 'duration_seconds': round(duration, 1), + }) + + return {'status': 'stopped', 'duration_seconds': round(duration, 1)} # ------------------------------------------------------------------ # SWEEP (hackrf_sweep) @@ -2162,16 +2190,23 @@ class SubGhzManager: bin_width: int = 100000, device_serial: str | None = None, ) -> dict: + # Pre-lock: tool availability & device detection (blocking I/O) + if not self.check_sweep(): + return {'status': 'error', 'message': 'hackrf_sweep not found'} + device_err = self._require_hackrf_device() + if device_err: + return {'status': 'error', 'message': device_err} + + # Wait for previous sweep thread to exit (blocking) before lock + if self._sweep_thread and self._sweep_thread.is_alive(): + self._sweep_thread.join(timeout=2.0) + if self._sweep_thread.is_alive(): + return {'status': 'error', 'message': 'Previous sweep still shutting down'} + with self._lock: if self.active_mode != 'idle': return {'status': 'error', 'message': f'Already running: {self.active_mode}'} - if not self.check_sweep(): - return {'status': 'error', 'message': 'hackrf_sweep not found'} - device_err = self._require_hackrf_device() - if device_err: - return {'status': 'error', 'message': device_err} - cmd = [ 'hackrf_sweep', '-f', f'{int(freq_start_mhz)}:{int(freq_end_mhz)}', @@ -2182,12 +2217,6 @@ class SubGhzManager: logger.info(f"SubGHz sweep: {' '.join(cmd)}") - # Wait for previous sweep thread to exit - if self._sweep_thread and self._sweep_thread.is_alive(): - self._sweep_thread.join(timeout=2.0) - if self._sweep_thread.is_alive(): - return {'status': 'error', 'message': 'Previous sweep still shutting down'} - try: self._sweep_process = subprocess.Popen( cmd, @@ -2313,15 +2342,20 @@ class SubGhzManager: logger.error(f"Error reading sweep output: {e}") def stop_sweep(self) -> dict: + proc_to_terminate: subprocess.Popen | None = None with self._lock: self._sweep_running = False if not self._sweep_process or self._sweep_process.poll() is not None: return {'status': 'not_running'} - safe_terminate(self._sweep_process) - unregister_process(self._sweep_process) + proc_to_terminate = self._sweep_process self._sweep_process = None + # Terminate outside lock to avoid blocking other operations + if proc_to_terminate: + safe_terminate(proc_to_terminate) + unregister_process(proc_to_terminate) + # Join sweep thread outside the lock to avoid blocking other operations if self._sweep_thread and self._sweep_thread.is_alive(): self._sweep_thread.join(timeout=2.0) @@ -2338,108 +2372,108 @@ class SubGhzManager: # CAPTURE LIBRARY # ------------------------------------------------------------------ - def list_captures(self) -> list[SubGhzCapture]: - captures = [] - for meta_path in sorted(self._captures_dir.glob('*.json'), reverse=True): - try: - data = json.loads(meta_path.read_text()) - bursts = data.get('bursts', []) - dominant_fingerprint = data.get('dominant_fingerprint', '') - if not dominant_fingerprint and isinstance(bursts, list): - fp_counts: dict[str, int] = {} - for burst in bursts: - fp = '' - if isinstance(burst, dict): - fp = str(burst.get('fingerprint') or '').strip() - if not fp: - continue - fp_counts[fp] = fp_counts.get(fp, 0) + 1 - if fp_counts: - dominant_fingerprint = max(fp_counts, key=fp_counts.get) - captures.append(SubGhzCapture( - capture_id=data['id'], - filename=data['filename'], - frequency_hz=data['frequency_hz'], + def list_captures(self) -> list[SubGhzCapture]: + captures = [] + for meta_path in sorted(self._captures_dir.glob('*.json'), reverse=True): + try: + data = json.loads(meta_path.read_text()) + bursts = data.get('bursts', []) + dominant_fingerprint = data.get('dominant_fingerprint', '') + if not dominant_fingerprint and isinstance(bursts, list): + fp_counts: dict[str, int] = {} + for burst in bursts: + fp = '' + if isinstance(burst, dict): + fp = str(burst.get('fingerprint') or '').strip() + if not fp: + continue + fp_counts[fp] = fp_counts.get(fp, 0) + 1 + if fp_counts: + dominant_fingerprint = max(fp_counts, key=fp_counts.get) + captures.append(SubGhzCapture( + capture_id=data['id'], + filename=data['filename'], + frequency_hz=data['frequency_hz'], sample_rate=data['sample_rate'], lna_gain=data.get('lna_gain', 0), vga_gain=data.get('vga_gain', 0), timestamp=data['timestamp'], - duration_seconds=data.get('duration_seconds', 0), - size_bytes=data.get('size_bytes', 0), - label=data.get('label', ''), - label_source=data.get('label_source', ''), - decoded_protocols=data.get('decoded_protocols', []), - bursts=bursts, - modulation_hint=data.get('modulation_hint', ''), - modulation_confidence=data.get('modulation_confidence', 0.0), - protocol_hint=data.get('protocol_hint', ''), - dominant_fingerprint=dominant_fingerprint, - fingerprint_group=data.get('fingerprint_group', ''), - fingerprint_group_size=data.get('fingerprint_group_size', 0), - trigger_enabled=bool(data.get('trigger_enabled', False)), - trigger_pre_seconds=data.get('trigger_pre_seconds', 0.0), - trigger_post_seconds=data.get('trigger_post_seconds', 0.0), - )) - except (json.JSONDecodeError, KeyError, OSError) as e: - logger.debug(f"Skipping invalid capture metadata {meta_path}: {e}") - - # Auto-group repeated fingerprints as likely same button/device clusters. - fingerprint_groups: dict[str, list[SubGhzCapture]] = {} - for capture in captures: - fp = (capture.dominant_fingerprint or '').strip().lower() - if not fp: - continue - fingerprint_groups.setdefault(fp, []).append(capture) - for fp, grouped in fingerprint_groups.items(): - group_id = f"SIG-{fp[:6].upper()}" - for capture in grouped: - capture.fingerprint_group = group_id - capture.fingerprint_group_size = len(grouped) - - return captures + duration_seconds=data.get('duration_seconds', 0), + size_bytes=data.get('size_bytes', 0), + label=data.get('label', ''), + label_source=data.get('label_source', ''), + decoded_protocols=data.get('decoded_protocols', []), + bursts=bursts, + modulation_hint=data.get('modulation_hint', ''), + modulation_confidence=data.get('modulation_confidence', 0.0), + protocol_hint=data.get('protocol_hint', ''), + dominant_fingerprint=dominant_fingerprint, + fingerprint_group=data.get('fingerprint_group', ''), + fingerprint_group_size=data.get('fingerprint_group_size', 0), + trigger_enabled=bool(data.get('trigger_enabled', False)), + trigger_pre_seconds=data.get('trigger_pre_seconds', 0.0), + trigger_post_seconds=data.get('trigger_post_seconds', 0.0), + )) + except (json.JSONDecodeError, KeyError, OSError) as e: + logger.debug(f"Skipping invalid capture metadata {meta_path}: {e}") + + # Auto-group repeated fingerprints as likely same button/device clusters. + fingerprint_groups: dict[str, list[SubGhzCapture]] = {} + for capture in captures: + fp = (capture.dominant_fingerprint or '').strip().lower() + if not fp: + continue + fingerprint_groups.setdefault(fp, []).append(capture) + for fp, grouped in fingerprint_groups.items(): + group_id = f"SIG-{fp[:6].upper()}" + for capture in grouped: + capture.fingerprint_group = group_id + capture.fingerprint_group_size = len(grouped) + + return captures def _load_capture(self, capture_id: str) -> SubGhzCapture | None: for meta_path in self._captures_dir.glob('*.json'): try: - data = json.loads(meta_path.read_text()) - if data.get('id') == capture_id: - bursts = data.get('bursts', []) - dominant_fingerprint = data.get('dominant_fingerprint', '') - if not dominant_fingerprint and isinstance(bursts, list): - fp_counts: dict[str, int] = {} - for burst in bursts: - fp = '' - if isinstance(burst, dict): - fp = str(burst.get('fingerprint') or '').strip() - if not fp: - continue - fp_counts[fp] = fp_counts.get(fp, 0) + 1 - if fp_counts: - dominant_fingerprint = max(fp_counts, key=fp_counts.get) - return SubGhzCapture( - capture_id=data['id'], - filename=data['filename'], - frequency_hz=data['frequency_hz'], - sample_rate=data['sample_rate'], + data = json.loads(meta_path.read_text()) + if data.get('id') == capture_id: + bursts = data.get('bursts', []) + dominant_fingerprint = data.get('dominant_fingerprint', '') + if not dominant_fingerprint and isinstance(bursts, list): + fp_counts: dict[str, int] = {} + for burst in bursts: + fp = '' + if isinstance(burst, dict): + fp = str(burst.get('fingerprint') or '').strip() + if not fp: + continue + fp_counts[fp] = fp_counts.get(fp, 0) + 1 + if fp_counts: + dominant_fingerprint = max(fp_counts, key=fp_counts.get) + return SubGhzCapture( + capture_id=data['id'], + filename=data['filename'], + frequency_hz=data['frequency_hz'], + sample_rate=data['sample_rate'], lna_gain=data.get('lna_gain', 0), vga_gain=data.get('vga_gain', 0), timestamp=data['timestamp'], - duration_seconds=data.get('duration_seconds', 0), - size_bytes=data.get('size_bytes', 0), - label=data.get('label', ''), - label_source=data.get('label_source', ''), - decoded_protocols=data.get('decoded_protocols', []), - bursts=bursts, - modulation_hint=data.get('modulation_hint', ''), - modulation_confidence=data.get('modulation_confidence', 0.0), - protocol_hint=data.get('protocol_hint', ''), - dominant_fingerprint=dominant_fingerprint, - fingerprint_group=data.get('fingerprint_group', ''), - fingerprint_group_size=data.get('fingerprint_group_size', 0), - trigger_enabled=bool(data.get('trigger_enabled', False)), - trigger_pre_seconds=data.get('trigger_pre_seconds', 0.0), - trigger_post_seconds=data.get('trigger_post_seconds', 0.0), - ) + duration_seconds=data.get('duration_seconds', 0), + size_bytes=data.get('size_bytes', 0), + label=data.get('label', ''), + label_source=data.get('label_source', ''), + decoded_protocols=data.get('decoded_protocols', []), + bursts=bursts, + modulation_hint=data.get('modulation_hint', ''), + modulation_confidence=data.get('modulation_confidence', 0.0), + protocol_hint=data.get('protocol_hint', ''), + dominant_fingerprint=dominant_fingerprint, + fingerprint_group=data.get('fingerprint_group', ''), + fingerprint_group_size=data.get('fingerprint_group_size', 0), + trigger_enabled=bool(data.get('trigger_enabled', False)), + trigger_pre_seconds=data.get('trigger_pre_seconds', 0.0), + trigger_post_seconds=data.get('trigger_post_seconds', 0.0), + ) except (json.JSONDecodeError, KeyError, OSError): continue return None @@ -2447,255 +2481,255 @@ class SubGhzManager: def get_capture(self, capture_id: str) -> SubGhzCapture | None: return self._load_capture(capture_id) - def get_capture_path(self, capture_id: str) -> Path | None: - capture = self._load_capture(capture_id) - if not capture: - return None - path = self._captures_dir / capture.filename - if path.exists(): - return path - return None - - def trim_capture( - self, - capture_id: str, - start_seconds: float | None = None, - duration_seconds: float | None = None, - label: str = '', - ) -> dict: - """Create a trimmed capture from a selected IQ time window. - - If start/duration are omitted and burst markers exist, the strongest burst - window is selected automatically with short padding. - """ - with self._lock: - if self.active_mode != 'idle': - return {'status': 'error', 'message': f'Already running: {self.active_mode}'} - - capture = self._load_capture(capture_id) - if not capture: - return {'status': 'error', 'message': f'Capture not found: {capture_id}'} - - src_path = self._captures_dir / capture.filename - if not src_path.exists(): - return {'status': 'error', 'message': 'IQ file missing'} - - try: - src_size = src_path.stat().st_size - except OSError: - return {'status': 'error', 'message': 'Unable to read capture file'} - if src_size < 2: - return {'status': 'error', 'message': 'Capture file has no IQ data'} - - total_duration = self._estimate_capture_duration_seconds(capture, src_size) - if total_duration <= 0: - return {'status': 'error', 'message': 'Unable to determine capture duration'} - - use_auto_burst = start_seconds is None and duration_seconds is None - auto_pad = 0.06 - if use_auto_burst: - bursts = capture.bursts if isinstance(capture.bursts, list) else [] - best_burst: dict | None = None - for burst in bursts: - if not isinstance(burst, dict): - continue - dur = float(burst.get('duration_seconds', 0.0) or 0.0) - if dur <= 0: - continue - if best_burst is None: - best_burst = burst - continue - best_peak = float(best_burst.get('peak_level', 0.0) or 0.0) - cur_peak = float(burst.get('peak_level', 0.0) or 0.0) - if cur_peak > best_peak: - best_burst = burst - elif cur_peak == best_peak and dur > float(best_burst.get('duration_seconds', 0.0) or 0.0): - best_burst = burst - - if best_burst: - burst_start = max(0.0, float(best_burst.get('start_seconds', 0.0) or 0.0)) - burst_dur = max(0.0, float(best_burst.get('duration_seconds', 0.0) or 0.0)) - start_seconds = max(0.0, burst_start - auto_pad) - end_seconds = min(total_duration, burst_start + burst_dur + auto_pad) - duration_seconds = max(0.0, end_seconds - start_seconds) - else: - return { - 'status': 'error', - 'message': 'No burst markers available. Select a segment manually before trimming.', - } - - try: - start_s = max(0.0, float(start_seconds or 0.0)) - except (TypeError, ValueError): - return {'status': 'error', 'message': 'Invalid start_seconds'} - try: - seg_s = None if duration_seconds is None else float(duration_seconds) - except (TypeError, ValueError): - return {'status': 'error', 'message': 'Invalid duration_seconds'} - - if seg_s is not None and seg_s <= 0: - return {'status': 'error', 'message': 'duration_seconds must be greater than 0'} - if start_s >= total_duration: - return {'status': 'error', 'message': 'start_seconds is beyond end of capture'} - - end_s = total_duration if seg_s is None else min(total_duration, start_s + seg_s) - if end_s <= start_s: - return {'status': 'error', 'message': 'Selected segment is empty'} - - bytes_per_second = max(2, int(capture.sample_rate) * 2) - start_byte = int(start_s * bytes_per_second) & ~1 - end_byte = int(end_s * bytes_per_second) & ~1 - if end_byte <= start_byte: - return {'status': 'error', 'message': 'Selected segment is too short'} - - trim_size = end_byte - start_byte - source_stem = Path(capture.filename).stem - trim_name = f"{source_stem}_trim_{datetime.now().strftime('%H%M%S')}_{uuid.uuid4().hex[:4]}.iq" - trim_path = self._captures_dir / trim_name - try: - with open(src_path, 'rb') as src, open(trim_path, 'wb') as dst: - src.seek(start_byte) - remaining = trim_size - while remaining > 0: - chunk = src.read(min(262144, remaining)) - if not chunk: - break - dst.write(chunk) - remaining -= len(chunk) - written = trim_path.stat().st_size if trim_path.exists() else 0 - except OSError as exc: - logger.error(f"Failed to create trimmed capture: {exc}") - try: - trim_path.unlink(missing_ok=True) # type: ignore[arg-type] - except Exception: - pass - return {'status': 'error', 'message': 'Failed to write trimmed capture'} - - if written < 2: - try: - trim_path.unlink(missing_ok=True) # type: ignore[arg-type] - except Exception: - pass - return {'status': 'error', 'message': 'Trimmed capture has no IQ data'} - - trimmed_duration = round(written / bytes_per_second, 3) - - adjusted_bursts: list[dict] = [] - if isinstance(capture.bursts, list): - for burst in capture.bursts: - if not isinstance(burst, dict): - continue - burst_start = max(0.0, float(burst.get('start_seconds', 0.0) or 0.0)) - burst_dur = max(0.0, float(burst.get('duration_seconds', 0.0) or 0.0)) - burst_end = burst_start + burst_dur - overlap_start = max(start_s, burst_start) - overlap_end = min(end_s, burst_end) - overlap_dur = overlap_end - overlap_start - if overlap_dur <= 0: - continue - adjusted = dict(burst) - adjusted['start_seconds'] = round(overlap_start - start_s, 3) - adjusted['duration_seconds'] = round(overlap_dur, 3) - adjusted_bursts.append(adjusted) - - dominant_fingerprint = '' - fp_counts: dict[str, int] = {} - for burst in adjusted_bursts: - fp = str(burst.get('fingerprint') or '').strip() - if not fp: - continue - fp_counts[fp] = fp_counts.get(fp, 0) + 1 - if fp_counts: - dominant_fingerprint = max(fp_counts, key=fp_counts.get) - elif capture.dominant_fingerprint: - dominant_fingerprint = capture.dominant_fingerprint - - modulation_hint = capture.modulation_hint - modulation_confidence = float(capture.modulation_confidence or 0.0) - if adjusted_bursts: - hint_totals: dict[str, float] = {} - for burst in adjusted_bursts: - hint = str(burst.get('modulation_hint') or '').strip() - conf = float(burst.get('modulation_confidence') or 0.0) - if not hint or hint.lower() == 'unknown': - continue - hint_totals[hint] = hint_totals.get(hint, 0.0) + max(0.05, conf) - if hint_totals: - modulation_hint = max(hint_totals, key=hint_totals.get) - total_score = max(sum(hint_totals.values()), 0.001) - modulation_confidence = min(0.98, hint_totals[modulation_hint] / total_score) - - protocol_hint = self._protocol_hint_from_capture( - capture.frequency_hz, - modulation_hint, - len(adjusted_bursts), - ) - - manual_label = str(label or '').strip() - if manual_label: - capture_label = manual_label - label_source = 'manual' - elif capture.label: - capture_label = f'{capture.label} (Trim)' - label_source = 'auto' - else: - capture_label = self._auto_capture_label( - capture.frequency_hz, - len(adjusted_bursts), - modulation_hint, - protocol_hint, - ) + ' (Trim)' - label_source = 'auto' - - trimmed_capture = SubGhzCapture( - capture_id=uuid.uuid4().hex[:12], - filename=trim_path.name, - frequency_hz=capture.frequency_hz, - sample_rate=capture.sample_rate, - lna_gain=capture.lna_gain, - vga_gain=capture.vga_gain, - timestamp=datetime.now(timezone.utc).isoformat(), - duration_seconds=round(trimmed_duration, 3), - size_bytes=int(written), - label=capture_label, - label_source=label_source, - decoded_protocols=list(capture.decoded_protocols), - bursts=adjusted_bursts, - modulation_hint=modulation_hint, - modulation_confidence=round(modulation_confidence, 3), - protocol_hint=protocol_hint, - dominant_fingerprint=dominant_fingerprint, - trigger_enabled=False, - trigger_pre_seconds=0.0, - trigger_post_seconds=0.0, - ) - - meta_path = trim_path.with_suffix('.json') - try: - meta_path.write_text(json.dumps(trimmed_capture.to_dict(), indent=2)) - except OSError as exc: - logger.error(f"Failed to write trimmed capture metadata: {exc}") - try: - trim_path.unlink(missing_ok=True) # type: ignore[arg-type] - except Exception: - pass - return {'status': 'error', 'message': 'Failed to write trimmed capture metadata'} - - return { - 'status': 'ok', - 'capture': trimmed_capture.to_dict(), - 'source_capture_id': capture_id, - 'segment': { - 'start_seconds': round(start_s, 3), - 'duration_seconds': round(trimmed_duration, 3), - 'auto_selected': bool(use_auto_burst), - }, - } - - def delete_capture(self, capture_id: str) -> bool: - capture = self._load_capture(capture_id) - if not capture: - return False + def get_capture_path(self, capture_id: str) -> Path | None: + capture = self._load_capture(capture_id) + if not capture: + return None + path = self._captures_dir / capture.filename + if path.exists(): + return path + return None + + def trim_capture( + self, + capture_id: str, + start_seconds: float | None = None, + duration_seconds: float | None = None, + label: str = '', + ) -> dict: + """Create a trimmed capture from a selected IQ time window. + + If start/duration are omitted and burst markers exist, the strongest burst + window is selected automatically with short padding. + """ + with self._lock: + if self.active_mode != 'idle': + return {'status': 'error', 'message': f'Already running: {self.active_mode}'} + + capture = self._load_capture(capture_id) + if not capture: + return {'status': 'error', 'message': f'Capture not found: {capture_id}'} + + src_path = self._captures_dir / capture.filename + if not src_path.exists(): + return {'status': 'error', 'message': 'IQ file missing'} + + try: + src_size = src_path.stat().st_size + except OSError: + return {'status': 'error', 'message': 'Unable to read capture file'} + if src_size < 2: + return {'status': 'error', 'message': 'Capture file has no IQ data'} + + total_duration = self._estimate_capture_duration_seconds(capture, src_size) + if total_duration <= 0: + return {'status': 'error', 'message': 'Unable to determine capture duration'} + + use_auto_burst = start_seconds is None and duration_seconds is None + auto_pad = 0.06 + if use_auto_burst: + bursts = capture.bursts if isinstance(capture.bursts, list) else [] + best_burst: dict | None = None + for burst in bursts: + if not isinstance(burst, dict): + continue + dur = float(burst.get('duration_seconds', 0.0) or 0.0) + if dur <= 0: + continue + if best_burst is None: + best_burst = burst + continue + best_peak = float(best_burst.get('peak_level', 0.0) or 0.0) + cur_peak = float(burst.get('peak_level', 0.0) or 0.0) + if cur_peak > best_peak: + best_burst = burst + elif cur_peak == best_peak and dur > float(best_burst.get('duration_seconds', 0.0) or 0.0): + best_burst = burst + + if best_burst: + burst_start = max(0.0, float(best_burst.get('start_seconds', 0.0) or 0.0)) + burst_dur = max(0.0, float(best_burst.get('duration_seconds', 0.0) or 0.0)) + start_seconds = max(0.0, burst_start - auto_pad) + end_seconds = min(total_duration, burst_start + burst_dur + auto_pad) + duration_seconds = max(0.0, end_seconds - start_seconds) + else: + return { + 'status': 'error', + 'message': 'No burst markers available. Select a segment manually before trimming.', + } + + try: + start_s = max(0.0, float(start_seconds or 0.0)) + except (TypeError, ValueError): + return {'status': 'error', 'message': 'Invalid start_seconds'} + try: + seg_s = None if duration_seconds is None else float(duration_seconds) + except (TypeError, ValueError): + return {'status': 'error', 'message': 'Invalid duration_seconds'} + + if seg_s is not None and seg_s <= 0: + return {'status': 'error', 'message': 'duration_seconds must be greater than 0'} + if start_s >= total_duration: + return {'status': 'error', 'message': 'start_seconds is beyond end of capture'} + + end_s = total_duration if seg_s is None else min(total_duration, start_s + seg_s) + if end_s <= start_s: + return {'status': 'error', 'message': 'Selected segment is empty'} + + bytes_per_second = max(2, int(capture.sample_rate) * 2) + start_byte = int(start_s * bytes_per_second) & ~1 + end_byte = int(end_s * bytes_per_second) & ~1 + if end_byte <= start_byte: + return {'status': 'error', 'message': 'Selected segment is too short'} + + trim_size = end_byte - start_byte + source_stem = Path(capture.filename).stem + trim_name = f"{source_stem}_trim_{datetime.now().strftime('%H%M%S')}_{uuid.uuid4().hex[:4]}.iq" + trim_path = self._captures_dir / trim_name + try: + with open(src_path, 'rb') as src, open(trim_path, 'wb') as dst: + src.seek(start_byte) + remaining = trim_size + while remaining > 0: + chunk = src.read(min(262144, remaining)) + if not chunk: + break + dst.write(chunk) + remaining -= len(chunk) + written = trim_path.stat().st_size if trim_path.exists() else 0 + except OSError as exc: + logger.error(f"Failed to create trimmed capture: {exc}") + try: + trim_path.unlink(missing_ok=True) # type: ignore[arg-type] + except Exception: + pass + return {'status': 'error', 'message': 'Failed to write trimmed capture'} + + if written < 2: + try: + trim_path.unlink(missing_ok=True) # type: ignore[arg-type] + except Exception: + pass + return {'status': 'error', 'message': 'Trimmed capture has no IQ data'} + + trimmed_duration = round(written / bytes_per_second, 3) + + adjusted_bursts: list[dict] = [] + if isinstance(capture.bursts, list): + for burst in capture.bursts: + if not isinstance(burst, dict): + continue + burst_start = max(0.0, float(burst.get('start_seconds', 0.0) or 0.0)) + burst_dur = max(0.0, float(burst.get('duration_seconds', 0.0) or 0.0)) + burst_end = burst_start + burst_dur + overlap_start = max(start_s, burst_start) + overlap_end = min(end_s, burst_end) + overlap_dur = overlap_end - overlap_start + if overlap_dur <= 0: + continue + adjusted = dict(burst) + adjusted['start_seconds'] = round(overlap_start - start_s, 3) + adjusted['duration_seconds'] = round(overlap_dur, 3) + adjusted_bursts.append(adjusted) + + dominant_fingerprint = '' + fp_counts: dict[str, int] = {} + for burst in adjusted_bursts: + fp = str(burst.get('fingerprint') or '').strip() + if not fp: + continue + fp_counts[fp] = fp_counts.get(fp, 0) + 1 + if fp_counts: + dominant_fingerprint = max(fp_counts, key=fp_counts.get) + elif capture.dominant_fingerprint: + dominant_fingerprint = capture.dominant_fingerprint + + modulation_hint = capture.modulation_hint + modulation_confidence = float(capture.modulation_confidence or 0.0) + if adjusted_bursts: + hint_totals: dict[str, float] = {} + for burst in adjusted_bursts: + hint = str(burst.get('modulation_hint') or '').strip() + conf = float(burst.get('modulation_confidence') or 0.0) + if not hint or hint.lower() == 'unknown': + continue + hint_totals[hint] = hint_totals.get(hint, 0.0) + max(0.05, conf) + if hint_totals: + modulation_hint = max(hint_totals, key=hint_totals.get) + total_score = max(sum(hint_totals.values()), 0.001) + modulation_confidence = min(0.98, hint_totals[modulation_hint] / total_score) + + protocol_hint = self._protocol_hint_from_capture( + capture.frequency_hz, + modulation_hint, + len(adjusted_bursts), + ) + + manual_label = str(label or '').strip() + if manual_label: + capture_label = manual_label + label_source = 'manual' + elif capture.label: + capture_label = f'{capture.label} (Trim)' + label_source = 'auto' + else: + capture_label = self._auto_capture_label( + capture.frequency_hz, + len(adjusted_bursts), + modulation_hint, + protocol_hint, + ) + ' (Trim)' + label_source = 'auto' + + trimmed_capture = SubGhzCapture( + capture_id=uuid.uuid4().hex[:12], + filename=trim_path.name, + frequency_hz=capture.frequency_hz, + sample_rate=capture.sample_rate, + lna_gain=capture.lna_gain, + vga_gain=capture.vga_gain, + timestamp=datetime.now(timezone.utc).isoformat(), + duration_seconds=round(trimmed_duration, 3), + size_bytes=int(written), + label=capture_label, + label_source=label_source, + decoded_protocols=list(capture.decoded_protocols), + bursts=adjusted_bursts, + modulation_hint=modulation_hint, + modulation_confidence=round(modulation_confidence, 3), + protocol_hint=protocol_hint, + dominant_fingerprint=dominant_fingerprint, + trigger_enabled=False, + trigger_pre_seconds=0.0, + trigger_post_seconds=0.0, + ) + + meta_path = trim_path.with_suffix('.json') + try: + meta_path.write_text(json.dumps(trimmed_capture.to_dict(), indent=2)) + except OSError as exc: + logger.error(f"Failed to write trimmed capture metadata: {exc}") + try: + trim_path.unlink(missing_ok=True) # type: ignore[arg-type] + except Exception: + pass + return {'status': 'error', 'message': 'Failed to write trimmed capture metadata'} + + return { + 'status': 'ok', + 'capture': trimmed_capture.to_dict(), + 'source_capture_id': capture_id, + 'segment': { + 'start_seconds': round(start_s, 3), + 'duration_seconds': round(trimmed_duration, 3), + 'auto_selected': bool(use_auto_burst), + }, + } + + def delete_capture(self, capture_id: str) -> bool: + capture = self._load_capture(capture_id) + if not capture: + return False iq_path = self._captures_dir / capture.filename meta_path = iq_path.with_suffix('.json') @@ -2710,88 +2744,88 @@ class SubGhzManager: logger.error(f"Failed to delete {path}: {e}") return deleted - def update_capture_label(self, capture_id: str, label: str) -> bool: - for meta_path in self._captures_dir.glob('*.json'): - try: - data = json.loads(meta_path.read_text()) - if data.get('id') == capture_id: - data['label'] = label - data['label_source'] = 'manual' if label else data.get('label_source', '') - meta_path.write_text(json.dumps(data, indent=2)) - return True - except (json.JSONDecodeError, KeyError, OSError): - continue - return False + def update_capture_label(self, capture_id: str, label: str) -> bool: + for meta_path in self._captures_dir.glob('*.json'): + try: + data = json.loads(meta_path.read_text()) + if data.get('id') == capture_id: + data['label'] = label + data['label_source'] = 'manual' if label else data.get('label_source', '') + meta_path.write_text(json.dumps(data, indent=2)) + return True + except (json.JSONDecodeError, KeyError, OSError): + continue + return False # ------------------------------------------------------------------ # STOP ALL # ------------------------------------------------------------------ - def stop_all(self) -> None: - """Stop any running SubGHz process.""" - rx_thread: threading.Thread | None = None - sweep_thread: threading.Thread | None = None - rx_file_handle: BinaryIO | None = None - - with self._lock: - self._decode_stop = True - self._sweep_running = False - self._rx_stop = True - - if self._tx_watchdog: - self._tx_watchdog.cancel() - self._tx_watchdog = None - - for proc_attr in ( - '_rx_process', - '_decode_hackrf_process', - '_decode_process', - '_tx_process', - '_sweep_process', - ): - process = getattr(self, proc_attr, None) - if process and process.poll() is None: - safe_terminate(process) - unregister_process(process) - setattr(self, proc_attr, None) - - rx_thread = self._rx_thread - self._rx_thread = None - sweep_thread = self._sweep_thread - self._sweep_thread = None - rx_file_handle = self._rx_file_handle - self._rx_file_handle = None - - self._cleanup_tx_temp_file() - self._rx_file = None - self._tx_capture_id = '' - - self._rx_start_time = 0 - self._rx_bytes_written = 0 - self._rx_bursts = [] - self._rx_trigger_enabled = False - self._rx_trigger_first_burst_start = None - self._rx_trigger_last_burst_end = None - self._rx_autostop_pending = False - self._rx_modulation_hint = '' - self._rx_modulation_confidence = 0.0 - self._rx_protocol_hint = '' - self._rx_fingerprint_counts = {} - self._tx_start_time = 0 - self._decode_start_time = 0 - self._decode_frequency_hz = 0 - self._decode_sample_rate = 0 - - if rx_thread and rx_thread.is_alive(): - rx_thread.join(timeout=1.5) - if sweep_thread and sweep_thread.is_alive(): - sweep_thread.join(timeout=1.5) - - if rx_file_handle: - try: - rx_file_handle.close() - except OSError: - pass + def stop_all(self) -> None: + """Stop any running SubGHz process.""" + rx_thread: threading.Thread | None = None + sweep_thread: threading.Thread | None = None + rx_file_handle: BinaryIO | None = None + + with self._lock: + self._decode_stop = True + self._sweep_running = False + self._rx_stop = True + + if self._tx_watchdog: + self._tx_watchdog.cancel() + self._tx_watchdog = None + + for proc_attr in ( + '_rx_process', + '_decode_hackrf_process', + '_decode_process', + '_tx_process', + '_sweep_process', + ): + process = getattr(self, proc_attr, None) + if process and process.poll() is None: + safe_terminate(process) + unregister_process(process) + setattr(self, proc_attr, None) + + rx_thread = self._rx_thread + self._rx_thread = None + sweep_thread = self._sweep_thread + self._sweep_thread = None + rx_file_handle = self._rx_file_handle + self._rx_file_handle = None + + self._cleanup_tx_temp_file() + self._rx_file = None + self._tx_capture_id = '' + + self._rx_start_time = 0 + self._rx_bytes_written = 0 + self._rx_bursts = [] + self._rx_trigger_enabled = False + self._rx_trigger_first_burst_start = None + self._rx_trigger_last_burst_end = None + self._rx_autostop_pending = False + self._rx_modulation_hint = '' + self._rx_modulation_confidence = 0.0 + self._rx_protocol_hint = '' + self._rx_fingerprint_counts = {} + self._tx_start_time = 0 + self._decode_start_time = 0 + self._decode_frequency_hz = 0 + self._decode_sample_rate = 0 + + if rx_thread and rx_thread.is_alive(): + rx_thread.join(timeout=1.5) + if sweep_thread and sweep_thread.is_alive(): + sweep_thread.join(timeout=1.5) + + if rx_file_handle: + try: + rx_file_handle.close() + except OSError: + pass # Global singleton diff --git a/utils/weather_sat.py b/utils/weather_sat.py index f30b36d..30ce6c6 100644 --- a/utils/weather_sat.py +++ b/utils/weather_sat.py @@ -173,7 +173,7 @@ class WeatherSatDecoder: self._current_frequency: float = 0.0 self._current_mode: str = '' self._capture_start_time: float = 0 - self._device_index: int = -1 + self._device_index: int = -1 self._capture_output_dir: Path | None = None self._on_complete_callback: Callable[[], None] | None = None self._capture_phase: str = 'idle' @@ -303,13 +303,13 @@ class WeatherSatDecoder: )) return False - self._current_satellite = satellite - self._current_frequency = sat_info['frequency'] - self._current_mode = sat_info['mode'] - self._device_index = -1 # Offline decode does not claim an SDR device - self._capture_start_time = time.time() - self._capture_phase = 'decoding' - self._stop_event.clear() + self._current_satellite = satellite + self._current_frequency = sat_info['frequency'] + self._current_mode = sat_info['mode'] + self._device_index = -1 # Offline decode does not claim an SDR device + self._capture_start_time = time.time() + self._capture_phase = 'decoding' + self._stop_event.clear() try: self._running = True @@ -363,6 +363,20 @@ class WeatherSatDecoder: Returns: True if started successfully """ + # Validate satellite BEFORE acquiring the lock + 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 + + # 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) + with self._lock: if self._running: return True @@ -375,15 +389,6 @@ class WeatherSatDecoder: )) 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 - self._current_satellite = satellite self._current_frequency = sat_info['frequency'] self._current_mode = sat_info['mode'] @@ -394,7 +399,7 @@ class WeatherSatDecoder: try: self._running = True - self._start_satdump(sat_info, device_index, gain, sample_rate, bias_t) + self._start_satdump(sat_info, device_index, gain, sample_rate, bias_t, source_id) logger.info( f"Weather satellite capture started: {satellite} " @@ -429,6 +434,7 @@ class WeatherSatDecoder: gain: float, sample_rate: int, bias_t: bool, + source_id: str | None = None, ) -> None: """Start SatDump live capture and decode.""" # Create timestamped output directory for this capture @@ -439,9 +445,9 @@ class WeatherSatDecoder: freq_hz = int(sat_info['frequency'] * 1_000_000) - # SatDump v1.2+ uses string source_id (device serial) not numeric index. - # Auto-detect serial by querying rtl_eeprom, fall back to string index. - source_id = self._resolve_device_id(device_index) + # 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', @@ -465,18 +471,18 @@ class WeatherSatDecoder: 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, - ) - register_process(self._process) - try: - os.close(slave_fd) # parent doesn't need the slave side - except OSError: - pass + self._process = subprocess.Popen( + cmd, + stdout=slave_fd, + stderr=slave_fd, + stdin=subprocess.DEVNULL, + close_fds=True, + ) + register_process(self._process) + try: + os.close(slave_fd) # parent doesn't need the slave side + except OSError: + pass # Check for early exit asynchronously (avoid blocking /start for 3s) def _check_early_exit(): @@ -568,18 +574,18 @@ class WeatherSatDecoder: 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, - ) - register_process(self._process) - try: - os.close(slave_fd) # parent doesn't need the slave side - except OSError: - pass + self._process = subprocess.Popen( + cmd, + stdout=slave_fd, + stderr=slave_fd, + stdin=subprocess.DEVNULL, + close_fds=True, + ) + register_process(self._process) + try: + os.close(slave_fd) # parent doesn't need the slave side + except OSError: + pass # For offline mode, don't check for early exit — file decoding # may complete very quickly and exit code 0 is normal success. @@ -812,20 +818,23 @@ class WeatherSatDecoder: # Signal watcher thread to do final scan and exit self._stop_event.set() - # Process ended — release resources - was_running = self._running - self._running = False + # Acquire lock when modifying shared state to avoid racing + # with stop() which may have already cleaned up. + with self._lock: + was_running = self._running + self._running = False + process = self._process elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0 if was_running: # Collect exit status (returncode is only set after poll/wait) - if self._process and self._process.returncode is None: + if process and process.returncode is None: try: - self._process.wait(timeout=5) + process.wait(timeout=5) except subprocess.TimeoutExpired: - self._process.kill() - self._process.wait() - retcode = self._process.returncode if self._process else None + process.kill() + process.wait() + retcode = process.returncode if process else None if retcode and retcode != 0: self._capture_phase = 'error' self._emit_progress(CaptureProgress( @@ -899,24 +908,24 @@ class WeatherSatDecoder: except OSError: continue - # Determine product type from filename/path - product = self._parse_product_name(filepath) - - # Copy image to main output dir for serving - safe_sat = re.sub(r'[^A-Za-z0-9_-]+', '_', self._current_satellite).strip('_') or 'satellite' - safe_stem = re.sub(r'[^A-Za-z0-9_-]+', '_', filepath.stem).strip('_') or 'image' - suffix = filepath.suffix.lower() - if suffix not in ('.png', '.jpg', '.jpeg'): - suffix = '.png' - serve_name = ( - f"{safe_sat}_{safe_stem}_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}" - f"{suffix}" - ) - serve_path = self._output_dir / serve_name - try: - shutil.copy2(filepath, serve_path) - except OSError: - # Copy failed — don't mark as known so it can be retried + # Determine product type from filename/path + product = self._parse_product_name(filepath) + + # Copy image to main output dir for serving + safe_sat = re.sub(r'[^A-Za-z0-9_-]+', '_', self._current_satellite).strip('_') or 'satellite' + safe_stem = re.sub(r'[^A-Za-z0-9_-]+', '_', filepath.stem).strip('_') or 'image' + suffix = filepath.suffix.lower() + if suffix not in ('.png', '.jpg', '.jpeg'): + suffix = '.png' + serve_name = ( + f"{safe_sat}_{safe_stem}_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}" + f"{suffix}" + ) + serve_path = self._output_dir / serve_name + try: + shutil.copy2(filepath, serve_path) + except OSError: + # Copy failed — don't mark as known so it can be retried continue # Only mark as known after successful copy @@ -960,12 +969,12 @@ class WeatherSatDecoder: return 'Multispectral Analysis' if 'thermal' in name or 'temp' in name: return 'Thermal' - if 'ndvi' in name: - return 'NDVI Vegetation' - if 'channel' in name or 'ch' in name: - match = re.search(r'(?:channel|ch)[\s_-]*(\d+)', name) - if match: - return f'Channel {match.group(1)}' + if 'ndvi' in name: + return 'NDVI Vegetation' + if 'channel' in name or 'ch' in name: + match = re.search(r'(?:channel|ch)[\s_-]*(\d+)', name) + if match: + return f'Channel {match.group(1)}' if 'avhrr' in name: return 'AVHRR' if 'msu' in name or 'mtvza' in name: @@ -986,14 +995,16 @@ class WeatherSatDecoder: self._running = False self._stop_event.set() self._close_pty() + process = self._process + self._process = None + elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0 + logger.info(f"Weather satellite capture stopped after {elapsed}s") + self._device_index = -1 - if self._process: - safe_terminate(self._process) - self._process = None - - elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0 - logger.info(f"Weather satellite capture stopped after {elapsed}s") - self._device_index = -1 + # Terminate outside the lock so stop() returns quickly + # and doesn't block start() or other lock acquisitions + if process: + safe_terminate(process) def get_images(self) -> list[WeatherSatImage]: """Get list of decoded images.""" @@ -1029,18 +1040,18 @@ class WeatherSatDecoder: sat_info = WEATHER_SATELLITES.get(satellite, {}) - image = WeatherSatImage( - filename=filepath.name, - path=filepath, - satellite=satellite, - mode=sat_info.get('mode', 'Unknown'), + image = WeatherSatImage( + filename=filepath.name, + path=filepath, + satellite=satellite, + mode=sat_info.get('mode', 'Unknown'), timestamp=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc), frequency=sat_info.get('frequency', 0.0), - size_bytes=stat.st_size, - product=self._parse_product_name(filepath), - ) - self._images.append(image) - known_filenames.add(filepath.name) + size_bytes=stat.st_size, + product=self._parse_product_name(filepath), + ) + self._images.append(image) + known_filenames.add(filepath.name) def delete_image(self, filename: str) -> bool: """Delete a decoded image.""" diff --git a/utils/wefax.py b/utils/wefax.py index 8dd9917..cfc1465 100644 --- a/utils/wefax.py +++ b/utils/wefax.py @@ -299,14 +299,7 @@ class WeFaxDecoder: try: self._running = True self._last_error = '' - self._start_pipeline() - - logger.info( - f"WeFax decoder started: {frequency_khz} kHz, " - f"station={station}, IOC={ioc}, LPM={lpm}" - ) - return True - + self._start_pipeline_spawn() except Exception as e: self._running = False self._last_error = str(e) @@ -317,8 +310,32 @@ class WeFaxDecoder: )) return False + # Health check sleep outside lock + try: + self._start_pipeline_health_check() + logger.info( + f"WeFax decoder started: {frequency_khz} kHz, " + f"station={station}, IOC={ioc}, LPM={lpm}" + ) + return True + except Exception as e: + with self._lock: + self._running = False + self._last_error = str(e) + logger.error(f"Failed to start WeFax decoder: {e}") + self._emit_progress(WeFaxProgress( + status='error', + message=str(e), + )) + return False + def _start_pipeline(self) -> None: """Start SDR FM demod subprocess in USB mode for WeFax.""" + self._start_pipeline_spawn() + self._start_pipeline_health_check() + + def _start_pipeline_spawn(self) -> None: + """Spawn the SDR FM demod subprocess. Must hold self._lock.""" try: sdr_type_enum = SDRType(self._sdr_type) except ValueError: @@ -361,21 +378,24 @@ class WeFaxDecoder: stderr=subprocess.PIPE, ) - # Post-spawn health check — catch immediate failures + def _start_pipeline_health_check(self) -> None: + """Post-spawn health check and decode thread start. Called outside lock.""" time.sleep(0.3) - if self._sdr_process.poll() is not None: - stderr_detail = '' - if self._sdr_process.stderr: - stderr_detail = self._sdr_process.stderr.read().decode( - errors='replace').strip() - rc = self._sdr_process.returncode - self._sdr_process = None - detail = stderr_detail.split('\n')[-1] if stderr_detail else f'exit code {rc}' - raise RuntimeError(f'{self._sdr_tool_name} failed: {detail}') - self._decode_thread = threading.Thread( - target=self._decode_audio_stream, daemon=True) - self._decode_thread.start() + with self._lock: + if self._sdr_process and self._sdr_process.poll() is not None: + stderr_detail = '' + if self._sdr_process.stderr: + stderr_detail = self._sdr_process.stderr.read().decode( + errors='replace').strip() + rc = self._sdr_process.returncode + self._sdr_process = None + detail = stderr_detail.split('\n')[-1] if stderr_detail else f'exit code {rc}' + raise RuntimeError(f'{self._sdr_tool_name} failed: {detail}') + + self._decode_thread = threading.Thread( + target=self._decode_audio_stream, daemon=True) + self._decode_thread.start() def _decode_audio_stream(self) -> None: """Read audio from SDR FM demod and decode WeFax images. From a50f77629c65b59ff4f21eccf78e028ce790f06b Mon Sep 17 00:00:00 2001 From: Smittix Date: Wed, 25 Feb 2026 21:18:35 +0000 Subject: [PATCH 48/52] Fix Morse mode button styling to match standard UI patterns Use run-btn/stop-btn classes and bottom placement instead of btn-primary/btn-danger in a flex section, and preset-btn class for band presets. Aligns with all other mode panels. Co-Authored-By: Claude Opus 4.6 --- static/js/modes/morse.js | 4 ++-- templates/partials/modes/morse.html | 31 ++++++++++------------------- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/static/js/modes/morse.js b/static/js/modes/morse.js index 1ba5acb..779e0db 100644 --- a/static/js/modes/morse.js +++ b/static/js/modes/morse.js @@ -347,8 +347,8 @@ var MorseMode = (function () { var indicator = document.getElementById('morseStatusIndicator'); var statusText = document.getElementById('morseStatusText'); - if (startBtn) startBtn.style.display = running ? 'none' : 'block'; - if (stopBtn) stopBtn.style.display = running ? 'block' : 'none'; + if (startBtn) startBtn.style.display = running ? 'none' : ''; + if (stopBtn) stopBtn.style.display = running ? '' : 'none'; if (indicator) { indicator.style.background = running ? '#00ff88' : 'var(--text-dim)'; diff --git a/templates/partials/modes/morse.html b/templates/partials/modes/morse.html index 00c9251..0b170ae 100644 --- a/templates/partials/modes/morse.html +++ b/templates/partials/modes/morse.html @@ -17,14 +17,14 @@
- - - - - - - - + + + + + + + +
@@ -103,18 +103,6 @@ - -
-
- - -
-
-

@@ -122,4 +110,7 @@ or an upconverter, plus an appropriate HF antenna (dipole, end-fed, or random wire).

+ + + From 935b7a4d9d37e2171af6e611dca1e742013081fc Mon Sep 17 00:00:00 2001 From: Smittix Date: Wed, 25 Feb 2026 21:49:16 +0000 Subject: [PATCH 49/52] Fix weather satellite mode returning false success on SatDump startup failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add synchronous startup verification after Popen() — sleep 0.5s and poll the process before returning to the caller. If SatDump exits immediately (missing device, bad args), raise RuntimeError with the actual error message instead of returning status: 'started'. Keep a shorter (2s) async backup check for slower failures. Also fix --source_id handling: omit the flag entirely when no serial number is found instead of passing "0" which SatDump may reject. Change start() and start_from_file() to return (bool, str|None) tuples so error messages propagate through to the HTTP response. Co-Authored-By: Claude Opus 4.6 --- routes/weather_sat.py | 96 ++++++++-------- tests/test_weather_sat_decoder.py | 29 +++-- tests/test_weather_sat_regressions.py | 3 +- tests/test_weather_sat_routes.py | 8 +- tests/test_weather_sat_scheduler.py | 6 +- utils/weather_sat.py | 160 ++++++++++++++++---------- utils/weather_sat_scheduler.py | 42 +++---- 7 files changed, 197 insertions(+), 147 deletions(-) diff --git a/routes/weather_sat.py b/routes/weather_sat.py index 023d9cf..71fa96c 100644 --- a/routes/weather_sat.py +++ b/routes/weather_sat.py @@ -26,48 +26,48 @@ logger = get_logger('intercept.weather_sat') weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat') # Queue for SSE progress streaming -_weather_sat_queue: queue.Queue = queue.Queue(maxsize=100) +_weather_sat_queue: queue.Queue = queue.Queue(maxsize=100) -def _progress_callback(progress: CaptureProgress) -> None: - """Callback to queue progress updates for SSE stream.""" - try: - _weather_sat_queue.put_nowait(progress.to_dict()) +def _progress_callback(progress: CaptureProgress) -> None: + """Callback to queue progress updates for SSE stream.""" + try: + _weather_sat_queue.put_nowait(progress.to_dict()) except queue.Full: try: _weather_sat_queue.get_nowait() _weather_sat_queue.put_nowait(progress.to_dict()) - except queue.Empty: - pass - - -def _release_weather_sat_device(device_index: int) -> None: - """Release an SDR device only if weather-sat currently owns it.""" - if device_index < 0: - return - - try: - import app as app_module - except ImportError: - return - - owner = None - get_status = getattr(app_module, 'get_sdr_device_status', None) - if callable(get_status): - try: - owner = get_status().get(device_index) - except Exception: - owner = None - - if owner and owner != 'weather_sat': - logger.debug( - 'Skipping SDR release for device %s owned by %s', - device_index, - owner, - ) - return - - app_module.release_sdr_device(device_index) + except queue.Empty: + pass + + +def _release_weather_sat_device(device_index: int) -> None: + """Release an SDR device only if weather-sat currently owns it.""" + if device_index < 0: + return + + try: + import app as app_module + except ImportError: + return + + owner = None + get_status = getattr(app_module, 'get_sdr_device_status', None) + if callable(get_status): + try: + owner = get_status().get(device_index) + except Exception: + owner = None + + if owner and owner != 'weather_sat': + logger.debug( + 'Skipping SDR release for device %s owned by %s', + device_index, + owner, + ) + return + + app_module.release_sdr_device(device_index) @weather_sat_bp.route('/status') @@ -178,15 +178,15 @@ def start_capture(): except queue.Empty: break - # Set callback and on-complete handler for SDR release - decoder.set_callback(_progress_callback) - - def _release_device(): - _release_weather_sat_device(device_index) + # Set callback and on-complete handler for SDR release + decoder.set_callback(_progress_callback) + + def _release_device(): + _release_weather_sat_device(device_index) decoder.set_on_complete(_release_device) - success = decoder.start( + success, error_msg = decoder.start( satellite=satellite, device_index=device_index, gain=gain, @@ -208,7 +208,7 @@ def start_capture(): _release_device() return jsonify({ 'status': 'error', - 'message': 'Failed to start capture' + 'message': error_msg or 'Failed to start capture' }), 500 @@ -310,7 +310,7 @@ def test_decode(): decoder.set_callback(_progress_callback) decoder.set_on_complete(None) - success = decoder.start_from_file( + success, error_msg = decoder.start_from_file( satellite=satellite, input_file=input_file, sample_rate=sample_rate, @@ -329,7 +329,7 @@ def test_decode(): else: return jsonify({ 'status': 'error', - 'message': 'Failed to start file decode' + 'message': error_msg or 'Failed to start file decode' }), 500 @@ -343,9 +343,9 @@ def stop_capture(): decoder = get_weather_sat_decoder() device_index = decoder.device_index - decoder.stop() - - _release_weather_sat_device(device_index) + decoder.stop() + + _release_weather_sat_device(device_index) return jsonify({'status': 'stopped'}) diff --git a/tests/test_weather_sat_decoder.py b/tests/test_weather_sat_decoder.py index 45f975d..8f8ebe2 100644 --- a/tests/test_weather_sat_decoder.py +++ b/tests/test_weather_sat_decoder.py @@ -73,9 +73,10 @@ class TestWeatherSatDecoder: callback = MagicMock() decoder.set_callback(callback) - success = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0) + success, error_msg = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0) assert success is False + assert error_msg is not None callback.assert_called() progress = callback.call_args[0][0] assert progress.status == 'error' @@ -88,7 +89,7 @@ class TestWeatherSatDecoder: callback = MagicMock() decoder.set_callback(callback) - success = decoder.start(satellite='FAKE-SAT', device_index=0, gain=40.0) + success, error_msg = decoder.start(satellite='FAKE-SAT', device_index=0, gain=40.0) assert success is False callback.assert_called() @@ -113,7 +114,7 @@ class TestWeatherSatDecoder: callback = MagicMock() decoder.set_callback(callback) - success = decoder.start( + success, error_msg = decoder.start( satellite='NOAA-18', device_index=0, gain=40.0, @@ -121,6 +122,7 @@ class TestWeatherSatDecoder: ) assert success is True + assert error_msg is None assert decoder.is_running is True assert decoder.current_satellite == 'NOAA-18' assert decoder.current_frequency == 137.9125 @@ -143,9 +145,10 @@ class TestWeatherSatDecoder: decoder = WeatherSatDecoder() decoder._running = True - success = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0) + success, error_msg = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0) assert success is True + assert error_msg is None mock_popen.assert_not_called() @patch('subprocess.Popen') @@ -160,9 +163,10 @@ class TestWeatherSatDecoder: callback = MagicMock() decoder.set_callback(callback) - success = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0) + success, error_msg = decoder.start(satellite='NOAA-18', device_index=0, gain=40.0) assert success is False + assert error_msg is not None assert decoder.is_running is False callback.assert_called() progress = callback.call_args[0][0] @@ -175,12 +179,13 @@ class TestWeatherSatDecoder: callback = MagicMock() decoder.set_callback(callback) - success = decoder.start_from_file( + success, error_msg = decoder.start_from_file( satellite='NOAA-18', input_file='data/test.wav', ) assert success is False + assert error_msg is not None callback.assert_called() @patch('subprocess.Popen') @@ -200,19 +205,21 @@ class TestWeatherSatDecoder: mock_pty.return_value = (10, 11) mock_process = MagicMock() + mock_process.poll.return_value = None # Process still running mock_popen.return_value = mock_process decoder = WeatherSatDecoder() callback = MagicMock() decoder.set_callback(callback) - success = decoder.start_from_file( + success, error_msg = decoder.start_from_file( satellite='NOAA-18', input_file='data/test.wav', sample_rate=1000000, ) assert success is True + assert error_msg is None assert decoder.is_running is True assert decoder.current_satellite == 'NOAA-18' @@ -236,7 +243,7 @@ class TestWeatherSatDecoder: callback = MagicMock() decoder.set_callback(callback) - success = decoder.start_from_file( + success, error_msg = decoder.start_from_file( satellite='NOAA-18', input_file='/etc/passwd', ) @@ -259,7 +266,7 @@ class TestWeatherSatDecoder: callback = MagicMock() decoder.set_callback(callback) - success = decoder.start_from_file( + success, error_msg = decoder.start_from_file( satellite='NOAA-18', input_file='data/missing.wav', ) @@ -426,12 +433,12 @@ class TestWeatherSatDecoder: @patch('subprocess.run') def test_resolve_device_id_fallback(self, mock_run): - """_resolve_device_id() should fall back to index string.""" + """_resolve_device_id() should return None when no serial found.""" mock_run.side_effect = FileNotFoundError serial = WeatherSatDecoder._resolve_device_id(0) - assert serial == '0' + assert serial is None def test_parse_product_name_rgb(self): """_parse_product_name() should identify RGB composite.""" diff --git a/tests/test_weather_sat_regressions.py b/tests/test_weather_sat_regressions.py index 737751a..66422c3 100644 --- a/tests/test_weather_sat_regressions.py +++ b/tests/test_weather_sat_regressions.py @@ -106,13 +106,14 @@ class TestWeatherSatDecoderRegressions: mock_resolve.return_value = resolved decoder = WeatherSatDecoder(output_dir=tmp_path / 'weather_sat_out') - success = decoder.start_from_file( + success, error_msg = decoder.start_from_file( satellite='METEOR-M2-3', input_file='data/weather_sat/samples/sample.wav', sample_rate=1_000_000, ) assert success is True + assert error_msg is None assert decoder.device_index == -1 mock_start.assert_called_once() diff --git a/tests/test_weather_sat_routes.py b/tests/test_weather_sat_routes.py index 7f13aca..f2c5672 100644 --- a/tests/test_weather_sat_routes.py +++ b/tests/test_weather_sat_routes.py @@ -73,7 +73,7 @@ class TestWeatherSatRoutes: mock_decoder = MagicMock() mock_decoder.is_running = False - mock_decoder.start.return_value = True + mock_decoder.start.return_value = (True, None) mock_get.return_value = mock_decoder payload = { @@ -233,7 +233,7 @@ class TestWeatherSatRoutes: mock_decoder = MagicMock() mock_decoder.is_running = False - mock_decoder.start.return_value = False + mock_decoder.start.return_value = (False, 'SatDump exited immediately (code 1)') mock_get.return_value = mock_decoder payload = {'satellite': 'NOAA-18'} @@ -246,7 +246,7 @@ class TestWeatherSatRoutes: assert response.status_code == 500 data = response.get_json() assert data['status'] == 'error' - assert 'Failed to start capture' in data['message'] + assert 'SatDump exited immediately' in data['message'] def test_test_decode_success(self, client): """POST /weather-sat/test-decode successfully starts file decode.""" @@ -262,7 +262,7 @@ class TestWeatherSatRoutes: mock_decoder = MagicMock() mock_decoder.is_running = False - mock_decoder.start_from_file.return_value = True + mock_decoder.start_from_file.return_value = (True, None) mock_get.return_value = mock_decoder payload = { diff --git a/tests/test_weather_sat_scheduler.py b/tests/test_weather_sat_scheduler.py index ccd4f88..6ac396a 100644 --- a/tests/test_weather_sat_scheduler.py +++ b/tests/test_weather_sat_scheduler.py @@ -546,7 +546,7 @@ class TestWeatherSatScheduler: mock_decoder = MagicMock() mock_decoder.is_running = False - mock_decoder.start.return_value = True + mock_decoder.start.return_value = (True, None) mock_get.return_value = mock_decoder mock_timer_instance = MagicMock() @@ -590,7 +590,7 @@ class TestWeatherSatScheduler: mock_decoder = MagicMock() mock_decoder.is_running = False - mock_decoder.start.return_value = False + mock_decoder.start.return_value = (False, 'Start failed') mock_get.return_value = mock_decoder pass_data = { @@ -798,7 +798,7 @@ class TestSchedulerIntegration: mock_decoder = MagicMock() mock_decoder.is_running = False - mock_decoder.start.return_value = True + mock_decoder.start.return_value = (True, None) mock_get_decoder.return_value = mock_decoder scheduler = WeatherSatScheduler() diff --git a/utils/weather_sat.py b/utils/weather_sat.py index 30ce6c6..29263a3 100644 --- a/utils/weather_sat.py +++ b/utils/weather_sat.py @@ -241,7 +241,7 @@ class WeatherSatDecoder: satellite: str, input_file: str | Path, sample_rate: int = DEFAULT_SAMPLE_RATE, - ) -> bool: + ) -> tuple[bool, str | None]: """Start weather satellite decode from a pre-recorded IQ/WAV file. No SDR hardware is required — SatDump runs in offline mode. @@ -252,28 +252,30 @@ class WeatherSatDecoder: sample_rate: Sample rate of the recording in Hz Returns: - True if started successfully + Tuple of (success, error_message). error_message is None on success. """ with self._lock: if self._running: - return True + return True, None if not self._decoder: logger.error("No weather satellite decoder available") + msg = 'SatDump not installed. Build from source or install via package manager.' self._emit_progress(CaptureProgress( status='error', - message='SatDump not installed. Build from source or install via package manager.' + message=msg, )) - return False + return False, msg sat_info = WEATHER_SATELLITES.get(satellite) if not sat_info: logger.error(f"Unknown satellite: {satellite}") + msg = f'Unknown satellite: {satellite}' self._emit_progress(CaptureProgress( status='error', - message=f'Unknown satellite: {satellite}' + message=msg, )) - return False + return False, msg input_path = Path(input_file) @@ -283,25 +285,28 @@ class WeatherSatDecoder: resolved = input_path.resolve() if not resolved.is_relative_to(allowed_base): logger.warning(f"Path traversal blocked in start_from_file: {input_file}") + msg = 'Input file must be under the data/ directory' self._emit_progress(CaptureProgress( status='error', - message='Input file must be under the data/ directory' + message=msg, )) - return False + return False, msg except (OSError, ValueError): + msg = 'Invalid file path' self._emit_progress(CaptureProgress( status='error', - message='Invalid file path' + message=msg, )) - return False + return False, msg if not input_path.is_file(): logger.error(f"Input file not found: {input_file}") + msg = 'Input file not found' self._emit_progress(CaptureProgress( status='error', - message='Input file not found' + message=msg, )) - return False + return False, msg self._current_satellite = satellite self._current_frequency = sat_info['frequency'] @@ -331,17 +336,18 @@ class WeatherSatDecoder: capture_phase='decoding', )) - return True + return True, None except Exception as e: self._running = False + error_msg = str(e) logger.error(f"Failed to start file decode: {e}") self._emit_progress(CaptureProgress( status='error', satellite=satellite, - message=str(e) + message=error_msg, )) - return False + return False, error_msg def start( self, @@ -350,7 +356,7 @@ class WeatherSatDecoder: gain: float = 40.0, sample_rate: int = DEFAULT_SAMPLE_RATE, bias_t: bool = False, - ) -> bool: + ) -> tuple[bool, str | None]: """Start weather satellite capture and decode. Args: @@ -361,17 +367,18 @@ class WeatherSatDecoder: bias_t: Enable bias-T power for LNA Returns: - True if started successfully + Tuple of (success, error_message). error_message is None on success. """ # Validate satellite BEFORE acquiring the lock sat_info = WEATHER_SATELLITES.get(satellite) if not sat_info: logger.error(f"Unknown satellite: {satellite}") + msg = f'Unknown satellite: {satellite}' self._emit_progress(CaptureProgress( status='error', - message=f'Unknown satellite: {satellite}' + message=msg, )) - return False + return False, msg # Resolve device ID BEFORE lock — this runs rtl_test which can # take up to 5s and has no side effects on instance state. @@ -379,15 +386,16 @@ class WeatherSatDecoder: with self._lock: if self._running: - return True + return True, None if not self._decoder: logger.error("No weather satellite decoder available") + msg = 'SatDump not installed. Build from source or install via package manager.' self._emit_progress(CaptureProgress( status='error', - message='SatDump not installed. Build from source or install via package manager.' + message=msg, )) - return False + return False, msg self._current_satellite = satellite self._current_frequency = sat_info['frequency'] @@ -415,17 +423,18 @@ class WeatherSatDecoder: capture_phase=self._capture_phase, )) - return True + return True, None except Exception as e: self._running = False + error_msg = str(e) logger.error(f"Failed to start weather satellite capture: {e}") self._emit_progress(CaptureProgress( status='error', satellite=satellite, - message=str(e) + message=error_msg, )) - return False + return False, error_msg def _start_satdump( self, @@ -457,9 +466,14 @@ class WeatherSatDecoder: '--samplerate', str(sample_rate), '--frequency', str(freq_hz), '--gain', str(int(gain)), - '--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') @@ -484,34 +498,28 @@ class WeatherSatDecoder: except OSError: pass - # Check for early exit asynchronously (avoid blocking /start for 3s) + # Synchronous startup check — catch immediate failures (bad args, + # missing device) before returning to the caller. + time.sleep(0.5) + if self._process.poll() is not None: + error_output = self._drain_pty_output(master_fd) + if error_output: + logger.error(f"SatDump output:\n{error_output}") + error_msg = self._extract_error(error_output, self._process.returncode) + raise RuntimeError(error_msg) + + # Backup async check for slower failures (e.g. device opens then + # fails after a second or two). def _check_early_exit(): - """Poll once after 3s; if SatDump died, emit an error event.""" - time.sleep(3) + """Poll once after 2s; if SatDump died, emit an error event.""" + time.sleep(2) process = self._process if process is None or process.poll() is None: return # still running or already cleaned up - retcode = process.returncode - output = b'' - try: - while True: - r, _, _ = select.select([master_fd], [], [], 0.1) - if not r: - break - chunk = os.read(master_fd, 4096) - if not chunk: - break - output += chunk - except OSError: - pass - output_str = output.decode('utf-8', errors='replace') - error_msg = f"SatDump exited immediately (code {retcode})" - if output_str: - for line in output_str.strip().splitlines(): - if 'error' in line.lower() or 'could not' in line.lower() or 'cannot' in line.lower(): - error_msg = line.strip() - break - logger.error(f"SatDump output:\n{output_str}") + error_output = self._drain_pty_output(master_fd) + if error_output: + logger.error(f"SatDump output:\n{error_output}") + error_msg = self._extract_error(error_output, process.returncode) self._emit_progress(CaptureProgress( status='error', satellite=self._current_satellite, @@ -587,9 +595,16 @@ class WeatherSatDecoder: except OSError: pass - # 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. + # Synchronous startup check — catch immediate failures (bad args, + # missing pipeline). For offline mode, exit code 0 is normal success + # (file decoding can finish quickly), so only raise on non-zero. + time.sleep(0.5) + if self._process.poll() is not None and self._process.returncode != 0: + error_output = self._drain_pty_output(master_fd) + if error_output: + logger.error(f"SatDump offline output:\n{error_output}") + error_msg = self._extract_error(error_output, self._process.returncode) + raise RuntimeError(error_msg) # Start reader thread to monitor output self._reader_thread = threading.Thread( @@ -622,12 +637,12 @@ class WeatherSatDecoder: return 'info' @staticmethod - def _resolve_device_id(device_index: int) -> str: + def _resolve_device_id(device_index: int) -> str | None: """Resolve RTL-SDR device index to serial number string for SatDump v1.2+. SatDump v1.2+ expects --source_id as a device serial string, not a - numeric index. Try to look up the serial via rtl_test, fall back to - the string representation of the index. + numeric index. Try to look up the serial via rtl_test, return None + if no serial can be found (caller should omit --source_id). """ try: result = subprocess.run( @@ -653,8 +668,35 @@ class WeatherSatDecoder: except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e: logger.debug(f"Could not detect device serial: {e}") - # Fall back to string index - return str(device_index) + # No serial found — caller should omit --source_id + return None + + @staticmethod + def _drain_pty_output(master_fd: int) -> str: + """Read all available output from a PTY master fd.""" + output = b'' + try: + while True: + r, _, _ = select.select([master_fd], [], [], 0.1) + if not r: + break + chunk = os.read(master_fd, 4096) + if not chunk: + break + output += chunk + except OSError: + pass + return output.decode('utf-8', errors='replace') + + @staticmethod + def _extract_error(output: str, returncode: int) -> str: + """Extract a meaningful error message from SatDump output.""" + if output: + for line in output.strip().splitlines(): + lower = line.lower() + if 'error' in lower or 'could not' in lower or 'cannot' in lower or 'failed' in lower: + return line.strip() + return f"SatDump exited immediately (code {returncode})" def _read_pty_lines(self): """Read lines from the PTY master fd, splitting on \\n and \\r. diff --git a/utils/weather_sat_scheduler.py b/utils/weather_sat_scheduler.py index c214090..2a88555 100644 --- a/utils/weather_sat_scheduler.py +++ b/utils/weather_sat_scheduler.py @@ -319,30 +319,30 @@ class WeatherSatScheduler: if self._progress_callback: decoder.set_callback(self._progress_callback) - def _release_device(): - try: - import app as app_module - owner = None - get_status = getattr(app_module, 'get_sdr_device_status', None) - if callable(get_status): - try: - owner = get_status().get(self._device) - except Exception: - owner = None - if owner and owner != 'weather_sat': - logger.debug( - "Skipping SDR release for device %s owned by %s", - self._device, - owner, - ) - return - app_module.release_sdr_device(self._device) - except ImportError: - pass + def _release_device(): + try: + import app as app_module + owner = None + get_status = getattr(app_module, 'get_sdr_device_status', None) + if callable(get_status): + try: + owner = get_status().get(self._device) + except Exception: + owner = None + if owner and owner != 'weather_sat': + logger.debug( + "Skipping SDR release for device %s owned by %s", + self._device, + owner, + ) + return + app_module.release_sdr_device(self._device) + except ImportError: + pass decoder.set_on_complete(lambda: self._on_capture_complete(sp, _release_device)) - success = decoder.start( + success, _error_msg = decoder.start( satellite=sp.satellite, device_index=self._device, gain=self._gain, From 8a46293e5c1cb8b840e55c5212a6042687a68641 Mon Sep 17 00:00:00 2001 From: Smittix Date: Wed, 25 Feb 2026 22:02:08 +0000 Subject: [PATCH 50/52] Fix DSC decoder for ITU-R M.493 compliance Correct modulation parameters (1200 bps, 2100/1300 Hz tones), replace invented format codes with the six ITU-defined specifiers {102, 112, 114, 116, 120, 123}, accept all valid EOS symbols (117, 122, 127), add parser validation (format, MMSI, raw field, telecommand range), and fix truthiness bugs that dropped zero-valued fields. Co-Authored-By: Claude Opus 4.6 --- tests/test_dsc.py | 276 +++++++++++++++++++++++++++++++++++------ utils/dsc/constants.py | 38 +++--- utils/dsc/decoder.py | 44 +++---- utils/dsc/parser.py | 69 +++++++++-- 4 files changed, 339 insertions(+), 88 deletions(-) diff --git a/tests/test_dsc.py b/tests/test_dsc.py index b47c9ae..9b83663 100644 --- a/tests/test_dsc.py +++ b/tests/test_dsc.py @@ -1,8 +1,8 @@ """Tests for DSC (Digital Selective Calling) utilities.""" import json + import pytest -from datetime import datetime class TestDSCParser: @@ -88,17 +88,15 @@ class TestDSCParser: assert get_distress_nature_text('invalid') == 'invalid' def test_get_format_text(self): - """Test format code to text conversion.""" + """Test format code to text conversion per ITU-R M.493.""" from utils.dsc.parser import get_format_text - assert get_format_text(100) == 'DISTRESS' assert get_format_text(102) == 'ALL_SHIPS' - assert get_format_text(106) == 'DISTRESS_ACK' - assert get_format_text(108) == 'DISTRESS_RELAY' assert get_format_text(112) == 'INDIVIDUAL' - assert get_format_text(116) == 'ROUTINE' - assert get_format_text(118) == 'SAFETY' - assert get_format_text(120) == 'URGENCY' + assert get_format_text(114) == 'INDIVIDUAL_ACK' + assert get_format_text(116) == 'GROUP' + assert get_format_text(120) == 'DISTRESS' + assert get_format_text(123) == 'ALL_SHIPS_URGENCY_SAFETY' def test_get_format_text_unknown(self): """Test format code returns unknown for invalid codes.""" @@ -107,6 +105,15 @@ class TestDSCParser: result = get_format_text(999) assert 'UNKNOWN' in result + def test_get_format_text_removed_codes(self): + """Test that non-ITU format codes are no longer recognized.""" + from utils.dsc.parser import get_format_text + + # These were previously defined but are not ITU-R M.493 specifiers + for code in [100, 104, 106, 108, 110, 118]: + result = get_format_text(code) + assert 'UNKNOWN' in result + def test_get_telecommand_text(self): """Test telecommand code to text conversion.""" from utils.dsc.parser import get_telecommand_text @@ -124,14 +131,13 @@ class TestDSCParser: assert get_category_priority('DISTRESS') == 0 assert get_category_priority('distress') == 0 - # Urgency is lower - assert get_category_priority('URGENCY') == 3 + # Urgency/safety + assert get_category_priority('ALL_SHIPS_URGENCY_SAFETY') == 2 - # Safety is lower still - assert get_category_priority('SAFETY') == 4 - - # Routine is lowest - assert get_category_priority('ROUTINE') == 5 + # Routine-level + assert get_category_priority('ALL_SHIPS') == 5 + assert get_category_priority('GROUP') == 5 + assert get_category_priority('INDIVIDUAL') == 5 # Unknown gets default high number assert get_category_priority('UNKNOWN') == 10 @@ -182,19 +188,20 @@ class TestDSCParser: assert classify_mmsi('812345678') == 'unknown' def test_parse_dsc_message_distress(self): - """Test parsing a distress message.""" + """Test parsing a distress message with ITU format 120.""" from utils.dsc.parser import parse_dsc_message raw = json.dumps({ 'type': 'dsc', - 'format': 100, + 'format': 120, 'source_mmsi': '232123456', - 'dest_mmsi': '000000000', + 'dest_mmsi': '002320001', 'category': 'DISTRESS', 'nature': 101, 'position': {'lat': 51.5, 'lon': -0.1}, 'telecommand1': 100, - 'timestamp': '2025-01-15T12:00:00Z' + 'timestamp': '2025-01-15T12:00:00Z', + 'raw': '120002032123456101100127', }) msg = parse_dsc_message(raw) @@ -210,26 +217,49 @@ class TestDSCParser: assert msg['is_critical'] is True assert msg['priority'] == 0 - def test_parse_dsc_message_routine(self): - """Test parsing a routine message.""" + def test_parse_dsc_message_group(self): + """Test parsing a group call message.""" from utils.dsc.parser import parse_dsc_message raw = json.dumps({ 'type': 'dsc', 'format': 116, 'source_mmsi': '366000001', - 'category': 'ROUTINE', - 'timestamp': '2025-01-15T12:00:00Z' + 'dest_mmsi': '023200001', + 'category': 'GROUP', + 'timestamp': '2025-01-15T12:00:00Z', + 'raw': '116023200001366000001117', }) msg = parse_dsc_message(raw) assert msg is not None - assert msg['category'] == 'ROUTINE' + assert msg['category'] == 'GROUP' assert msg['source_country'] == 'USA' assert msg['is_critical'] is False assert msg['priority'] == 5 + def test_parse_dsc_message_individual(self): + """Test parsing an individual call message.""" + from utils.dsc.parser import parse_dsc_message + + raw = json.dumps({ + 'type': 'dsc', + 'format': 112, + 'source_mmsi': '366000001', + 'dest_mmsi': '232123456', + 'category': 'INDIVIDUAL', + 'telecommand1': 100, + 'timestamp': '2025-01-15T12:00:00Z', + 'raw': '112232123456366000001100122', + }) + + msg = parse_dsc_message(raw) + + assert msg is not None + assert msg['category'] == 'INDIVIDUAL' + assert msg['is_critical'] is False + def test_parse_dsc_message_invalid_json(self): """Test parsing rejects invalid JSON.""" from utils.dsc.parser import parse_dsc_message @@ -262,6 +292,171 @@ class TestDSCParser: assert parse_dsc_message(None) is None assert parse_dsc_message(' ') is None + def test_parse_dsc_message_rejects_non_itu_format(self): + """Test parser rejects records with non-ITU format specifier.""" + from utils.dsc.parser import parse_dsc_message + + for bad_format in [100, 104, 106, 108, 110, 118, 999]: + raw = json.dumps({ + 'type': 'dsc', + 'format': bad_format, + 'source_mmsi': '232123456', + 'category': 'ROUTINE', + 'raw': '120232123456100127', + }) + assert parse_dsc_message(raw) is None, f"Format {bad_format} should be rejected" + + def test_parse_dsc_message_rejects_telecommand_out_of_range(self): + """Test parser rejects records with telecommand out of 100-127 range.""" + from utils.dsc.parser import parse_dsc_message + + raw = json.dumps({ + 'type': 'dsc', + 'format': 120, + 'source_mmsi': '232123456', + 'dest_mmsi': '002320001', + 'category': 'DISTRESS', + 'telecommand1': 200, + 'timestamp': '2025-01-15T12:00:00Z', + 'raw': '120002032123456200127', + }) + assert parse_dsc_message(raw) is None + + def test_parse_dsc_message_accepts_zero_telecommand(self): + """Test parser does not drop telecommand with value 100 (truthiness fix).""" + from utils.dsc.parser import parse_dsc_message + + raw = json.dumps({ + 'type': 'dsc', + 'format': 112, + 'source_mmsi': '232123456', + 'dest_mmsi': '366000001', + 'category': 'INDIVIDUAL', + 'telecommand1': 100, + 'telecommand2': 100, + 'timestamp': '2025-01-15T12:00:00Z', + 'raw': '112366000001232123456100100122', + }) + + msg = parse_dsc_message(raw) + assert msg is not None + assert msg['telecommand1'] == 100 + assert msg['telecommand2'] == 100 + + def test_parse_dsc_message_validates_raw_field(self): + """Test parser validates raw field structure.""" + from utils.dsc.parser import parse_dsc_message + + # Non-digit raw field + raw = json.dumps({ + 'type': 'dsc', + 'format': 120, + 'source_mmsi': '232123456', + 'category': 'DISTRESS', + 'raw': '12abc', + }) + assert parse_dsc_message(raw) is None + + # Raw field length not divisible by 3 + raw = json.dumps({ + 'type': 'dsc', + 'format': 120, + 'source_mmsi': '232123456', + 'category': 'DISTRESS', + 'raw': '1201', + }) + assert parse_dsc_message(raw) is None + + # Raw field with non-EOS last token + raw = json.dumps({ + 'type': 'dsc', + 'format': 120, + 'source_mmsi': '232123456', + 'category': 'DISTRESS', + 'raw': '120100', + }) + assert parse_dsc_message(raw) is None + + def test_parse_dsc_message_accepts_valid_eos_in_raw(self): + """Test parser accepts all three valid EOS values in raw field.""" + from utils.dsc.parser import parse_dsc_message + + for eos in [117, 122, 127]: + raw = json.dumps({ + 'type': 'dsc', + 'format': 120, + 'source_mmsi': '232123456', + 'dest_mmsi': '002320001', + 'category': 'DISTRESS', + 'timestamp': '2025-01-15T12:00:00Z', + 'raw': f'120002032123456{eos:03d}', + }) + msg = parse_dsc_message(raw) + assert msg is not None, f"EOS {eos} should be accepted" + + def test_parse_dsc_message_rejects_invalid_mmsi(self): + """Test parser rejects invalid MMSI values.""" + from utils.dsc.parser import parse_dsc_message + + # All-zeros MMSI + raw = json.dumps({ + 'type': 'dsc', + 'format': 120, + 'source_mmsi': '000000000', + 'category': 'DISTRESS', + 'raw': '120000000000127', + }) + assert parse_dsc_message(raw) is None + + # Short MMSI + raw = json.dumps({ + 'type': 'dsc', + 'format': 120, + 'source_mmsi': '12345', + 'category': 'DISTRESS', + 'raw': '120127', + }) + assert parse_dsc_message(raw) is None + + def test_parse_dsc_message_nature_zero_not_dropped(self): + """Test that nature code 0 is not dropped by truthiness check.""" + from utils.dsc.parser import parse_dsc_message + + raw = json.dumps({ + 'type': 'dsc', + 'format': 120, + 'source_mmsi': '232123456', + 'dest_mmsi': '002320001', + 'category': 'DISTRESS', + 'nature': 0, + 'timestamp': '2025-01-15T12:00:00Z', + 'raw': '120002032123456000127', + }) + + msg = parse_dsc_message(raw) + assert msg is not None + assert msg['nature_code'] == 0 + + def test_parse_dsc_message_channel_zero_not_dropped(self): + """Test that channel value 0 is not dropped by truthiness check.""" + from utils.dsc.parser import parse_dsc_message + + raw = json.dumps({ + 'type': 'dsc', + 'format': 112, + 'source_mmsi': '232123456', + 'dest_mmsi': '366000001', + 'category': 'INDIVIDUAL', + 'channel': 0, + 'telecommand1': 100, + 'timestamp': '2025-01-15T12:00:00Z', + 'raw': '112366000001232123456100122', + }) + + msg = parse_dsc_message(raw) + assert msg is not None + assert msg['channel'] == 0 + def test_format_dsc_for_display(self): """Test message formatting for display.""" from utils.dsc.parser import format_dsc_for_display @@ -413,17 +608,24 @@ class TestDSCConstants: """Tests for DSC constants.""" def test_format_codes_completeness(self): - """Test that all standard format codes are defined.""" + """Test that all ITU-R M.493 format specifiers are defined.""" from utils.dsc.constants import FORMAT_CODES - # ITU-R M.493 format codes - assert 100 in FORMAT_CODES # DISTRESS - assert 102 in FORMAT_CODES # ALL_SHIPS - assert 106 in FORMAT_CODES # DISTRESS_ACK - assert 112 in FORMAT_CODES # INDIVIDUAL - assert 116 in FORMAT_CODES # ROUTINE - assert 118 in FORMAT_CODES # SAFETY - assert 120 in FORMAT_CODES # URGENCY + # ITU-R M.493 format specifiers (and only these) + expected_keys = {102, 112, 114, 116, 120, 123} + assert set(FORMAT_CODES.keys()) == expected_keys + + def test_valid_format_specifiers_set(self): + """Test VALID_FORMAT_SPECIFIERS matches FORMAT_CODES keys.""" + from utils.dsc.constants import FORMAT_CODES, VALID_FORMAT_SPECIFIERS + + assert set(FORMAT_CODES.keys()) == VALID_FORMAT_SPECIFIERS + + def test_valid_eos_symbols(self): + """Test VALID_EOS contains the three ITU-defined EOS symbols.""" + from utils.dsc.constants import VALID_EOS + + assert {117, 122, 127} == VALID_EOS def test_distress_nature_codes_completeness(self): """Test that all distress nature codes are defined.""" @@ -458,13 +660,13 @@ class TestDSCConstants: assert VHF_CHANNELS[70] == 156.525 def test_dsc_modulation_parameters(self): - """Test DSC modulation constants.""" + """Test DSC modulation constants per ITU-R M.493.""" from utils.dsc.constants import ( DSC_BAUD_RATE, DSC_MARK_FREQ, DSC_SPACE_FREQ, ) - assert DSC_BAUD_RATE == 100 - assert DSC_MARK_FREQ == 1800 - assert DSC_SPACE_FREQ == 1200 + assert DSC_BAUD_RATE == 1200 + assert DSC_MARK_FREQ == 2100 + assert DSC_SPACE_FREQ == 1300 diff --git a/utils/dsc/constants.py b/utils/dsc/constants.py index 44d7ba2..d8372f9 100644 --- a/utils/dsc/constants.py +++ b/utils/dsc/constants.py @@ -14,30 +14,26 @@ from __future__ import annotations # ============================================================================= FORMAT_CODES = { - 100: 'DISTRESS', # All ships distress alert - 102: 'ALL_SHIPS', # All ships call - 104: 'GROUP', # Group call - 106: 'DISTRESS_ACK', # Distress acknowledgement - 108: 'DISTRESS_RELAY', # Distress relay - 110: 'GEOGRAPHIC', # Geographic area call - 112: 'INDIVIDUAL', # Individual call - 114: 'INDIVIDUAL_ACK', # Individual acknowledgement - 116: 'ROUTINE', # Routine call - 118: 'SAFETY', # Safety call - 120: 'URGENCY', # Urgency call + 102: 'ALL_SHIPS', # All ships call + 112: 'INDIVIDUAL', # Individual call + 114: 'INDIVIDUAL_ACK', # Individual acknowledgement + 116: 'GROUP', # Group call (including geographic area) + 120: 'DISTRESS', # Distress alert + 123: 'ALL_SHIPS_URGENCY_SAFETY', # All ships urgency/safety } +# Valid ITU-R M.493 format specifiers +VALID_FORMAT_SPECIFIERS = {102, 112, 114, 116, 120, 123} + +# Valid EOS (End of Sequence) symbols per ITU-R M.493 +VALID_EOS = {117, 122, 127} + # Category priority (lower = higher priority) CATEGORY_PRIORITY = { 'DISTRESS': 0, - 'DISTRESS_ACK': 1, - 'DISTRESS_RELAY': 2, - 'URGENCY': 3, - 'SAFETY': 4, - 'ROUTINE': 5, + 'ALL_SHIPS_URGENCY_SAFETY': 2, 'ALL_SHIPS': 5, 'GROUP': 5, - 'GEOGRAPHIC': 5, 'INDIVIDUAL': 5, 'INDIVIDUAL_ACK': 5, } @@ -453,11 +449,11 @@ VHF_CHANNELS = { # DSC Modulation Parameters # ============================================================================= -DSC_BAUD_RATE = 100 # 100 baud per ITU-R M.493 +DSC_BAUD_RATE = 1200 # 1200 bps per ITU-R M.493 -# FSK tone frequencies (Hz) -DSC_MARK_FREQ = 1800 # B (mark) - binary 1 -DSC_SPACE_FREQ = 1200 # Y (space) - binary 0 +# FSK tone frequencies (Hz) on 1700 Hz subcarrier +DSC_MARK_FREQ = 2100 # B (mark) - binary 1 +DSC_SPACE_FREQ = 1300 # Y (space) - binary 0 # Audio sample rate for decoding DSC_AUDIO_SAMPLE_RATE = 48000 diff --git a/utils/dsc/decoder.py b/utils/dsc/decoder.py index 51442bd..3f6399e 100644 --- a/utils/dsc/decoder.py +++ b/utils/dsc/decoder.py @@ -5,9 +5,9 @@ DSC (Digital Selective Calling) decoder. Decodes VHF DSC signals per ITU-R M.493. Reads 48kHz 16-bit signed audio from stdin (from rtl_fm) and outputs JSON messages to stdout. -DSC uses 100 baud FSK with: -- Mark (1): 1800 Hz -- Space (0): 1200 Hz +DSC uses 1200 bps FSK on a 1700 Hz subcarrier with: +- Mark (1): 2100 Hz +- Space (0): 1300 Hz Frame structure: 1. Dot pattern: 200 bits alternating 1/0 for synchronization @@ -42,6 +42,7 @@ from .constants import ( DSC_AUDIO_SAMPLE_RATE, FORMAT_CODES, DISTRESS_NATURE_CODES, + VALID_EOS, ) # Configure logging @@ -57,7 +58,7 @@ class DSCDecoder: """ DSC FSK decoder. - Demodulates 100 baud FSK audio and decodes DSC protocol. + Demodulates 1200 bps FSK audio and decodes DSC protocol. """ def __init__(self, sample_rate: int = DSC_AUDIO_SAMPLE_RATE): @@ -66,13 +67,13 @@ class DSCDecoder: self.samples_per_bit = sample_rate // self.baud_rate # FSK frequencies - self.mark_freq = DSC_MARK_FREQ # 1800 Hz = binary 1 - self.space_freq = DSC_SPACE_FREQ # 1200 Hz = binary 0 + self.mark_freq = DSC_MARK_FREQ # 2100 Hz = binary 1 + self.space_freq = DSC_SPACE_FREQ # 1300 Hz = binary 0 - # Bandpass filter for DSC band (1100-1900 Hz) + # Bandpass filter for DSC band (1100-2300 Hz) nyq = sample_rate / 2 low = 1100 / nyq - high = 1900 / nyq + high = 2300 / nyq self.bp_b, self.bp_a = scipy_signal.butter(4, [low, high], btype='band') # Build FSK correlators @@ -278,11 +279,11 @@ class DSCDecoder: if len(symbols) < 5: return None - # Look for EOS (End of Sequence) - symbol 127 + # Look for EOS (End of Sequence) - symbols 117, 122, or 127 eos_found = False eos_index = -1 for i, sym in enumerate(symbols): - if sym == 127: # EOS symbol + if sym in VALID_EOS: eos_found = True eos_index = i break @@ -337,20 +338,21 @@ class DSCDecoder: format_code = symbols[0] format_text = FORMAT_CODES.get(format_code, f'UNKNOWN-{format_code}') - # Determine category from format - category = 'ROUTINE' - if format_code == 100: + # Derive category from format specifier per ITU-R M.493 + if format_code == 120: category = 'DISTRESS' - elif format_code == 106: - category = 'DISTRESS_ACK' - elif format_code == 108: - category = 'DISTRESS_RELAY' - elif format_code == 118: - category = 'SAFETY' - elif format_code == 120: - category = 'URGENCY' + elif format_code == 123: + category = 'ALL_SHIPS_URGENCY_SAFETY' elif format_code == 102: category = 'ALL_SHIPS' + elif format_code == 116: + category = 'GROUP' + elif format_code == 112: + category = 'INDIVIDUAL' + elif format_code == 114: + category = 'INDIVIDUAL_ACK' + else: + category = FORMAT_CODES.get(format_code, 'UNKNOWN') # Decode MMSI from symbols 1-5 (destination/address) dest_mmsi = self._decode_mmsi(symbols[1:6]) diff --git a/utils/dsc/parser.py b/utils/dsc/parser.py index a8b6d25..30f7dd3 100644 --- a/utils/dsc/parser.py +++ b/utils/dsc/parser.py @@ -19,6 +19,8 @@ from .constants import ( TELECOMMAND_CODES, CATEGORY_PRIORITY, MID_COUNTRY_MAP, + VALID_FORMAT_SPECIFIERS, + VALID_EOS, ) logger = logging.getLogger('intercept.dsc.parser') @@ -139,13 +141,62 @@ def parse_dsc_message(raw_line: str) -> dict[str, Any] | None: if 'source_mmsi' not in data: return None + # ITU-R M.493 validation: format specifier must be valid + format_code = data.get('format') + if format_code not in VALID_FORMAT_SPECIFIERS: + logger.debug(f"Rejected DSC: invalid format specifier {format_code}") + return None + + # Validate MMSIs + source_mmsi = str(data.get('source_mmsi', '')) + if not validate_mmsi(source_mmsi): + logger.debug(f"Rejected DSC: invalid source MMSI {source_mmsi}") + return None + + dest_mmsi_val = data.get('dest_mmsi') + if dest_mmsi_val is not None: + dest_mmsi_str = str(dest_mmsi_val) + if not validate_mmsi(dest_mmsi_str): + logger.debug(f"Rejected DSC: invalid dest MMSI {dest_mmsi_str}") + return None + + # Validate raw field structure if present + raw = data.get('raw') + if raw is not None: + raw_str = str(raw) + if not re.match(r'^\d+$', raw_str): + logger.debug("Rejected DSC: raw field contains non-digits") + return None + if len(raw_str) % 3 != 0: + logger.debug("Rejected DSC: raw field length not divisible by 3") + return None + # Last 3-digit token must be a valid EOS symbol + if len(raw_str) >= 3: + last_token = int(raw_str[-3:]) + if last_token not in VALID_EOS: + logger.debug(f"Rejected DSC: raw EOS token {last_token} not valid") + return None + + # Validate telecommand values if present (must be 100-127) + for tc_field in ('telecommand1', 'telecommand2'): + tc_val = data.get(tc_field) + if tc_val is not None: + try: + tc_int = int(tc_val) + except (ValueError, TypeError): + logger.debug(f"Rejected DSC: invalid {tc_field} value {tc_val}") + return None + if tc_int < 100 or tc_int > 127: + logger.debug(f"Rejected DSC: {tc_field} {tc_int} out of range 100-127") + return None + # Build parsed message msg = { 'type': 'dsc_message', - 'source_mmsi': str(data.get('source_mmsi', '')), - 'dest_mmsi': str(data.get('dest_mmsi', '')) if data.get('dest_mmsi') else None, - 'format_code': data.get('format'), - 'format_text': get_format_text(data.get('format', 0)), + 'source_mmsi': source_mmsi, + 'dest_mmsi': str(data.get('dest_mmsi', '')) if data.get('dest_mmsi') is not None else None, + 'format_code': format_code, + 'format_text': get_format_text(format_code), 'category': data.get('category', 'UNKNOWN').upper(), 'timestamp': data.get('timestamp') or datetime.utcnow().isoformat(), } @@ -156,7 +207,7 @@ def parse_dsc_message(raw_line: str) -> dict[str, Any] | None: msg['source_country'] = country # Add distress nature if present - if 'nature' in data and data['nature']: + if data.get('nature') is not None: msg['nature_code'] = data['nature'] msg['nature_of_distress'] = get_distress_nature_text(data['nature']) @@ -173,16 +224,16 @@ def parse_dsc_message(raw_line: str) -> dict[str, Any] | None: pass # Add telecommand info - if 'telecommand1' in data and data['telecommand1']: + if data.get('telecommand1') is not None: msg['telecommand1'] = data['telecommand1'] msg['telecommand1_text'] = get_telecommand_text(data['telecommand1']) - if 'telecommand2' in data and data['telecommand2']: + if data.get('telecommand2') is not None: msg['telecommand2'] = data['telecommand2'] msg['telecommand2_text'] = get_telecommand_text(data['telecommand2']) # Add channel if present - if 'channel' in data and data['channel']: + if data.get('channel') is not None: msg['channel'] = data['channel'] # Add EOS (End of Sequence) info @@ -197,7 +248,7 @@ def parse_dsc_message(raw_line: str) -> dict[str, Any] | None: msg['priority'] = get_category_priority(msg['category']) # Mark if this is a critical alert - msg['is_critical'] = msg['category'] in ('DISTRESS', 'DISTRESS_ACK', 'DISTRESS_RELAY', 'URGENCY') + msg['is_critical'] = msg['category'] in ('DISTRESS', 'ALL_SHIPS_URGENCY_SAFETY') return msg From dc7c05b03f49968ffeccd9a0079c74ae503bb73d Mon Sep 17 00:00:00 2001 From: Smittix Date: Wed, 25 Feb 2026 23:26:47 +0000 Subject: [PATCH 51/52] Fix welcome dashboard jitter and refine Morse mode UI Fix "What's New" section shifting up/down on smaller screens (#157) by isolating the logo pulse animation to its own compositing layer, stabilizing the scrollbar gutter, and pinning the welcome container dimensions. Morse mode improvements: relocate scope and decoded output panels to the main content area, use shared SDR device controls, and reduce panel heights for better layout. Co-Authored-By: Claude Opus 4.6 --- static/css/index.css | 4 ++ static/css/modes/morse.css | 15 ++---- static/js/modes/morse.js | 35 +++++++++--- templates/index.html | 83 +++++++++++++++++++++-------- templates/partials/modes/morse.html | 18 ------- 5 files changed, 97 insertions(+), 58 deletions(-) diff --git a/static/css/index.css b/static/css/index.css index 0adb406..b10518b 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -206,6 +206,8 @@ body { max-width: 900px; z-index: 1; animation: welcomeFadeIn 0.8s ease-out; + max-height: calc(100vh - 40px); + overflow: hidden; } @keyframes welcomeFadeIn { @@ -232,6 +234,7 @@ body { .welcome-logo { animation: logoPulse 3s ease-in-out infinite; + will-change: filter; } @keyframes logoPulse { @@ -332,6 +335,7 @@ body { padding: 20px; max-height: calc(100vh - 300px); overflow-y: auto; + scrollbar-gutter: stable; } .changelog-release { diff --git a/static/css/modes/morse.css b/static/css/modes/morse.css index 4e46e09..844bf49 100644 --- a/static/css/modes/morse.css +++ b/static/css/modes/morse.css @@ -11,7 +11,7 @@ .morse-scope-container canvas { width: 100%; - height: 120px; + height: 80px; display: block; border-radius: 4px; } @@ -21,8 +21,8 @@ background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 6px; - padding: 16px; - min-height: 200px; + padding: 12px; + min-height: 120px; max-height: 400px; overflow-y: auto; font-family: var(--font-mono); @@ -30,7 +30,6 @@ line-height: 1.6; color: var(--text-primary); word-wrap: break-word; - flex: 1; } .morse-decoded-panel:empty::before { @@ -112,14 +111,6 @@ gap: 4px; } -/* Visuals container layout */ -#morseVisuals { - flex-direction: column; - gap: 12px; - padding: 16px; - height: 100%; -} - /* Word space styling */ .morse-word-space { display: inline; diff --git a/static/js/modes/morse.js b/static/js/modes/morse.js index 779e0db..b594bf6 100644 --- a/static/js/modes/morse.js +++ b/static/js/modes/morse.js @@ -67,11 +67,11 @@ var MorseMode = (function () { frequency: document.getElementById('morseFrequency').value || '14.060', gain: document.getElementById('morseGain').value || '0', ppm: document.getElementById('morsePPM').value || '0', - device: document.getElementById('morseDevice').value || '0', - sdr_type: document.getElementById('morseSdrType').value || 'rtlsdr', + device: document.getElementById('deviceSelect')?.value || '0', + sdr_type: document.getElementById('sdrTypeSelect')?.value || 'rtlsdr', tone_freq: document.getElementById('morseToneFreq').value || '700', wpm: document.getElementById('morseWpm').value || '15', - bias_t: document.getElementById('morseBiasT').checked, + bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false, }; fetch('/morse/start', { @@ -191,6 +191,8 @@ var MorseMode = (function () { // Update count var countEl = document.getElementById('morseCharCount'); if (countEl) countEl.textContent = state.charCount + ' chars'; + var barChars = document.getElementById('morseStatusBarChars'); + if (barChars) barChars.textContent = state.charCount + ' chars decoded'; } function appendSpace() { @@ -210,6 +212,8 @@ var MorseMode = (function () { state.decodedLog = []; var countEl = document.getElementById('morseCharCount'); if (countEl) countEl.textContent = '0 chars'; + var barChars = document.getElementById('morseStatusBarChars'); + if (barChars) barChars.textContent = '0 chars decoded'; } // ---- Scope canvas ---- @@ -221,21 +225,28 @@ var MorseMode = (function () { var dpr = window.devicePixelRatio || 1; var rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; - canvas.height = 120 * dpr; - canvas.style.height = '120px'; + canvas.height = 80 * dpr; + canvas.style.height = '80px'; scopeCtx = canvas.getContext('2d'); scopeCtx.scale(dpr, dpr); scopeHistory = []; + var toneLabel = document.getElementById('morseScopeToneLabel'); + var threshLabel = document.getElementById('morseScopeThreshLabel'); + function draw() { if (!scopeCtx) return; var w = rect.width; - var h = 120; + var h = 80; - scopeCtx.fillStyle = '#0a0e14'; + scopeCtx.fillStyle = '#050510'; scopeCtx.fillRect(0, 0, w, h); + // Update header labels + if (toneLabel) toneLabel.textContent = scopeToneOn ? 'ON' : '--'; + if (threshLabel) threshLabel.textContent = scopeThreshold > 0 ? Math.round(scopeThreshold) : '--'; + if (scopeHistory.length === 0) { scopeAnim = requestAnimationFrame(draw); return; @@ -356,6 +367,16 @@ var MorseMode = (function () { if (statusText) { statusText.textContent = running ? 'Listening' : 'Standby'; } + + // Toggle scope and output panels (pager/sensor pattern) + var scopePanel = document.getElementById('morseScopePanel'); + var outputPanel = document.getElementById('morseOutputPanel'); + if (scopePanel) scopePanel.style.display = running ? 'block' : 'none'; + if (outputPanel) outputPanel.style.display = running ? 'block' : 'none'; + + var scopeStatus = document.getElementById('morseScopeStatusLabel'); + if (scopeStatus) scopeStatus.textContent = running ? 'ACTIVE' : 'IDLE'; + if (scopeStatus) scopeStatus.style.color = running ? '#0f0' : '#444'; } function setFreq(mhz) { diff --git a/templates/index.html b/templates/index.html index bd65eb3..019da83 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3008,24 +3008,6 @@ - -