diff --git a/README.md b/README.md
index b4d27b6..be8a5a4 100644
--- a/README.md
+++ b/README.md
@@ -50,12 +50,38 @@ Support the developer of this open-source project
- **Meshtastic** - LoRa mesh network integration
- **Space Weather** - Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL (no SDR required)
- **Spy Stations** - Number stations and diplomatic HF network database
-- **Remote Agents** - Distributed SIGINT with remote sensor nodes
-- **Offline Mode** - Bundled assets for air-gapped/field deployments
-
----
-
-## Installation / Debian / Ubuntu / MacOS
+- **Remote Agents** - Distributed SIGINT with remote sensor nodes
+- **Offline Mode** - Bundled assets for air-gapped/field deployments
+
+---
+
+## CW / Morse Decoder Notes
+
+Live backend:
+- Uses `rtl_fm` piped into `multimon-ng` (`MORSE_CW`) for real-time decode.
+
+Recommended baseline settings:
+- **Tone**: `700 Hz`
+- **Bandwidth**: `200 Hz` (use `100 Hz` for crowded bands, `400 Hz` for drifting signals)
+- **Threshold Mode**: `Auto`
+- **WPM Mode**: `Auto`
+
+Auto Tone Track behavior:
+- Continuously measures nearby tone energy around the configured CW pitch.
+- Steers the detector toward the strongest valid CW tone when signal-to-noise is sufficient.
+- Use **Hold Tone Lock** to freeze tracking once the desired signal is centered.
+
+Troubleshooting (no decode / noisy decode):
+- Confirm demod path is **USB/CW-compatible** and frequency is tuned correctly.
+- If multiple SDRs are connected and the selected one has no PCM output, Morse startup now auto-tries other detected SDR devices and reports the active device/serial in status logs.
+- Match **tone** and **bandwidth** to the actual sidetone/pitch.
+- Try **Threshold Auto** first; if needed, switch to manual threshold and recalibrate.
+- Use **Reset/Calibrate** after major frequency or band condition changes.
+- Raise **Minimum Signal Gate** to suppress random noise keying.
+
+---
+
+## Installation / Debian / Ubuntu / MacOS
**1. Clone and run:**
```bash
diff --git a/requirements.txt b/requirements.txt
index cb05331..6b9fdfb 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -44,3 +44,6 @@ cryptography>=41.0.0
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
flask-sock
websocket-client>=1.6.0
+
+# System health monitoring (optional - graceful fallback if unavailable)
+psutil>=5.9.0
diff --git a/routes/__init__.py b/routes/__init__.py
index e1e1319..3a00b91 100644
--- a/routes/__init__.py
+++ b/routes/__init__.py
@@ -30,6 +30,7 @@ def register_blueprints(app):
from .sstv import sstv_bp
from .sstv_general import sstv_general_bp
from .subghz import subghz_bp
+ from .system import system_bp
from .tscm import init_tscm_state, tscm_bp
from .updater import updater_bp
from .vdl2 import vdl2_bp
@@ -75,6 +76,7 @@ def register_blueprints(app):
app.register_blueprint(signalid_bp) # External signal ID enrichment
app.register_blueprint(wefax_bp) # WeFax HF weather fax decoder
app.register_blueprint(morse_bp) # CW/Morse code decoder
+ app.register_blueprint(system_bp) # System health monitoring
# Initialize TSCM state with queue and lock from app
import app as app_module
diff --git a/routes/aprs.py b/routes/aprs.py
index a6e29d6..606f3a4 100644
--- a/routes/aprs.py
+++ b/routes/aprs.py
@@ -1462,9 +1462,11 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
try:
app_module.aprs_queue.put({'type': 'status', 'status': 'started'})
- # Read line-by-line in text mode. Empty string '' signals EOF.
- for line in iter(decoder_process.stdout.readline, ''):
- line = line.strip()
+ # Read line-by-line in binary mode. Empty bytes b'' signals EOF.
+ # Decode with errors='replace' so corrupted radio bytes (e.g. 0xf7)
+ # never crash the stream.
+ for raw in iter(decoder_process.stdout.readline, b''):
+ line = raw.decode('utf-8', errors='replace').strip()
if not line:
continue
@@ -1784,15 +1786,15 @@ def start_aprs() -> Response:
rtl_stderr_thread.start()
# Start decoder with stdin wired to rtl_fm's stdout.
- # Use text mode with line buffering for reliable line-by-line reading.
+ # Use binary mode to avoid UnicodeDecodeError on raw/corrupted bytes
+ # from the radio decoder (e.g. 0xf7). Lines are decoded manually
+ # in stream_aprs_output with errors='replace'.
# Merge stderr into stdout to avoid blocking on unbuffered stderr.
decoder_process = subprocess.Popen(
decoder_cmd,
stdin=rtl_process.stdout,
stdout=PIPE,
stderr=STDOUT,
- text=True,
- bufsize=1,
start_new_session=True
)
@@ -1827,7 +1829,8 @@ def start_aprs() -> Response:
if decoder_process.poll() is not None:
# Decoder exited early - capture any output
- error_output = decoder_process.stdout.read()[:500] if decoder_process.stdout else ''
+ raw_output = decoder_process.stdout.read()[:500] if decoder_process.stdout else b''
+ error_output = raw_output.decode('utf-8', errors='replace') if raw_output else ''
error_msg = f'{decoder_name} failed to start'
if error_output:
error_msg += f': {error_output}'
diff --git a/routes/morse.py b/routes/morse.py
index 56800a2..5491979 100644
--- a/routes/morse.py
+++ b/routes/morse.py
@@ -1,251 +1,953 @@
-"""CW/Morse code decoder routes."""
-
-from __future__ import annotations
-
-import contextlib
-import queue
-import subprocess
-import threading
-from typing import Any
-
-from flask import Blueprint, Response, jsonify, request
-
-import app as app_module
-from utils.event_pipeline import process_event
-from utils.logging import sensor_logger as logger
-from utils.morse import morse_decoder_thread
-from utils.process import register_process, safe_terminate, unregister_process
-from utils.sdr import SDRFactory, SDRType
-from utils.sse import sse_stream_fanout
-from utils.validation import (
- validate_device_index,
- validate_frequency,
- validate_gain,
- validate_ppm,
-)
-
-morse_bp = Blueprint('morse', __name__)
-
-# Track which device is being used
-morse_active_device: int | None = None
-
-
-def _validate_tone_freq(value: Any) -> float:
- """Validate CW tone frequency (300-1200 Hz)."""
- try:
- freq = float(value)
- if not 300 <= freq <= 1200:
- raise ValueError("Tone frequency must be between 300 and 1200 Hz")
- return freq
- except (ValueError, TypeError) as e:
- raise ValueError(f"Invalid tone frequency: {value}") from e
-
-
-def _validate_wpm(value: Any) -> int:
- """Validate words per minute (5-50)."""
- try:
- wpm = int(value)
- if not 5 <= wpm <= 50:
- raise ValueError("WPM must be between 5 and 50")
- return wpm
- except (ValueError, TypeError) as e:
- raise ValueError(f"Invalid WPM: {value}") from e
-
-
-@morse_bp.route('/morse/start', methods=['POST'])
-def start_morse() -> Response:
- global morse_active_device
-
- with app_module.morse_lock:
- if app_module.morse_process:
- return jsonify({'status': 'error', 'message': 'Morse decoder already running'}), 409
-
- data = request.json or {}
-
- # Validate standard SDR inputs
- try:
- freq = validate_frequency(data.get('frequency', '14.060'))
- gain = validate_gain(data.get('gain', '0'))
- ppm = validate_ppm(data.get('ppm', '0'))
- device = validate_device_index(data.get('device', '0'))
- except ValueError as e:
- return jsonify({'status': 'error', 'message': str(e)}), 400
-
- # Validate Morse-specific inputs
- try:
- tone_freq = _validate_tone_freq(data.get('tone_freq', '700'))
- except ValueError as e:
- return jsonify({'status': 'error', 'message': str(e)}), 400
-
- try:
- wpm = _validate_wpm(data.get('wpm', '15'))
- except ValueError as e:
- return jsonify({'status': 'error', 'message': str(e)}), 400
-
- # Claim SDR device
- device_int = int(device)
- error = app_module.claim_sdr_device(device_int, 'morse')
- if error:
- return jsonify({
- 'status': 'error',
- 'error_type': 'DEVICE_BUSY',
- 'message': error,
- }), 409
- morse_active_device = device_int
-
- # Clear queue
- while not app_module.morse_queue.empty():
- try:
- app_module.morse_queue.get_nowait()
- except queue.Empty:
- break
-
- # Build rtl_fm USB demodulation command
- sdr_type_str = data.get('sdr_type', 'rtlsdr')
- try:
- sdr_type = SDRType(sdr_type_str)
- except ValueError:
- sdr_type = SDRType.RTL_SDR
-
- sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
- builder = SDRFactory.get_builder(sdr_device.sdr_type)
-
- sample_rate = 8000
- bias_t = data.get('bias_t', False)
-
- rtl_cmd = builder.build_fm_demod_command(
- device=sdr_device,
- frequency_mhz=freq,
- sample_rate=sample_rate,
- gain=float(gain) if gain and gain != '0' else None,
- ppm=int(ppm) if ppm and ppm != '0' else None,
- modulation='usb',
- bias_t=bias_t,
- )
-
- full_cmd = ' '.join(rtl_cmd)
- logger.info(f"Morse decoder running: {full_cmd}")
-
- try:
- rtl_process = subprocess.Popen(
- rtl_cmd,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- )
- register_process(rtl_process)
-
- # Monitor rtl_fm stderr
- def monitor_stderr():
- for line in rtl_process.stderr:
- err_text = line.decode('utf-8', errors='replace').strip()
- if err_text:
- logger.debug(f"[rtl_fm/morse] {err_text}")
-
- stderr_thread = threading.Thread(target=monitor_stderr)
- stderr_thread.daemon = True
- stderr_thread.start()
-
- # Start Morse decoder thread
- stop_event = threading.Event()
- decoder_thread = threading.Thread(
- target=morse_decoder_thread,
- args=(
- rtl_process.stdout,
- app_module.morse_queue,
- stop_event,
- sample_rate,
- tone_freq,
- wpm,
- ),
- )
- decoder_thread.daemon = True
- decoder_thread.start()
-
- app_module.morse_process = rtl_process
- app_module.morse_process._stop_decoder = stop_event
- app_module.morse_process._decoder_thread = decoder_thread
-
- app_module.morse_queue.put({'type': 'status', 'status': 'started'})
-
- return jsonify({
- 'status': 'started',
- 'command': full_cmd,
- 'tone_freq': tone_freq,
- 'wpm': wpm,
- })
-
- except FileNotFoundError as e:
- if morse_active_device is not None:
- app_module.release_sdr_device(morse_active_device)
- morse_active_device = None
- return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}), 400
-
- except Exception as e:
- # Clean up rtl_fm if it was started
- try:
- rtl_process.terminate()
- rtl_process.wait(timeout=2)
- except Exception:
- with contextlib.suppress(Exception):
- rtl_process.kill()
- unregister_process(rtl_process)
- if morse_active_device is not None:
- app_module.release_sdr_device(morse_active_device)
- morse_active_device = None
- return jsonify({'status': 'error', 'message': str(e)}), 500
-
-
-@morse_bp.route('/morse/stop', methods=['POST'])
-def stop_morse() -> Response:
- global morse_active_device
-
- with app_module.morse_lock:
- if app_module.morse_process:
- # Signal decoder thread to stop
- stop_event = getattr(app_module.morse_process, '_stop_decoder', None)
- if stop_event:
- stop_event.set()
-
- safe_terminate(app_module.morse_process)
- unregister_process(app_module.morse_process)
- app_module.morse_process = None
-
- if morse_active_device is not None:
- app_module.release_sdr_device(morse_active_device)
- morse_active_device = None
-
- app_module.morse_queue.put({'type': 'status', 'status': 'stopped'})
- return jsonify({'status': 'stopped'})
-
- return jsonify({'status': 'not_running'})
-
-
-@morse_bp.route('/morse/status')
-def morse_status() -> Response:
- with app_module.morse_lock:
- running = (
- app_module.morse_process is not None
- and app_module.morse_process.poll() is None
- )
- return jsonify({'running': running})
-
-
-@morse_bp.route('/morse/stream')
-def morse_stream() -> Response:
- def _on_msg(msg: dict[str, Any]) -> None:
- process_event('morse', msg, msg.get('type'))
-
- response = Response(
- sse_stream_fanout(
- source_queue=app_module.morse_queue,
- channel_key='morse',
- timeout=1.0,
- keepalive_interval=30.0,
- on_message=_on_msg,
- ),
- mimetype='text/event-stream',
- )
- response.headers['Cache-Control'] = 'no-cache'
- response.headers['X-Accel-Buffering'] = 'no'
- response.headers['Connection'] = 'keep-alive'
- return response
+"""CW/Morse code decoder routes."""
+
+from __future__ import annotations
+
+import contextlib
+import queue
+import subprocess
+import tempfile
+import threading
+import time
+from pathlib import Path
+from typing import Any
+
+from flask import Blueprint, Response, jsonify, request
+
+import app as app_module
+from utils.event_pipeline import process_event
+from utils.logging import sensor_logger as logger
+from utils.morse import (
+ decode_morse_wav_file,
+ morse_decoder_thread,
+)
+from utils.process import register_process, safe_terminate, unregister_process
+from utils.sdr import SDRFactory, SDRType
+from utils.sse import sse_stream_fanout
+from utils.validation import (
+ validate_device_index,
+ validate_frequency,
+ validate_gain,
+ validate_ppm,
+)
+
+morse_bp = Blueprint('morse', __name__)
+
+
+class _FilteredQueue:
+ """Suppress decoder-thread 'stopped' events that race with route lifecycle."""
+
+ def __init__(self, inner: queue.Queue) -> None:
+ self._inner = inner
+
+ def put_nowait(self, item: Any) -> None:
+ if isinstance(item, dict) and item.get('type') == 'status' and item.get('status') == 'stopped':
+ return
+ self._inner.put_nowait(item)
+
+ def put(self, item: Any, **kwargs: Any) -> None:
+ if isinstance(item, dict) and item.get('type') == 'status' and item.get('status') == 'stopped':
+ return
+ self._inner.put(item, **kwargs)
+
+# Track which device is being used
+morse_active_device: int | None = None
+
+# Runtime lifecycle state.
+MORSE_IDLE = 'idle'
+MORSE_STARTING = 'starting'
+MORSE_RUNNING = 'running'
+MORSE_STOPPING = 'stopping'
+MORSE_ERROR = 'error'
+
+morse_state = MORSE_IDLE
+morse_state_message = 'Idle'
+morse_state_since = time.monotonic()
+morse_last_error = ''
+morse_runtime_config: dict[str, Any] = {}
+morse_session_id = 0
+
+morse_decoder_worker: threading.Thread | None = None
+morse_stderr_worker: threading.Thread | None = None
+morse_stop_event: threading.Event | None = None
+morse_control_queue: queue.Queue | None = None
+
+def _set_state(state: str, message: str = '', *, enqueue: bool = True, extra: dict[str, Any] | None = None) -> None:
+ """Update lifecycle state and optionally emit a status queue event."""
+ global morse_state, morse_state_message, morse_state_since
+ morse_state = state
+ morse_state_message = message or state
+ morse_state_since = time.monotonic()
+
+ if not enqueue:
+ return
+
+ payload: dict[str, Any] = {
+ 'type': 'status',
+ 'status': state,
+ 'state': state,
+ 'message': morse_state_message,
+ 'session_id': morse_session_id,
+ 'timestamp': time.strftime('%H:%M:%S'),
+ }
+ if extra:
+ payload.update(extra)
+ with contextlib.suppress(queue.Full):
+ app_module.morse_queue.put_nowait(payload)
+
+
+def _drain_queue(q: queue.Queue) -> None:
+ while not q.empty():
+ try:
+ q.get_nowait()
+ except queue.Empty:
+ break
+
+
+def _join_thread(worker: threading.Thread | None, timeout_s: float) -> bool:
+ if worker is None:
+ return True
+ worker.join(timeout=timeout_s)
+ return not worker.is_alive()
+
+
+def _close_pipe(pipe_obj: Any) -> None:
+ if pipe_obj is None:
+ return
+ with contextlib.suppress(Exception):
+ pipe_obj.close()
+
+
+def _queue_morse_event(payload: dict[str, Any]) -> None:
+ with contextlib.suppress(queue.Full):
+ app_module.morse_queue.put_nowait(payload)
+
+
+def _bool_value(value: Any, default: bool = False) -> bool:
+ if isinstance(value, bool):
+ return value
+ if value is None:
+ return default
+ text = str(value).strip().lower()
+ if text in {'1', 'true', 'yes', 'on'}:
+ return True
+ if text in {'0', 'false', 'no', 'off'}:
+ return False
+ return default
+
+
+def _float_value(value: Any, default: float) -> float:
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return float(default)
+
+
+def _validate_tone_freq(value: Any) -> float:
+ """Validate CW tone frequency (300-1200 Hz)."""
+ try:
+ freq = float(value)
+ if not 300 <= freq <= 1200:
+ raise ValueError('Tone frequency must be between 300 and 1200 Hz')
+ return freq
+ except (ValueError, TypeError) as e:
+ raise ValueError(f'Invalid tone frequency: {value}') from e
+
+
+def _validate_wpm(value: Any) -> int:
+ """Validate words per minute (5-50)."""
+ try:
+ wpm = int(value)
+ if not 5 <= wpm <= 50:
+ raise ValueError('WPM must be between 5 and 50')
+ return wpm
+ except (ValueError, TypeError) as e:
+ raise ValueError(f'Invalid WPM: {value}') from e
+
+
+def _validate_bandwidth(value: Any) -> int:
+ try:
+ bw = int(value)
+ if bw not in (50, 100, 200, 400):
+ raise ValueError('Bandwidth must be one of 50, 100, 200, 400 Hz')
+ return bw
+ except (TypeError, ValueError) as e:
+ raise ValueError(f'Invalid bandwidth: {value}') from e
+
+
+def _validate_threshold_mode(value: Any) -> str:
+ mode = str(value or 'auto').strip().lower()
+ if mode not in {'auto', 'manual'}:
+ raise ValueError('threshold_mode must be auto or manual')
+ return mode
+
+
+def _validate_wpm_mode(value: Any) -> str:
+ mode = str(value or 'auto').strip().lower()
+ if mode not in {'auto', 'manual'}:
+ raise ValueError('wpm_mode must be auto or manual')
+ return mode
+
+
+def _validate_threshold_multiplier(value: Any) -> float:
+ try:
+ multiplier = float(value)
+ if not 1.1 <= multiplier <= 8.0:
+ raise ValueError('threshold_multiplier must be between 1.1 and 8.0')
+ return multiplier
+ except (TypeError, ValueError) as e:
+ raise ValueError(f'Invalid threshold multiplier: {value}') from e
+
+
+def _validate_non_negative_float(value: Any, field_name: str) -> float:
+ try:
+ parsed = float(value)
+ if parsed < 0:
+ raise ValueError(f'{field_name} must be non-negative')
+ return parsed
+ except (TypeError, ValueError) as e:
+ raise ValueError(f'Invalid {field_name}: {value}') from e
+
+
+def _validate_signal_gate(value: Any) -> float:
+ try:
+ gate = float(value)
+ if not 0.0 <= gate <= 1.0:
+ raise ValueError('signal_gate must be between 0.0 and 1.0')
+ return gate
+ except (TypeError, ValueError) as e:
+ raise ValueError(f'Invalid signal gate: {value}') from e
+
+
+def _snapshot_live_resources() -> list[str]:
+ alive: list[str] = []
+ if morse_decoder_worker and morse_decoder_worker.is_alive():
+ alive.append('decoder_thread')
+ if morse_stderr_worker and morse_stderr_worker.is_alive():
+ alive.append('stderr_thread')
+ if app_module.morse_process and app_module.morse_process.poll() is None:
+ alive.append('rtl_process')
+ return alive
+
+
+@morse_bp.route('/morse/start', methods=['POST'])
+def start_morse() -> Response:
+ global morse_active_device, morse_decoder_worker, morse_stderr_worker
+ global morse_stop_event, morse_control_queue, morse_runtime_config
+ global morse_last_error, morse_session_id
+
+ data = request.json or {}
+
+ try:
+ freq = validate_frequency(data.get('frequency', '14.060'), min_mhz=0.5, max_mhz=30.0)
+ gain = validate_gain(data.get('gain', '0'))
+ ppm = validate_ppm(data.get('ppm', '0'))
+ device = validate_device_index(data.get('device', '0'))
+ except ValueError as e:
+ return jsonify({'status': 'error', 'message': str(e)}), 400
+
+ try:
+ tone_freq = _validate_tone_freq(data.get('tone_freq', '700'))
+ wpm = _validate_wpm(data.get('wpm', '15'))
+ bandwidth_hz = _validate_bandwidth(data.get('bandwidth_hz', '200'))
+ threshold_mode = _validate_threshold_mode(data.get('threshold_mode', 'auto'))
+ wpm_mode = _validate_wpm_mode(data.get('wpm_mode', 'auto'))
+ threshold_multiplier = _validate_threshold_multiplier(data.get('threshold_multiplier', '2.8'))
+ manual_threshold = _validate_non_negative_float(data.get('manual_threshold', '0'), 'manual threshold')
+ threshold_offset = _validate_non_negative_float(data.get('threshold_offset', '0'), 'threshold offset')
+ min_signal_gate = _validate_signal_gate(data.get('signal_gate', '0'))
+ auto_tone_track = _bool_value(data.get('auto_tone_track', True), True)
+ tone_lock = _bool_value(data.get('tone_lock', False), False)
+ wpm_lock = _bool_value(data.get('wpm_lock', False), False)
+ except ValueError as e:
+ return jsonify({'status': 'error', 'message': str(e)}), 400
+
+ with app_module.morse_lock:
+ if morse_state in {MORSE_STARTING, MORSE_RUNNING, MORSE_STOPPING}:
+ return jsonify({
+ 'status': 'error',
+ 'message': f'Morse decoder is {morse_state}',
+ 'state': morse_state,
+ }), 409
+
+ device_int = int(device)
+ error = app_module.claim_sdr_device(device_int, 'morse')
+ if error:
+ return jsonify({
+ 'status': 'error',
+ 'error_type': 'DEVICE_BUSY',
+ 'message': error,
+ }), 409
+
+ morse_active_device = device_int
+ morse_last_error = ''
+ morse_session_id += 1
+
+ _drain_queue(app_module.morse_queue)
+ _set_state(MORSE_STARTING, 'Starting decoder...')
+
+ sample_rate = 22050
+ bias_t = _bool_value(data.get('bias_t', False), False)
+
+ sdr_type_str = data.get('sdr_type', 'rtlsdr')
+ try:
+ sdr_type = SDRType(sdr_type_str)
+ except ValueError:
+ sdr_type = SDRType.RTL_SDR
+
+ requested_device_index = int(device)
+ active_device_index = requested_device_index
+ builder = SDRFactory.get_builder(sdr_type)
+
+ device_catalog: dict[int, dict[str, str]] = {}
+ candidate_device_indices: list[int] = [requested_device_index]
+ with contextlib.suppress(Exception):
+ detected_devices = SDRFactory.detect_devices()
+ same_type_devices = [d for d in detected_devices if d.sdr_type == sdr_type]
+ for d in same_type_devices:
+ device_catalog[d.index] = {
+ 'name': str(d.name or f'SDR {d.index}'),
+ 'serial': str(d.serial or 'Unknown'),
+ }
+ for d in sorted(same_type_devices, key=lambda dev: dev.index):
+ if d.index not in candidate_device_indices:
+ candidate_device_indices.append(d.index)
+
+ def _device_label(device_index: int) -> str:
+ meta = device_catalog.get(device_index, {})
+ serial = str(meta.get('serial') or 'Unknown')
+ name = str(meta.get('name') or f'SDR {device_index}')
+ return f'device {device_index} ({name}, SN: {serial})'
+
+ def _build_rtl_cmd(device_index: int, direct_sampling_mode: int | None) -> list[str]:
+ tuned_frequency_mhz = max(0.5, float(freq) - (float(tone_freq) / 1_000_000.0))
+ sdr_device = SDRFactory.create_default_device(sdr_type, index=device_index)
+ fm_kwargs: dict[str, Any] = {
+ 'device': sdr_device,
+ 'frequency_mhz': tuned_frequency_mhz,
+ 'sample_rate': sample_rate,
+ 'gain': float(gain) if gain and gain != '0' else None,
+ 'ppm': int(ppm) if ppm and ppm != '0' else None,
+ 'modulation': 'usb',
+ 'bias_t': bias_t,
+ }
+ if direct_sampling_mode in (1, 2):
+ fm_kwargs['direct_sampling'] = int(direct_sampling_mode)
+
+ cmd = list(builder.build_fm_demod_command(**fm_kwargs))
+
+ if cmd and cmd[-1] != '-':
+ cmd.append('-')
+ return cmd
+
+ can_try_direct_sampling = bool(sdr_type == SDRType.RTL_SDR and float(freq) < 24.0)
+ direct_sampling_attempts: list[int | None] = [2, 1, None] if can_try_direct_sampling else [None]
+
+ runtime_config: dict[str, Any] = {
+ 'sample_rate': sample_rate,
+ 'rf_frequency_mhz': float(freq),
+ 'tuned_frequency_mhz': max(0.5, float(freq) - (float(tone_freq) / 1_000_000.0)),
+ 'tone_freq': tone_freq,
+ 'wpm': wpm,
+ 'bandwidth_hz': bandwidth_hz,
+ 'auto_tone_track': auto_tone_track,
+ 'tone_lock': tone_lock,
+ 'threshold_mode': threshold_mode,
+ 'manual_threshold': manual_threshold,
+ 'threshold_multiplier': threshold_multiplier,
+ 'threshold_offset': threshold_offset,
+ 'wpm_mode': wpm_mode,
+ 'wpm_lock': wpm_lock,
+ 'min_signal_gate': min_signal_gate,
+ 'source': 'rtl_fm',
+ 'requested_device': requested_device_index,
+ 'active_device': active_device_index,
+ 'device_serial': str(device_catalog.get(active_device_index, {}).get('serial') or 'Unknown'),
+ 'candidate_devices': list(candidate_device_indices),
+ }
+
+ active_rtl_process: subprocess.Popen[bytes] | None = None
+ active_stop_event: threading.Event | None = None
+ active_control_queue: queue.Queue | None = None
+ active_decoder_thread: threading.Thread | None = None
+ active_stderr_thread: threading.Thread | None = None
+ rtl_process: subprocess.Popen[bytes] | None = None
+ stop_event: threading.Event | None = None
+ control_queue: queue.Queue | None = None
+ decoder_thread: threading.Thread | None = None
+ stderr_thread: threading.Thread | None = None
+
+ def _cleanup_attempt(
+ rtl_proc: subprocess.Popen[bytes] | None,
+ stop_evt: threading.Event | None,
+ control_q: queue.Queue | None,
+ decoder_worker: threading.Thread | None,
+ stderr_worker: threading.Thread | None,
+ ) -> None:
+ if stop_evt is not None:
+ stop_evt.set()
+ if control_q is not None:
+ with contextlib.suppress(queue.Full):
+ control_q.put_nowait({'cmd': 'shutdown'})
+
+ if rtl_proc is not None:
+ _close_pipe(getattr(rtl_proc, 'stdout', None))
+ _close_pipe(getattr(rtl_proc, 'stderr', None))
+
+ if rtl_proc is not None:
+ safe_terminate(rtl_proc, timeout=0.4)
+ unregister_process(rtl_proc)
+
+ _join_thread(decoder_worker, timeout_s=0.35)
+ _join_thread(stderr_worker, timeout_s=0.35)
+
+ full_cmd = ''
+ attempt_errors: list[str] = []
+
+ try:
+ startup_succeeded = False
+ for device_pos, candidate_device_index in enumerate(candidate_device_indices, start=1):
+ if candidate_device_index != active_device_index:
+ prev_device = active_device_index
+ claim_error = app_module.claim_sdr_device(candidate_device_index, 'morse')
+ if claim_error:
+ msg = f'{_device_label(candidate_device_index)} unavailable: {claim_error}'
+ attempt_errors.append(msg)
+ logger.warning('Morse startup device fallback skipped: %s', msg)
+ _queue_morse_event({'type': 'info', 'text': f'[morse] {msg}'})
+ continue
+
+ if prev_device is not None:
+ app_module.release_sdr_device(prev_device)
+ active_device_index = candidate_device_index
+ with app_module.morse_lock:
+ morse_active_device = active_device_index
+
+ _queue_morse_event({
+ 'type': 'info',
+ 'text': (
+ f'[morse] switching to {_device_label(active_device_index)} '
+ f'({device_pos}/{len(candidate_device_indices)})'
+ ),
+ })
+
+ runtime_config['active_device'] = active_device_index
+ runtime_config['device_serial'] = str(
+ device_catalog.get(active_device_index, {}).get('serial') or 'Unknown'
+ )
+ runtime_config.pop('startup_waiting', None)
+ runtime_config.pop('startup_warning', None)
+
+ for attempt_index, direct_sampling_mode in enumerate(direct_sampling_attempts, start=1):
+ rtl_process = None
+ stop_event = None
+ control_queue = None
+ decoder_thread = None
+ stderr_thread = None
+
+ rtl_cmd = _build_rtl_cmd(active_device_index, direct_sampling_mode)
+ direct_mode_label = direct_sampling_mode if direct_sampling_mode is not None else 'none'
+ full_cmd = ' '.join(rtl_cmd)
+ logger.info(
+ 'Morse decoder attempt device=%s (%s/%s) rf=%.6f tuned=%.6f direct_mode=%s (%s/%s): %s',
+ active_device_index,
+ device_pos,
+ len(candidate_device_indices),
+ float(freq),
+ float(runtime_config.get('tuned_frequency_mhz', freq)),
+ direct_mode_label,
+ attempt_index,
+ len(direct_sampling_attempts),
+ full_cmd,
+ )
+ _queue_morse_event({'type': 'info', 'text': f'[cmd] {full_cmd}'})
+
+ rtl_process = subprocess.Popen(
+ rtl_cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ bufsize=0,
+ )
+ register_process(rtl_process)
+
+ stop_event = threading.Event()
+ control_queue = queue.Queue(maxsize=16)
+ pcm_ready_event = threading.Event()
+ stderr_lines: list[str] = []
+
+ def monitor_stderr(
+ proc: subprocess.Popen[bytes] = rtl_process,
+ proc_stop_event: threading.Event = stop_event,
+ capture_lines: list[str] = stderr_lines,
+ ) -> None:
+ stderr_stream = proc.stderr
+ if stderr_stream is None:
+ return
+ try:
+ while not proc_stop_event.is_set():
+ line = stderr_stream.readline()
+ if not line:
+ if proc.poll() is not None:
+ break
+ time.sleep(0.02)
+ continue
+ err_text = line.decode('utf-8', errors='replace').strip()
+ if not err_text:
+ continue
+ if len(capture_lines) >= 40:
+ del capture_lines[:10]
+ capture_lines.append(err_text)
+ _queue_morse_event({'type': 'info', 'text': f'[rtl_fm] {err_text}'})
+ except (ValueError, OSError):
+ return
+ except Exception:
+ return
+
+ stderr_thread = threading.Thread(target=monitor_stderr, daemon=True, name='morse-stderr')
+ stderr_thread.start()
+
+ if rtl_process.stdout is None:
+ raise RuntimeError('rtl_fm stdout unavailable')
+
+ decoder_thread = threading.Thread(
+ target=morse_decoder_thread,
+ kwargs={
+ 'rtl_stdout': rtl_process.stdout,
+ 'output_queue': _FilteredQueue(app_module.morse_queue),
+ 'stop_event': stop_event,
+ 'sample_rate': sample_rate,
+ 'tone_freq': tone_freq,
+ 'wpm': wpm,
+ 'decoder_config': runtime_config,
+ 'control_queue': control_queue,
+ 'pcm_ready_event': pcm_ready_event,
+ },
+ daemon=True,
+ name='morse-decoder',
+ )
+ decoder_thread.start()
+
+ startup_deadline = time.monotonic() + 4.0
+ startup_ok = False
+ startup_error = ''
+
+ while time.monotonic() < startup_deadline:
+ if pcm_ready_event.is_set():
+ startup_ok = True
+ break
+ if rtl_process.poll() is not None:
+ startup_error = f'rtl_fm exited during startup (code {rtl_process.returncode})'
+ break
+ time.sleep(0.05)
+
+ if not startup_ok:
+ if not startup_error:
+ startup_error = 'No PCM samples received within startup timeout'
+ if stderr_lines:
+ startup_error = f'{startup_error}; stderr: {stderr_lines[-1]}'
+ is_last_device = device_pos == len(candidate_device_indices)
+ is_last_attempt = attempt_index == len(direct_sampling_attempts)
+ if (
+ is_last_device
+ and is_last_attempt
+ and rtl_process.poll() is None
+ ):
+ startup_ok = True
+ runtime_config['startup_waiting'] = True
+ runtime_config['startup_warning'] = startup_error
+ logger.warning(
+ 'Morse startup continuing without PCM on %s: %s',
+ _device_label(active_device_index),
+ startup_error,
+ )
+ _queue_morse_event({
+ 'type': 'info',
+ 'text': '[morse] waiting for PCM stream...',
+ })
+
+ if startup_ok:
+ runtime_config['direct_sampling_mode'] = direct_sampling_mode
+ runtime_config['direct_sampling'] = (
+ int(direct_sampling_mode) if direct_sampling_mode is not None else 0
+ )
+ runtime_config['command'] = full_cmd
+ runtime_config['active_device'] = active_device_index
+
+ active_rtl_process = rtl_process
+ active_stop_event = stop_event
+ active_control_queue = control_queue
+ active_decoder_thread = decoder_thread
+ active_stderr_thread = stderr_thread
+ startup_succeeded = True
+ break
+
+ attempt_errors.append(
+ f'{_device_label(active_device_index)} '
+ f'attempt {attempt_index}/{len(direct_sampling_attempts)} '
+ f'(source=rtl_fm direct_mode={direct_mode_label}): {startup_error}'
+ )
+ logger.warning('Morse startup attempt failed: %s', attempt_errors[-1])
+ _queue_morse_event({'type': 'info', 'text': f'[morse] startup attempt failed: {startup_error}'})
+
+ _cleanup_attempt(
+ rtl_process,
+ stop_event,
+ control_queue,
+ decoder_thread,
+ stderr_thread,
+ )
+ rtl_process = None
+ stop_event = None
+ control_queue = None
+ decoder_thread = None
+ stderr_thread = None
+
+ if startup_succeeded:
+ break
+
+ if device_pos < len(candidate_device_indices):
+ next_device = candidate_device_indices[device_pos]
+ _queue_morse_event({
+ 'type': 'status',
+ 'state': MORSE_STARTING,
+ 'status': MORSE_STARTING,
+ 'message': (
+ f'No PCM on {_device_label(active_device_index)}. '
+ f'Trying {_device_label(next_device)}...'
+ ),
+ 'session_id': morse_session_id,
+ 'timestamp': time.strftime('%H:%M:%S'),
+ })
+
+ if (
+ active_rtl_process is None
+ or active_stop_event is None
+ or active_control_queue is None
+ or active_decoder_thread is None
+ or active_stderr_thread is None
+ ):
+ msg = (
+ f'SDR capture started but no PCM stream was received from '
+ f'{_device_label(active_device_index)}.'
+ )
+ if attempt_errors:
+ msg += ' ' + ' | '.join(attempt_errors)
+ logger.error('Morse startup failed: %s', msg)
+ with app_module.morse_lock:
+ if morse_active_device is not None:
+ app_module.release_sdr_device(morse_active_device)
+ morse_active_device = None
+ morse_last_error = msg
+ _set_state(MORSE_ERROR, msg)
+ _set_state(MORSE_IDLE, 'Idle')
+ return jsonify({'status': 'error', 'message': msg}), 500
+
+ with app_module.morse_lock:
+ app_module.morse_process = active_rtl_process
+ app_module.morse_process._stop_decoder = active_stop_event
+ app_module.morse_process._decoder_thread = active_decoder_thread
+ app_module.morse_process._stderr_thread = active_stderr_thread
+ app_module.morse_process._control_queue = active_control_queue
+
+ morse_stop_event = active_stop_event
+ morse_control_queue = active_control_queue
+ morse_decoder_worker = active_decoder_thread
+ morse_stderr_worker = active_stderr_thread
+ morse_runtime_config = dict(runtime_config)
+ _set_state(MORSE_RUNNING, 'Listening')
+
+ return jsonify({
+ 'status': 'started',
+ 'state': MORSE_RUNNING,
+ 'command': full_cmd,
+ 'tone_freq': tone_freq,
+ 'wpm': wpm,
+ 'config': runtime_config,
+ 'session_id': morse_session_id,
+ })
+
+ except FileNotFoundError as e:
+ _cleanup_attempt(
+ rtl_process if rtl_process is not None else active_rtl_process,
+ stop_event if stop_event is not None else active_stop_event,
+ control_queue if control_queue is not None else active_control_queue,
+ decoder_thread if decoder_thread is not None else active_decoder_thread,
+ stderr_thread if stderr_thread is not None else active_stderr_thread,
+ )
+ with app_module.morse_lock:
+ if morse_active_device is not None:
+ app_module.release_sdr_device(morse_active_device)
+ morse_active_device = None
+ morse_last_error = f'Tool not found: {e.filename}'
+ _set_state(MORSE_ERROR, morse_last_error)
+ _set_state(MORSE_IDLE, 'Idle')
+ return jsonify({'status': 'error', 'message': morse_last_error}), 400
+
+ except Exception as e:
+ _cleanup_attempt(
+ rtl_process if rtl_process is not None else active_rtl_process,
+ stop_event if stop_event is not None else active_stop_event,
+ control_queue if control_queue is not None else active_control_queue,
+ decoder_thread if decoder_thread is not None else active_decoder_thread,
+ stderr_thread if stderr_thread is not None else active_stderr_thread,
+ )
+ with app_module.morse_lock:
+ if morse_active_device is not None:
+ app_module.release_sdr_device(morse_active_device)
+ morse_active_device = None
+ morse_last_error = str(e)
+ _set_state(MORSE_ERROR, morse_last_error)
+ _set_state(MORSE_IDLE, 'Idle')
+ return jsonify({'status': 'error', 'message': str(e)}), 500
+
+
+@morse_bp.route('/morse/stop', methods=['POST'])
+def stop_morse() -> Response:
+ global morse_active_device, morse_decoder_worker, morse_stderr_worker
+ global morse_stop_event, morse_control_queue
+
+ stop_started = time.perf_counter()
+
+ with app_module.morse_lock:
+ if morse_state == MORSE_STOPPING:
+ return jsonify({'status': 'stopping', 'state': MORSE_STOPPING}), 202
+
+ rtl_proc = app_module.morse_process
+ stop_event = morse_stop_event or getattr(rtl_proc, '_stop_decoder', None)
+ decoder_thread = morse_decoder_worker or getattr(rtl_proc, '_decoder_thread', None)
+ stderr_thread = morse_stderr_worker or getattr(rtl_proc, '_stderr_thread', None)
+ control_queue = morse_control_queue or getattr(rtl_proc, '_control_queue', None)
+ active_device = morse_active_device
+
+ if (
+ not rtl_proc
+ and not stop_event
+ and not decoder_thread
+ and not stderr_thread
+ ):
+ _set_state(MORSE_IDLE, 'Idle', enqueue=False)
+ return jsonify({'status': 'not_running', 'state': MORSE_IDLE})
+
+ _set_state(MORSE_STOPPING, 'Stopping decoder...')
+
+ app_module.morse_process = None
+ morse_stop_event = None
+ morse_control_queue = None
+ morse_decoder_worker = None
+ morse_stderr_worker = None
+
+ cleanup_steps: list[str] = []
+
+ def _mark(step: str) -> None:
+ cleanup_steps.append(step)
+ logger.debug(f'[morse.stop] {step}')
+
+ _mark('enter stop')
+
+ if stop_event is not None:
+ stop_event.set()
+ _mark('stop_event set')
+
+ if control_queue is not None:
+ with contextlib.suppress(queue.Full):
+ control_queue.put_nowait({'cmd': 'shutdown'})
+ _mark('control_queue shutdown signal sent')
+
+ if rtl_proc is not None:
+ _close_pipe(getattr(rtl_proc, 'stdout', None))
+ _close_pipe(getattr(rtl_proc, 'stderr', None))
+ _mark('rtl_fm pipes closed')
+
+ if rtl_proc is not None:
+ safe_terminate(rtl_proc, timeout=0.6)
+ unregister_process(rtl_proc)
+ _mark('rtl_fm process terminated')
+
+ decoder_joined = _join_thread(decoder_thread, timeout_s=0.45)
+ stderr_joined = _join_thread(stderr_thread, timeout_s=0.45)
+ _mark(f'decoder thread joined={decoder_joined}')
+ _mark(f'stderr thread joined={stderr_joined}')
+
+ if active_device is not None:
+ app_module.release_sdr_device(active_device)
+ _mark(f'SDR device {active_device} released')
+
+ stop_ms = round((time.perf_counter() - stop_started) * 1000.0, 1)
+ alive_after = []
+ if not decoder_joined:
+ alive_after.append('decoder_thread')
+ if not stderr_joined:
+ alive_after.append('stderr_thread')
+ if rtl_proc is not None and rtl_proc.poll() is None:
+ alive_after.append('rtl_process')
+
+ with app_module.morse_lock:
+ morse_active_device = None
+ _set_state(MORSE_IDLE, 'Stopped', extra={
+ 'stop_ms': stop_ms,
+ 'cleanup_steps': cleanup_steps,
+ 'alive': alive_after,
+ })
+
+ with contextlib.suppress(queue.Full):
+ app_module.morse_queue.put_nowait({
+ 'type': 'status',
+ 'status': 'stopped',
+ 'state': MORSE_IDLE,
+ 'stop_ms': stop_ms,
+ 'cleanup_steps': cleanup_steps,
+ 'alive': alive_after,
+ 'timestamp': time.strftime('%H:%M:%S'),
+ })
+
+ if stop_ms > 500.0 or alive_after:
+ logger.warning(
+ '[morse.stop] slow/partial cleanup: stop_ms=%s alive=%s steps=%s',
+ stop_ms,
+ ','.join(alive_after) if alive_after else 'none',
+ '; '.join(cleanup_steps),
+ )
+ else:
+ logger.info('[morse.stop] cleanup complete in %sms', stop_ms)
+
+ return jsonify({
+ 'status': 'stopped',
+ 'state': MORSE_IDLE,
+ 'stop_ms': stop_ms,
+ 'alive': alive_after,
+ 'cleanup_steps': cleanup_steps,
+ })
+
+
+@morse_bp.route('/morse/calibrate', methods=['POST'])
+def calibrate_morse() -> Response:
+ """Reset decoder threshold/timing estimators without restarting the process."""
+ with app_module.morse_lock:
+ if morse_state != MORSE_RUNNING or morse_control_queue is None:
+ return jsonify({
+ 'status': 'not_running',
+ 'state': morse_state,
+ 'message': 'Morse decoder is not running',
+ }), 409
+
+ with contextlib.suppress(queue.Full):
+ morse_control_queue.put_nowait({'cmd': 'reset'})
+
+ with contextlib.suppress(queue.Full):
+ app_module.morse_queue.put_nowait({
+ 'type': 'info',
+ 'text': '[morse] Calibration reset requested',
+ })
+
+ return jsonify({'status': 'ok', 'state': morse_state})
+
+
+@morse_bp.route('/morse/decode-file', methods=['POST'])
+def decode_morse_file() -> Response:
+ """Decode Morse from an uploaded WAV file."""
+ if 'audio' not in request.files:
+ return jsonify({'status': 'error', 'message': 'No audio file provided'}), 400
+
+ audio_file = request.files['audio']
+ if not audio_file.filename:
+ return jsonify({'status': 'error', 'message': 'No file selected'}), 400
+
+ # Parse optional tuning/decoder parameters from form fields.
+ form = request.form or {}
+ try:
+ tone_freq = _validate_tone_freq(form.get('tone_freq', '700'))
+ wpm = _validate_wpm(form.get('wpm', '15'))
+ bandwidth_hz = _validate_bandwidth(form.get('bandwidth_hz', '200'))
+ threshold_mode = _validate_threshold_mode(form.get('threshold_mode', 'auto'))
+ wpm_mode = _validate_wpm_mode(form.get('wpm_mode', 'auto'))
+ threshold_multiplier = _validate_threshold_multiplier(form.get('threshold_multiplier', '2.8'))
+ manual_threshold = _validate_non_negative_float(form.get('manual_threshold', '0'), 'manual threshold')
+ threshold_offset = _validate_non_negative_float(form.get('threshold_offset', '0'), 'threshold offset')
+ signal_gate = _validate_signal_gate(form.get('signal_gate', '0'))
+ auto_tone_track = _bool_value(form.get('auto_tone_track', 'true'), True)
+ tone_lock = _bool_value(form.get('tone_lock', 'false'), False)
+ wpm_lock = _bool_value(form.get('wpm_lock', 'false'), False)
+ except ValueError as e:
+ return jsonify({'status': 'error', 'message': str(e)}), 400
+
+ with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
+ audio_file.save(tmp.name)
+ tmp_path = Path(tmp.name)
+
+ try:
+ result = decode_morse_wav_file(
+ tmp_path,
+ sample_rate=8000,
+ tone_freq=tone_freq,
+ wpm=wpm,
+ bandwidth_hz=bandwidth_hz,
+ auto_tone_track=auto_tone_track,
+ tone_lock=tone_lock,
+ threshold_mode=threshold_mode,
+ manual_threshold=manual_threshold,
+ threshold_multiplier=threshold_multiplier,
+ threshold_offset=threshold_offset,
+ wpm_mode=wpm_mode,
+ wpm_lock=wpm_lock,
+ min_signal_gate=signal_gate,
+ )
+
+ text = str(result.get('text', ''))
+ raw = str(result.get('raw', ''))
+ metrics = result.get('metrics', {})
+
+ return jsonify({
+ 'status': 'ok',
+ 'text': text,
+ 'raw': raw,
+ 'char_count': len(text.replace(' ', '')),
+ 'word_count': len([w for w in text.split(' ') if w]),
+ 'metrics': metrics,
+ })
+ except Exception as e:
+ logger.error(f'Morse decode-file error: {e}')
+ return jsonify({'status': 'error', 'message': str(e)}), 500
+ finally:
+ with contextlib.suppress(Exception):
+ tmp_path.unlink(missing_ok=True)
+
+
+@morse_bp.route('/morse/status')
+def morse_status() -> Response:
+ with app_module.morse_lock:
+ running = (
+ app_module.morse_process is not None
+ and app_module.morse_process.poll() is None
+ and morse_state in {MORSE_RUNNING, MORSE_STARTING, MORSE_STOPPING}
+ )
+ since_ms = round((time.monotonic() - morse_state_since) * 1000.0, 1)
+ return jsonify({
+ 'running': running,
+ 'state': morse_state,
+ 'message': morse_state_message,
+ 'since_ms': since_ms,
+ 'session_id': morse_session_id,
+ 'config': morse_runtime_config,
+ 'alive': _snapshot_live_resources(),
+ 'error': morse_last_error,
+ })
+
+
+@morse_bp.route('/morse/stream')
+def morse_stream() -> Response:
+ def _on_msg(msg: dict[str, Any]) -> None:
+ process_event('morse', msg, msg.get('type'))
+
+ response = Response(
+ sse_stream_fanout(
+ source_queue=app_module.morse_queue,
+ channel_key='morse',
+ timeout=1.0,
+ keepalive_interval=30.0,
+ on_message=_on_msg,
+ ),
+ mimetype='text/event-stream',
+ )
+ response.headers['Cache-Control'] = 'no-cache'
+ response.headers['X-Accel-Buffering'] = 'no'
+ response.headers['Connection'] = 'keep-alive'
+ return response
diff --git a/routes/system.py b/routes/system.py
new file mode 100644
index 0000000..839899d
--- /dev/null
+++ b/routes/system.py
@@ -0,0 +1,323 @@
+"""System Health monitoring blueprint.
+
+Provides real-time system metrics (CPU, memory, disk, temperatures),
+active process status, and SDR device enumeration via SSE streaming.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import os
+import platform
+import queue
+import socket
+import threading
+import time
+from typing import Any
+
+from flask import Blueprint, Response, jsonify
+
+from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT
+from utils.logging import sensor_logger as logger
+from utils.sse import sse_stream_fanout
+
+try:
+ import psutil
+
+ _HAS_PSUTIL = True
+except ImportError:
+ psutil = None # type: ignore[assignment]
+ _HAS_PSUTIL = False
+
+system_bp = Blueprint('system', __name__, url_prefix='/system')
+
+# ---------------------------------------------------------------------------
+# Background metrics collector
+# ---------------------------------------------------------------------------
+
+_metrics_queue: queue.Queue = queue.Queue(maxsize=500)
+_collector_started = False
+_collector_lock = threading.Lock()
+_app_start_time: float | None = None
+
+
+def _get_app_start_time() -> float:
+ """Return the application start timestamp from the main app module."""
+ global _app_start_time
+ if _app_start_time is None:
+ try:
+ import app as app_module
+
+ _app_start_time = getattr(app_module, '_app_start_time', time.time())
+ except Exception:
+ _app_start_time = time.time()
+ return _app_start_time
+
+
+def _get_app_version() -> str:
+ """Return the application version string."""
+ try:
+ from config import VERSION
+
+ return VERSION
+ except Exception:
+ return 'unknown'
+
+
+def _format_uptime(seconds: float) -> str:
+ """Format seconds into a human-readable uptime string."""
+ days = int(seconds // 86400)
+ hours = int((seconds % 86400) // 3600)
+ minutes = int((seconds % 3600) // 60)
+ parts = []
+ if days > 0:
+ parts.append(f'{days}d')
+ if hours > 0:
+ parts.append(f'{hours}h')
+ parts.append(f'{minutes}m')
+ return ' '.join(parts)
+
+
+def _collect_process_status() -> dict[str, bool]:
+ """Return running/stopped status for each decoder process.
+
+ Mirrors the logic in app.py health_check().
+ """
+ try:
+ import app as app_module
+
+ def _alive(attr: str) -> bool:
+ proc = getattr(app_module, attr, None)
+ if proc is None:
+ return False
+ try:
+ return proc.poll() is None
+ except Exception:
+ return False
+
+ processes: dict[str, bool] = {
+ 'pager': _alive('current_process'),
+ 'sensor': _alive('sensor_process'),
+ 'adsb': _alive('adsb_process'),
+ 'ais': _alive('ais_process'),
+ 'acars': _alive('acars_process'),
+ 'vdl2': _alive('vdl2_process'),
+ 'aprs': _alive('aprs_process'),
+ 'dsc': _alive('dsc_process'),
+ 'morse': _alive('morse_process'),
+ }
+
+ # WiFi
+ try:
+ from app import _get_wifi_health
+
+ wifi_active, _, _ = _get_wifi_health()
+ processes['wifi'] = wifi_active
+ except Exception:
+ processes['wifi'] = False
+
+ # Bluetooth
+ try:
+ from app import _get_bluetooth_health
+
+ bt_active, _ = _get_bluetooth_health()
+ processes['bluetooth'] = bt_active
+ except Exception:
+ processes['bluetooth'] = False
+
+ # SubGHz
+ try:
+ from app import _get_subghz_active
+
+ processes['subghz'] = _get_subghz_active()
+ except Exception:
+ processes['subghz'] = False
+
+ return processes
+ except Exception:
+ return {}
+
+
+def _collect_metrics() -> dict[str, Any]:
+ """Gather a snapshot of system metrics."""
+ now = time.time()
+ start = _get_app_start_time()
+ uptime_seconds = round(now - start, 2)
+
+ metrics: dict[str, Any] = {
+ 'type': 'system_metrics',
+ 'timestamp': now,
+ 'system': {
+ 'hostname': socket.gethostname(),
+ 'platform': platform.platform(),
+ 'python': platform.python_version(),
+ 'version': _get_app_version(),
+ 'uptime_seconds': uptime_seconds,
+ 'uptime_human': _format_uptime(uptime_seconds),
+ },
+ 'processes': _collect_process_status(),
+ }
+
+ if _HAS_PSUTIL:
+ # CPU
+ cpu_percent = psutil.cpu_percent(interval=None)
+ cpu_count = psutil.cpu_count() or 1
+ try:
+ load_1, load_5, load_15 = os.getloadavg()
+ except (OSError, AttributeError):
+ load_1 = load_5 = load_15 = 0.0
+
+ metrics['cpu'] = {
+ 'percent': cpu_percent,
+ 'count': cpu_count,
+ 'load_1': round(load_1, 2),
+ 'load_5': round(load_5, 2),
+ 'load_15': round(load_15, 2),
+ }
+
+ # Memory
+ mem = psutil.virtual_memory()
+ metrics['memory'] = {
+ 'total': mem.total,
+ 'used': mem.used,
+ 'available': mem.available,
+ 'percent': mem.percent,
+ }
+
+ swap = psutil.swap_memory()
+ metrics['swap'] = {
+ 'total': swap.total,
+ 'used': swap.used,
+ 'percent': swap.percent,
+ }
+
+ # Disk
+ try:
+ disk = psutil.disk_usage('/')
+ metrics['disk'] = {
+ 'total': disk.total,
+ 'used': disk.used,
+ 'free': disk.free,
+ 'percent': disk.percent,
+ 'path': '/',
+ }
+ except Exception:
+ metrics['disk'] = None
+
+ # Temperatures
+ try:
+ temps = psutil.sensors_temperatures()
+ if temps:
+ temp_data: dict[str, list[dict[str, Any]]] = {}
+ for chip, entries in temps.items():
+ temp_data[chip] = [
+ {
+ 'label': e.label or chip,
+ 'current': e.current,
+ 'high': e.high,
+ 'critical': e.critical,
+ }
+ for e in entries
+ ]
+ metrics['temperatures'] = temp_data
+ else:
+ metrics['temperatures'] = None
+ except (AttributeError, Exception):
+ metrics['temperatures'] = None
+ else:
+ metrics['cpu'] = None
+ metrics['memory'] = None
+ metrics['swap'] = None
+ metrics['disk'] = None
+ metrics['temperatures'] = None
+
+ return metrics
+
+
+def _collector_loop() -> None:
+ """Background thread that pushes metrics onto the queue every 3 seconds."""
+ # Seed psutil's CPU measurement so the first real read isn't 0%.
+ if _HAS_PSUTIL:
+ with contextlib.suppress(Exception):
+ psutil.cpu_percent(interval=None)
+
+ while True:
+ try:
+ metrics = _collect_metrics()
+ # Non-blocking put — drop oldest if full
+ try:
+ _metrics_queue.put_nowait(metrics)
+ except queue.Full:
+ with contextlib.suppress(queue.Empty):
+ _metrics_queue.get_nowait()
+ _metrics_queue.put_nowait(metrics)
+ except Exception as exc:
+ logger.debug('system metrics collection error: %s', exc)
+ time.sleep(3)
+
+
+def _ensure_collector() -> None:
+ """Start the background collector thread once."""
+ global _collector_started
+ if _collector_started:
+ return
+ with _collector_lock:
+ if _collector_started:
+ return
+ t = threading.Thread(target=_collector_loop, daemon=True, name='system-metrics-collector')
+ t.start()
+ _collector_started = True
+ logger.info('System metrics collector started')
+
+
+# ---------------------------------------------------------------------------
+# Routes
+# ---------------------------------------------------------------------------
+
+
+@system_bp.route('/metrics')
+def get_metrics() -> Response:
+ """REST snapshot of current system metrics."""
+ _ensure_collector()
+ return jsonify(_collect_metrics())
+
+
+@system_bp.route('/stream')
+def stream_system() -> Response:
+ """SSE stream for real-time system metrics."""
+ _ensure_collector()
+
+ response = Response(
+ sse_stream_fanout(
+ source_queue=_metrics_queue,
+ channel_key='system',
+ timeout=SSE_QUEUE_TIMEOUT,
+ keepalive_interval=SSE_KEEPALIVE_INTERVAL,
+ ),
+ mimetype='text/event-stream',
+ )
+ response.headers['Cache-Control'] = 'no-cache'
+ response.headers['X-Accel-Buffering'] = 'no'
+ return response
+
+
+@system_bp.route('/sdr_devices')
+def get_sdr_devices() -> Response:
+ """Enumerate all connected SDR devices (on-demand, not every tick)."""
+ try:
+ from utils.sdr.detection import detect_all_devices
+
+ devices = detect_all_devices()
+ result = []
+ for d in devices:
+ result.append({
+ 'type': d.sdr_type.value if hasattr(d.sdr_type, 'value') else str(d.sdr_type),
+ 'index': d.index,
+ 'name': d.name,
+ 'serial': d.serial or '',
+ 'driver': d.driver or '',
+ })
+ return jsonify({'devices': result})
+ except Exception as exc:
+ logger.warning('SDR device detection failed: %s', exc)
+ return jsonify({'devices': [], 'error': str(exc)})
diff --git a/static/css/adsb_dashboard.css b/static/css/adsb_dashboard.css
index b196714..41b6515 100644
--- a/static/css/adsb_dashboard.css
+++ b/static/css/adsb_dashboard.css
@@ -1246,7 +1246,7 @@ body {
.control-group select {
padding: 4px 8px;
- background: rgba(0, 0, 0, 0.3);
+ background: var(--bg-dark);
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);
diff --git a/static/css/ais_dashboard.css b/static/css/ais_dashboard.css
index 4232caf..2dd5d2f 100644
--- a/static/css/ais_dashboard.css
+++ b/static/css/ais_dashboard.css
@@ -779,7 +779,7 @@ body {
.control-group select {
padding: 4px 8px;
- background: rgba(0, 0, 0, 0.3);
+ background: var(--bg-dark);
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);
diff --git a/static/css/modes/aprs.css b/static/css/modes/aprs.css
index 2ef4cc2..9f6b4e1 100644
--- a/static/css/modes/aprs.css
+++ b/static/css/modes/aprs.css
@@ -60,7 +60,7 @@
gap: 4px;
}
.aprs-strip .strip-select {
- background: rgba(0,0,0,0.3);
+ background: var(--bg-dark);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 8px;
diff --git a/static/css/modes/morse.css b/static/css/modes/morse.css
index 844bf49..2ea11fc 100644
--- a/static/css/modes/morse.css
+++ b/static/css/modes/morse.css
@@ -1,118 +1,201 @@
-/* Morse Code / CW Decoder Styles */
-
-/* Scope canvas container */
-.morse-scope-container {
- background: var(--bg-primary);
- border: 1px solid var(--border-color);
- border-radius: 6px;
- padding: 8px;
- margin-bottom: 12px;
-}
-
-.morse-scope-container canvas {
- width: 100%;
- height: 80px;
- display: block;
- border-radius: 4px;
-}
-
-/* Decoded text panel */
-.morse-decoded-panel {
- background: var(--bg-primary);
- border: 1px solid var(--border-color);
- border-radius: 6px;
- padding: 12px;
- min-height: 120px;
- max-height: 400px;
- overflow-y: auto;
- font-family: var(--font-mono);
- font-size: 18px;
- line-height: 1.6;
- color: var(--text-primary);
- word-wrap: break-word;
-}
-
-.morse-decoded-panel:empty::before {
- content: 'Decoded text will appear here...';
- color: var(--text-dim);
- font-size: 14px;
- font-style: italic;
-}
-
-/* Individual decoded character with fade-in */
-.morse-char {
- display: inline;
- animation: morseFadeIn 0.3s ease-out;
- position: relative;
-}
-
-@keyframes morseFadeIn {
- from {
- opacity: 0;
- color: var(--accent-cyan);
- }
- to {
- opacity: 1;
- color: var(--text-primary);
- }
-}
-
-/* Small Morse notation above character */
-.morse-char-morse {
- font-size: 9px;
- color: var(--text-dim);
- letter-spacing: 1px;
- display: block;
- line-height: 1;
- margin-bottom: -2px;
-}
-
-/* Reference grid */
-.morse-ref-grid {
- transition: max-height 0.3s ease, opacity 0.3s ease;
- max-height: 500px;
- opacity: 1;
- overflow: hidden;
-}
-
-.morse-ref-grid.collapsed {
- max-height: 0;
- opacity: 0;
-}
-
-/* Toolbar: export/copy/clear */
-.morse-toolbar {
- display: flex;
- gap: 6px;
- margin-bottom: 8px;
- flex-wrap: wrap;
-}
-
-.morse-toolbar .btn {
- font-size: 11px;
- padding: 4px 10px;
-}
-
-/* Status bar at bottom */
-.morse-status-bar {
- display: flex;
- justify-content: space-between;
- align-items: center;
- font-size: 11px;
- color: var(--text-dim);
- padding: 6px 0;
- border-top: 1px solid var(--border-color);
- margin-top: 8px;
-}
-
-.morse-status-bar .status-item {
- display: flex;
- align-items: center;
- gap: 4px;
-}
-
-/* Word space styling */
-.morse-word-space {
- display: inline;
- width: 0.5em;
-}
+/* Morse Code / CW Decoder Styles */
+
+.morse-mode-help,
+.morse-help-text {
+ font-size: 11px;
+ color: var(--text-dim);
+}
+
+.morse-help-text {
+ margin-top: 4px;
+ display: block;
+}
+
+.morse-hf-note {
+ font-size: 11px;
+ color: #ffaa00;
+ line-height: 1.5;
+}
+
+.morse-presets {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+.morse-actions-row {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.morse-file-row {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.morse-file-row input[type='file'] {
+ width: 100%;
+ max-width: 100%;
+}
+
+.morse-status {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ color: var(--text-dim);
+}
+
+.morse-status #morseCharCount {
+ margin-left: auto;
+}
+
+.morse-ref-grid {
+ transition: max-height 0.3s ease, opacity 0.3s ease;
+ max-height: 560px;
+ opacity: 1;
+ overflow: hidden;
+ font-family: var(--font-mono);
+ font-size: 10px;
+ line-height: 1.8;
+ columns: 2;
+ column-gap: 12px;
+ color: var(--text-dim);
+}
+
+.morse-ref-grid.collapsed {
+ max-height: 0;
+ opacity: 0;
+}
+
+.morse-ref-toggle {
+ font-size: 10px;
+ color: var(--text-dim);
+}
+
+.morse-ref-divider {
+ margin-top: 4px;
+ border-top: 1px solid var(--border-color);
+ padding-top: 4px;
+}
+
+.morse-decoded-panel {
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ padding: 12px;
+ min-height: 120px;
+ max-height: 400px;
+ overflow-y: auto;
+ font-family: var(--font-mono);
+ font-size: 18px;
+ line-height: 1.6;
+ color: var(--text-primary);
+ word-wrap: break-word;
+}
+
+.morse-decoded-panel:empty::before {
+ content: 'Decoded text will appear here...';
+ color: var(--text-dim);
+ font-size: 14px;
+ font-style: italic;
+}
+
+.morse-char {
+ display: inline;
+ animation: morseFadeIn 0.3s ease-out;
+ position: relative;
+}
+
+@keyframes morseFadeIn {
+ from {
+ opacity: 0;
+ color: var(--accent-cyan);
+ }
+ to {
+ opacity: 1;
+ color: var(--text-primary);
+ }
+}
+
+.morse-word-space {
+ display: inline;
+ width: 0.5em;
+}
+
+.morse-raw-panel {
+ margin-top: 8px;
+ padding: 8px;
+ border: 1px solid #1a1a2e;
+ border-radius: 4px;
+ background: #080812;
+}
+
+.morse-raw-label {
+ font-size: 10px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: #667;
+ margin-bottom: 4px;
+}
+
+.morse-raw-text {
+ min-height: 30px;
+ max-height: 90px;
+ overflow-y: auto;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: #8fd0ff;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.morse-metrics-panel {
+ margin-top: 8px;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 6px;
+ font-size: 10px;
+ color: #7a8694;
+}
+
+.morse-metrics-panel span {
+ padding: 4px 6px;
+ border-radius: 4px;
+ border: 1px solid #1a1a2e;
+ background: #080811;
+ font-family: var(--font-mono);
+}
+
+.morse-status-bar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 11px;
+ color: var(--text-dim);
+ padding: 6px 0;
+ border-top: 1px solid var(--border-color);
+ margin-top: 8px;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.morse-status-bar .status-item {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+@media (max-width: 768px) {
+ .morse-metrics-panel {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .morse-file-row {
+ flex-direction: column;
+ align-items: stretch;
+ }
+}
diff --git a/static/css/modes/system.css b/static/css/modes/system.css
new file mode 100644
index 0000000..3efd245
--- /dev/null
+++ b/static/css/modes/system.css
@@ -0,0 +1,215 @@
+/* System Health Mode Styles */
+
+.sys-dashboard {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+ gap: 16px;
+ padding: 16px;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.sys-card {
+ background: var(--bg-card, #1a1a2e);
+ border: 1px solid var(--border-color, #2a2a4a);
+ border-radius: 6px;
+ padding: 16px;
+ min-height: 120px;
+}
+
+.sys-card-header {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-dim, #8888aa);
+ margin-bottom: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.sys-card-body {
+ font-size: 12px;
+ color: var(--text-primary, #e0e0ff);
+ font-family: var(--font-mono, 'JetBrains Mono', monospace);
+}
+
+.sys-card-detail {
+ font-size: 11px;
+ color: var(--text-dim, #8888aa);
+ margin-top: 4px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Metric Bars */
+.sys-metric-bar-wrap {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 6px;
+}
+
+.sys-metric-bar-label {
+ font-size: 10px;
+ color: var(--text-dim, #8888aa);
+ min-width: 40px;
+ text-transform: uppercase;
+}
+
+.sys-metric-bar {
+ flex: 1;
+ height: 8px;
+ background: var(--bg-primary, #0d0d1a);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.sys-metric-bar-fill {
+ height: 100%;
+ border-radius: 4px;
+ transition: width 0.4s ease;
+}
+
+.sys-metric-bar-fill.ok {
+ background: var(--accent-green, #00ff88);
+}
+
+.sys-metric-bar-fill.warn {
+ background: var(--accent-yellow, #ffcc00);
+}
+
+.sys-metric-bar-fill.crit {
+ background: var(--accent-red, #ff3366);
+}
+
+.sys-metric-bar-value {
+ font-size: 12px;
+ font-weight: 700;
+ min-width: 36px;
+ text-align: right;
+ font-family: var(--font-mono, 'JetBrains Mono', monospace);
+}
+
+.sys-metric-na {
+ color: var(--text-dim, #8888aa);
+ font-style: italic;
+ font-size: 11px;
+}
+
+/* Process items */
+.sys-process-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 0;
+}
+
+.sys-process-name {
+ font-size: 12px;
+}
+
+.sys-process-dot {
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.sys-process-dot.running {
+ background: var(--accent-green, #00ff88);
+ box-shadow: 0 0 4px rgba(0, 255, 136, 0.4);
+}
+
+.sys-process-dot.stopped {
+ background: var(--text-dim, #555);
+}
+
+/* SDR Devices */
+.sys-sdr-device {
+ padding: 6px 0;
+ border-bottom: 1px solid var(--border-color, #2a2a4a);
+}
+
+.sys-sdr-device:last-child {
+ border-bottom: none;
+}
+
+.sys-rescan-btn {
+ font-size: 9px;
+ padding: 2px 8px;
+ background: transparent;
+ border: 1px solid var(--border-color, #2a2a4a);
+ color: var(--accent-cyan, #00d4ff);
+ border-radius: 3px;
+ cursor: pointer;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.sys-rescan-btn:hover {
+ background: var(--bg-primary, #0d0d1a);
+}
+
+/* Sidebar Quick Grid */
+.sys-quick-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 6px;
+}
+
+.sys-quick-item {
+ padding: 6px 8px;
+ background: var(--bg-primary, #0d0d1a);
+ border: 1px solid var(--border-color, #2a2a4a);
+ border-radius: 4px;
+ text-align: center;
+}
+
+.sys-quick-label {
+ display: block;
+ font-size: 9px;
+ color: var(--text-dim, #8888aa);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: 2px;
+}
+
+.sys-quick-value {
+ display: block;
+ font-size: 14px;
+ font-weight: 700;
+ font-family: var(--font-mono, 'JetBrains Mono', monospace);
+ color: var(--text-primary, #e0e0ff);
+}
+
+/* Color-coded quick values */
+.sys-val-ok {
+ color: var(--accent-green, #00ff88) !important;
+}
+
+.sys-val-warn {
+ color: var(--accent-yellow, #ffcc00) !important;
+}
+
+.sys-val-crit {
+ color: var(--accent-red, #ff3366) !important;
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .sys-dashboard {
+ grid-template-columns: 1fr;
+ padding: 8px;
+ gap: 10px;
+ }
+}
+
+@media (max-width: 1024px) and (min-width: 769px) {
+ .sys-dashboard {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
diff --git a/static/js/modes/morse.js b/static/js/modes/morse.js
index b594bf6..94d191e 100644
--- a/static/js/modes/morse.js
+++ b/static/js/modes/morse.js
@@ -1,400 +1,1220 @@
-/**
- * Morse Code (CW) decoder module.
- *
- * IIFE providing start/stop controls, SSE streaming, scope canvas,
- * decoded text display, and export capabilities.
- */
-var MorseMode = (function () {
- 'use strict';
-
- var state = {
- running: false,
- initialized: false,
- eventSource: null,
- charCount: 0,
- decodedLog: [], // { timestamp, morse, char }
- };
-
- // Scope state
- var scopeCtx = null;
- var scopeAnim = null;
- var scopeHistory = [];
- var SCOPE_HISTORY_LEN = 300;
- var scopeThreshold = 0;
- var scopeToneOn = false;
-
- // ---- Initialization ----
-
- function init() {
- if (state.initialized) {
- checkStatus();
- return;
- }
- state.initialized = true;
- checkStatus();
- }
-
- function destroy() {
- disconnectSSE();
- stopScope();
- }
-
- // ---- Status ----
-
- function checkStatus() {
- fetch('/morse/status')
- .then(function (r) { return r.json(); })
- .then(function (data) {
- if (data.running) {
- state.running = true;
- updateUI(true);
- connectSSE();
- startScope();
- } else {
- state.running = false;
- updateUI(false);
- }
- })
- .catch(function () {});
- }
-
- // ---- Start / Stop ----
-
- function start() {
- if (state.running) return;
-
- var payload = {
- frequency: document.getElementById('morseFrequency').value || '14.060',
- gain: document.getElementById('morseGain').value || '0',
- ppm: document.getElementById('morsePPM').value || '0',
- 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: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false,
- };
-
- fetch('/morse/start', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
- })
- .then(function (r) { return r.json(); })
- .then(function (data) {
- if (data.status === 'started') {
- state.running = true;
- state.charCount = 0;
- state.decodedLog = [];
- updateUI(true);
- connectSSE();
- startScope();
- clearDecodedText();
- } else {
- alert('Error: ' + (data.message || 'Unknown error'));
- }
- })
- .catch(function (err) {
- alert('Failed to start Morse decoder: ' + err);
- });
- }
-
- function stop() {
- fetch('/morse/stop', { method: 'POST' })
- .then(function (r) { return r.json(); })
- .then(function () {
- state.running = false;
- updateUI(false);
- disconnectSSE();
- stopScope();
- })
- .catch(function () {});
- }
-
- // ---- SSE ----
-
- function connectSSE() {
- disconnectSSE();
- var es = new EventSource('/morse/stream');
-
- es.onmessage = function (e) {
- try {
- var msg = JSON.parse(e.data);
- handleMessage(msg);
- } catch (_) {}
- };
-
- es.onerror = function () {
- // Reconnect handled by browser
- };
-
- state.eventSource = es;
- }
-
- function disconnectSSE() {
- if (state.eventSource) {
- state.eventSource.close();
- state.eventSource = null;
- }
- }
-
- function handleMessage(msg) {
- var type = msg.type;
-
- if (type === 'scope') {
- // Update scope data
- var amps = msg.amplitudes || [];
- for (var i = 0; i < amps.length; i++) {
- scopeHistory.push(amps[i]);
- if (scopeHistory.length > SCOPE_HISTORY_LEN) {
- scopeHistory.shift();
- }
- }
- scopeThreshold = msg.threshold || 0;
- scopeToneOn = msg.tone_on || false;
-
- } else if (type === 'morse_char') {
- appendChar(msg.char, msg.morse, msg.timestamp);
-
- } else if (type === 'morse_space') {
- appendSpace();
-
- } else if (type === 'status') {
- if (msg.status === 'stopped') {
- state.running = false;
- updateUI(false);
- disconnectSSE();
- stopScope();
- }
- } else if (type === 'error') {
- console.error('Morse error:', msg.text);
- }
- }
-
- // ---- Decoded text ----
-
- function appendChar(ch, morse, timestamp) {
- state.charCount++;
- state.decodedLog.push({ timestamp: timestamp, morse: morse, char: ch });
-
- var panel = document.getElementById('morseDecodedText');
- if (!panel) return;
-
- var span = document.createElement('span');
- span.className = 'morse-char';
- span.textContent = ch;
- span.title = morse + ' (' + timestamp + ')';
- panel.appendChild(span);
-
- // Auto-scroll
- panel.scrollTop = panel.scrollHeight;
-
- // 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() {
- var panel = document.getElementById('morseDecodedText');
- if (!panel) return;
-
- var span = document.createElement('span');
- span.className = 'morse-word-space';
- span.textContent = ' ';
- panel.appendChild(span);
- }
-
- function clearDecodedText() {
- var panel = document.getElementById('morseDecodedText');
- if (panel) panel.innerHTML = '';
- state.charCount = 0;
- 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 ----
-
- function startScope() {
- var canvas = document.getElementById('morseScopeCanvas');
- if (!canvas) return;
-
- var dpr = window.devicePixelRatio || 1;
- var rect = canvas.getBoundingClientRect();
- canvas.width = rect.width * dpr;
- 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 = 80;
-
- 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;
- }
-
- // Find max for normalization
- var maxVal = 0;
- for (var i = 0; i < scopeHistory.length; i++) {
- if (scopeHistory[i] > maxVal) maxVal = scopeHistory[i];
- }
- if (maxVal === 0) maxVal = 1;
-
- var barW = w / SCOPE_HISTORY_LEN;
- var threshNorm = scopeThreshold / maxVal;
-
- // Draw amplitude bars
- for (var j = 0; j < scopeHistory.length; j++) {
- var norm = scopeHistory[j] / maxVal;
- var barH = norm * (h - 10);
- var x = j * barW;
- var y = h - barH;
-
- // Green if above threshold, gray if below
- if (scopeHistory[j] > scopeThreshold) {
- scopeCtx.fillStyle = '#00ff88';
- } else {
- scopeCtx.fillStyle = '#334455';
- }
- scopeCtx.fillRect(x, y, Math.max(barW - 1, 1), barH);
- }
-
- // Draw threshold line
- if (scopeThreshold > 0) {
- var threshY = h - (threshNorm * (h - 10));
- scopeCtx.strokeStyle = '#ff4444';
- scopeCtx.lineWidth = 1;
- scopeCtx.setLineDash([4, 4]);
- scopeCtx.beginPath();
- scopeCtx.moveTo(0, threshY);
- scopeCtx.lineTo(w, threshY);
- scopeCtx.stroke();
- scopeCtx.setLineDash([]);
- }
-
- // Tone indicator
- if (scopeToneOn) {
- scopeCtx.fillStyle = '#00ff88';
- scopeCtx.beginPath();
- scopeCtx.arc(w - 12, 12, 5, 0, Math.PI * 2);
- scopeCtx.fill();
- }
-
- scopeAnim = requestAnimationFrame(draw);
- }
-
- draw();
- }
-
- function stopScope() {
- if (scopeAnim) {
- cancelAnimationFrame(scopeAnim);
- scopeAnim = null;
- }
- scopeCtx = null;
- }
-
- // ---- Export ----
-
- function exportTxt() {
- var text = state.decodedLog.map(function (e) { return e.char; }).join('');
- downloadFile('morse_decoded.txt', text, 'text/plain');
- }
-
- function exportCsv() {
- var lines = ['timestamp,morse,character'];
- state.decodedLog.forEach(function (e) {
- lines.push(e.timestamp + ',"' + e.morse + '",' + e.char);
- });
- downloadFile('morse_decoded.csv', lines.join('\n'), 'text/csv');
- }
-
- function copyToClipboard() {
- var text = state.decodedLog.map(function (e) { return e.char; }).join('');
- navigator.clipboard.writeText(text).then(function () {
- var btn = document.getElementById('morseCopyBtn');
- if (btn) {
- var orig = btn.textContent;
- btn.textContent = 'Copied!';
- setTimeout(function () { btn.textContent = orig; }, 1500);
- }
- });
- }
-
- function downloadFile(filename, content, type) {
- var blob = new Blob([content], { type: type });
- var url = URL.createObjectURL(blob);
- var a = document.createElement('a');
- a.href = url;
- a.download = filename;
- a.click();
- URL.revokeObjectURL(url);
- }
-
- // ---- UI ----
-
- function updateUI(running) {
- var startBtn = document.getElementById('morseStartBtn');
- var stopBtn = document.getElementById('morseStopBtn');
- var indicator = document.getElementById('morseStatusIndicator');
- var statusText = document.getElementById('morseStatusText');
-
- if (startBtn) startBtn.style.display = running ? 'none' : '';
- if (stopBtn) stopBtn.style.display = running ? '' : 'none';
-
- if (indicator) {
- indicator.style.background = running ? '#00ff88' : 'var(--text-dim)';
- }
- 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) {
- var el = document.getElementById('morseFrequency');
- if (el) el.value = mhz;
- }
-
- // ---- Public API ----
-
- return {
- init: init,
- destroy: destroy,
- start: start,
- stop: stop,
- setFreq: setFreq,
- exportTxt: exportTxt,
- exportCsv: exportCsv,
- copyToClipboard: copyToClipboard,
- clearText: clearDecodedText,
- };
-})();
+/**
+ * Morse Code (CW) decoder mode.
+ * Lifecycle state machine: idle -> starting -> running -> stopping -> idle/error
+ */
+var MorseMode = (function () {
+ 'use strict';
+
+ var SETTINGS_KEY = 'intercept.morse.settings.v3';
+ var STATUS_POLL_MS = 5000;
+ var LOCAL_STOP_TIMEOUT_MS = 12000;
+ var START_TIMEOUT_MS = 60000;
+
+ var state = {
+ initialized: false,
+ controlsBound: false,
+ lifecycle: 'idle',
+ eventSource: null,
+ statusPollTimer: null,
+ stopPromise: null,
+ startSeq: 0,
+ charCount: 0,
+ decodedLog: [], // { timestamp, morse, char }
+ rawLog: [],
+ waiting: false,
+ waitingStart: 0,
+ lastMetrics: {
+ wpm: 15,
+ tone_freq: 700,
+ level: 0,
+ threshold: 0,
+ noise_floor: 0,
+ stop_ms: null,
+ },
+ };
+
+ // Scope state
+ var scopeCtx = null;
+ var scopeAnim = null;
+ var scopeHistory = [];
+ var scopeThreshold = 0;
+ var scopeToneOn = false;
+ var scopeWaiting = false;
+ var waitingStart = 0;
+ var scopeRect = null;
+ var SCOPE_HISTORY_LEN = 300;
+
+ function el(id) {
+ return document.getElementById(id);
+ }
+
+ function notifyInfo(text) {
+ if (typeof showInfo === 'function') {
+ showInfo(text);
+ } else {
+ console.info(text);
+ }
+ }
+
+ function notifyError(text) {
+ if (typeof showError === 'function') {
+ showError(text);
+ } else {
+ alert(text);
+ }
+ }
+
+ function parseJsonSafe(response) {
+ return response.json().catch(function () { return {}; });
+ }
+
+ function postJson(url, payload, timeoutMs) {
+ var controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
+ var timeoutId = controller ? setTimeout(function () { controller.abort(); }, timeoutMs) : null;
+
+ return fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload || {}),
+ signal: controller ? controller.signal : undefined,
+ }).then(function (response) {
+ return parseJsonSafe(response).then(function (data) {
+ if (!response.ok) {
+ var msg = data.message || data.error || ('HTTP ' + response.status);
+ throw new Error(msg);
+ }
+ return data;
+ });
+ }).catch(function (err) {
+ if (err && err.name === 'AbortError') {
+ throw new Error('Request timed out');
+ }
+ throw err;
+ }).finally(function () {
+ if (timeoutId) clearTimeout(timeoutId);
+ });
+ }
+
+ function collectConfig() {
+ return {
+ frequency: (el('morseFrequency') && el('morseFrequency').value) || '14.060',
+ gain: (el('morseGain') && el('morseGain').value) || '40',
+ ppm: (el('morsePPM') && el('morsePPM').value) || '0',
+ device: (el('deviceSelect') && el('deviceSelect').value) || '0',
+ sdr_type: (el('sdrTypeSelect') && el('sdrTypeSelect').value) || 'rtlsdr',
+ bias_t: (typeof getBiasTEnabled === 'function') ? getBiasTEnabled() : false,
+ tone_freq: (el('morseToneFreq') && el('morseToneFreq').value) || '700',
+ bandwidth_hz: (el('morseBandwidth') && el('morseBandwidth').value) || '200',
+ auto_tone_track: !!(el('morseAutoToneTrack') && el('morseAutoToneTrack').checked),
+ tone_lock: !!(el('morseToneLock') && el('morseToneLock').checked),
+ threshold_mode: (el('morseThresholdMode') && el('morseThresholdMode').value) || 'auto',
+ manual_threshold: (el('morseManualThreshold') && el('morseManualThreshold').value) || '0',
+ threshold_multiplier: (el('morseThresholdMultiplier') && el('morseThresholdMultiplier').value) || '2.8',
+ threshold_offset: (el('morseThresholdOffset') && el('morseThresholdOffset').value) || '0',
+ signal_gate: (el('morseSignalGate') && el('morseSignalGate').value) || '0.05',
+ wpm_mode: (el('morseWpmMode') && el('morseWpmMode').value) || 'auto',
+ wpm: (el('morseWpm') && el('morseWpm').value) || '15',
+ wpm_lock: !!(el('morseWpmLock') && el('morseWpmLock').checked),
+ };
+ }
+
+ function persistSettings() {
+ try {
+ var payload = {
+ frequency: (el('morseFrequency') && el('morseFrequency').value) || '14.060',
+ gain: (el('morseGain') && el('morseGain').value) || '40',
+ ppm: (el('morsePPM') && el('morsePPM').value) || '0',
+ tone_freq: (el('morseToneFreq') && el('morseToneFreq').value) || '700',
+ bandwidth_hz: (el('morseBandwidth') && el('morseBandwidth').value) || '200',
+ auto_tone_track: !!(el('morseAutoToneTrack') && el('morseAutoToneTrack').checked),
+ tone_lock: !!(el('morseToneLock') && el('morseToneLock').checked),
+ threshold_mode: (el('morseThresholdMode') && el('morseThresholdMode').value) || 'auto',
+ manual_threshold: (el('morseManualThreshold') && el('morseManualThreshold').value) || '0',
+ threshold_multiplier: (el('morseThresholdMultiplier') && el('morseThresholdMultiplier').value) || '2.8',
+ threshold_offset: (el('morseThresholdOffset') && el('morseThresholdOffset').value) || '0',
+ signal_gate: (el('morseSignalGate') && el('morseSignalGate').value) || '0.05',
+ wpm_mode: (el('morseWpmMode') && el('morseWpmMode').value) || 'auto',
+ wpm: (el('morseWpm') && el('morseWpm').value) || '15',
+ wpm_lock: !!(el('morseWpmLock') && el('morseWpmLock').checked),
+ show_raw: !!(el('morseShowRaw') && el('morseShowRaw').checked),
+ show_diag: !!(el('morseShowDiag') && el('morseShowDiag').checked),
+ };
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(payload));
+ } catch (_) {
+ // Ignore local storage errors.
+ }
+ }
+
+ function applySettings(settings) {
+ if (!settings || typeof settings !== 'object') return;
+
+ if (el('morseFrequency') && settings.frequency !== undefined) el('morseFrequency').value = settings.frequency;
+ if (el('morseGain') && settings.gain !== undefined) el('morseGain').value = settings.gain;
+ if (el('morsePPM') && settings.ppm !== undefined) el('morsePPM').value = settings.ppm;
+ if (el('morseToneFreq') && settings.tone_freq !== undefined) el('morseToneFreq').value = settings.tone_freq;
+ if (el('morseBandwidth') && settings.bandwidth_hz !== undefined) el('morseBandwidth').value = settings.bandwidth_hz;
+ if (el('morseThresholdMode') && settings.threshold_mode !== undefined) el('morseThresholdMode').value = settings.threshold_mode;
+ if (el('morseManualThreshold') && settings.manual_threshold !== undefined) el('morseManualThreshold').value = settings.manual_threshold;
+ if (el('morseThresholdMultiplier') && settings.threshold_multiplier !== undefined) el('morseThresholdMultiplier').value = settings.threshold_multiplier;
+ if (el('morseThresholdOffset') && settings.threshold_offset !== undefined) el('morseThresholdOffset').value = settings.threshold_offset;
+ if (el('morseSignalGate') && settings.signal_gate !== undefined) el('morseSignalGate').value = settings.signal_gate;
+ if (el('morseWpmMode') && settings.wpm_mode !== undefined) el('morseWpmMode').value = settings.wpm_mode;
+ if (el('morseWpm') && settings.wpm !== undefined) el('morseWpm').value = settings.wpm;
+
+ if (el('morseAutoToneTrack') && settings.auto_tone_track !== undefined) el('morseAutoToneTrack').checked = !!settings.auto_tone_track;
+ if (el('morseToneLock') && settings.tone_lock !== undefined) el('morseToneLock').checked = !!settings.tone_lock;
+ if (el('morseWpmLock') && settings.wpm_lock !== undefined) el('morseWpmLock').checked = !!settings.wpm_lock;
+ if (el('morseShowRaw') && settings.show_raw !== undefined) el('morseShowRaw').checked = !!settings.show_raw;
+ if (el('morseShowDiag') && settings.show_diag !== undefined) el('morseShowDiag').checked = !!settings.show_diag;
+
+ updateToneLabel((el('morseToneFreq') && el('morseToneFreq').value) || '700');
+ updateWpmLabel((el('morseWpm') && el('morseWpm').value) || '15');
+ onThresholdModeChange();
+ onWpmModeChange();
+ toggleRawPanel();
+ toggleDiagPanel();
+ }
+
+ function loadSettings() {
+ try {
+ var raw = localStorage.getItem(SETTINGS_KEY);
+ if (!raw) {
+ if (el('morseShowDiag')) el('morseShowDiag').checked = true;
+ toggleDiagPanel();
+ persistSettings();
+ return;
+ }
+ var parsed = JSON.parse(raw);
+ applySettings(parsed);
+ } catch (_) {
+ // Ignore malformed settings.
+ if (el('morseShowDiag')) el('morseShowDiag').checked = true;
+ toggleDiagPanel();
+ }
+ }
+
+ function bindControls() {
+ if (state.controlsBound) return;
+ state.controlsBound = true;
+
+ var ids = [
+ 'morseFrequency', 'morseGain', 'morsePPM', 'morseToneFreq', 'morseBandwidth',
+ 'morseAutoToneTrack', 'morseToneLock', 'morseThresholdMode', 'morseManualThreshold',
+ 'morseThresholdMultiplier', 'morseThresholdOffset', 'morseSignalGate',
+ 'morseWpmMode', 'morseWpm', 'morseWpmLock', 'morseShowRaw', 'morseShowDiag'
+ ];
+
+ ids.forEach(function (id) {
+ var node = el(id);
+ if (!node) return;
+ node.addEventListener('change', persistSettings);
+ if (node.tagName === 'INPUT' && (node.type === 'range' || node.type === 'number' || node.type === 'text')) {
+ node.addEventListener('input', persistSettings);
+ }
+ });
+
+ if (el('morseShowRaw')) {
+ el('morseShowRaw').addEventListener('change', toggleRawPanel);
+ }
+ if (el('morseShowDiag')) {
+ el('morseShowDiag').addEventListener('change', toggleDiagPanel);
+ }
+ }
+
+ function setLifecycle(next) {
+ state.lifecycle = next;
+ updateUI();
+ }
+
+ function isTransition() {
+ return state.lifecycle === 'starting' || state.lifecycle === 'stopping';
+ }
+
+ function isActive() {
+ return state.lifecycle === 'starting' || state.lifecycle === 'running' || state.lifecycle === 'stopping';
+ }
+
+ function init() {
+ bindControls();
+
+ if (state.initialized) {
+ checkStatus();
+ return;
+ }
+
+ state.initialized = true;
+ loadSettings();
+ updateUI();
+ checkStatus();
+
+ if (!state.statusPollTimer) {
+ state.statusPollTimer = setInterval(checkStatus, STATUS_POLL_MS);
+ }
+ }
+
+ function destroy() {
+ if (state.statusPollTimer) {
+ clearInterval(state.statusPollTimer);
+ state.statusPollTimer = null;
+ }
+
+ if (state.lifecycle === 'running' || state.lifecycle === 'starting') {
+ stop({ silent: true }).catch(function () { });
+ } else {
+ disconnectSSE();
+ stopScope();
+ }
+
+ state.initialized = false;
+ }
+
+ function start() {
+ if (state.lifecycle === 'running' || state.lifecycle === 'starting') {
+ return Promise.resolve({ status: 'already_running' });
+ }
+
+ if (state.lifecycle === 'stopping' && state.stopPromise) {
+ return state.stopPromise.then(function () {
+ return start();
+ });
+ }
+
+ if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('morse')) {
+ return Promise.resolve({ status: 'blocked' });
+ }
+
+ clearDiagLog();
+ clearDecodedText();
+ clearRawText();
+ appendDiagLine('[start] requesting decoder startup...');
+
+ var payload = collectConfig();
+ persistSettings();
+
+ var seq = ++state.startSeq;
+ setLifecycle('starting');
+
+ return postJson('/morse/start', payload, START_TIMEOUT_MS)
+ .then(function (data) {
+ if (seq !== state.startSeq) {
+ return data;
+ }
+
+ if (data.status !== 'started') {
+ throw new Error(data.message || 'Failed to start Morse decoder');
+ }
+
+ if (typeof reserveDevice === 'function') {
+ var parsedDevice = Number(payload.device);
+ if (Number.isFinite(parsedDevice)) {
+ reserveDevice(parsedDevice, 'morse');
+ }
+ }
+
+ setLifecycle('running');
+ connectSSE();
+ startScope();
+ setStatusText('Listening');
+ applyMetrics(data.config || {}, true);
+ appendDiagLine('[start] decoder started');
+ notifyInfo('Morse decoder started');
+ return data;
+ })
+ .catch(function (err) {
+ if (seq !== state.startSeq) {
+ return { status: 'stale' };
+ }
+ var initialErrorMsg = String(err && err.message ? err.message : err);
+ if (initialErrorMsg === 'Request timed out while waiting for decoder startup') {
+ return fetch('/morse/status')
+ .then(function (r) { return parseJsonSafe(r); })
+ .then(function (statusData) {
+ var statusError = statusData && (statusData.error || statusData.message);
+ var resolvedError = statusError ? String(statusError) : initialErrorMsg;
+ setLifecycle('error');
+ setStatusText('Start failed');
+ appendDiagLine('[start] failed: ' + resolvedError);
+ notifyError('Failed to start Morse decoder: ' + resolvedError);
+ return { status: 'error', message: resolvedError };
+ })
+ .catch(function () {
+ setLifecycle('error');
+ setStatusText('Start failed');
+ appendDiagLine('[start] failed: ' + initialErrorMsg);
+ notifyError('Failed to start Morse decoder: ' + initialErrorMsg);
+ return { status: 'error', message: initialErrorMsg };
+ });
+ }
+ setLifecycle('error');
+ setStatusText('Start failed');
+ appendDiagLine('[start] failed: ' + initialErrorMsg);
+ notifyError('Failed to start Morse decoder: ' + initialErrorMsg);
+ return { status: 'error', message: initialErrorMsg };
+ });
+ }
+
+ function stop(options) {
+ options = options || {};
+
+ if (state.stopPromise) {
+ return state.stopPromise;
+ }
+
+ var currentlyActive = isActive();
+ if (!currentlyActive && !options.force) {
+ disconnectSSE();
+ stopScope();
+ setLifecycle('idle');
+ if (typeof releaseDevice === 'function') releaseDevice('morse');
+ return Promise.resolve({ status: 'not_running' });
+ }
+
+ state.startSeq += 1; // invalidate in-flight start responses
+ setLifecycle('stopping');
+ setStatusText('Stopping...');
+
+ disconnectSSE();
+ stopScope();
+ if (typeof releaseDevice === 'function') {
+ releaseDevice('morse');
+ }
+
+ var stopPromise;
+ if (options.skipRequest) {
+ stopPromise = Promise.resolve({ status: 'skipped' });
+ } else {
+ stopPromise = postJson('/morse/stop', {}, LOCAL_STOP_TIMEOUT_MS)
+ .catch(function (err) {
+ appendDiagLine('[stop] ' + (err && err.message ? err.message : err));
+ return { status: 'error', message: String(err && err.message ? err.message : err) };
+ });
+ }
+
+ state.stopPromise = stopPromise.then(function (data) {
+ if (data && data.stop_ms !== undefined) {
+ state.lastMetrics.stop_ms = Number(data.stop_ms);
+ updateMetricLabel('morseMetricStopMs', 'STOP ' + Math.round(state.lastMetrics.stop_ms) + ' ms');
+ }
+
+ if (data && Array.isArray(data.cleanup_steps)) {
+ appendDiagLine('[stop] ' + data.cleanup_steps.join(' | '));
+ }
+ if (data && Array.isArray(data.alive) && data.alive.length) {
+ appendDiagLine('[stop] still alive: ' + data.alive.join(', '));
+ }
+
+ if (!data || data.status === 'error') {
+ return data; // Stay in 'stopping' — let checkStatus resolve
+ }
+ setLifecycle('idle');
+ setStatusText('Standby');
+ return data;
+ }).finally(function () {
+ state.stopPromise = null;
+ });
+
+ return state.stopPromise;
+ }
+
+ function checkStatus() {
+ if (!state.initialized) return;
+ if (state.stopPromise) return; // Don't poll during in-flight stop
+
+ fetch('/morse/status')
+ .then(function (r) { return parseJsonSafe(r); })
+ .then(function (data) {
+ if (!data || typeof data !== 'object') return;
+ // Guard against in-flight polls that were dispatched before stop
+ if (state.stopPromise) return;
+
+ if (data.running) {
+ if (state.lifecycle === 'stopping') return; // Don't override post-timeout stopping
+ if (data.state === 'starting') {
+ setLifecycle('starting');
+ } else if (data.state === 'stopping') {
+ setLifecycle('stopping');
+ } else {
+ setLifecycle('running');
+ }
+
+ if (!state.eventSource) connectSSE();
+ if (!scopeAnim && state.lifecycle === 'running') startScope();
+
+ var message = data.message || (state.lifecycle === 'running' ? 'Listening' : data.state);
+ setStatusText(message);
+ if (data.config) {
+ applyMetrics(data.config, true);
+ }
+ } else if (state.lifecycle === 'running' || state.lifecycle === 'starting' || state.lifecycle === 'stopping') {
+ disconnectSSE();
+ stopScope();
+ setLifecycle('idle');
+ setStatusText('Standby');
+ if (typeof releaseDevice === 'function') {
+ releaseDevice('morse');
+ }
+ }
+
+ if (data.error) {
+ appendDiagLine('[status] ' + data.error);
+ }
+ })
+ .catch(function () {
+ // Ignore status polling errors.
+ });
+ }
+
+ function connectSSE() {
+ disconnectSSE();
+
+ var es = new EventSource('/morse/stream');
+ es.onmessage = function (e) {
+ try {
+ var msg = JSON.parse(e.data);
+ handleMessage(msg);
+ } catch (_) {
+ // Ignore malformed events.
+ }
+ };
+
+ es.onerror = function () {
+ if (state.lifecycle === 'running') {
+ appendDiagLine('[stream] reconnecting...');
+ }
+ };
+
+ state.eventSource = es;
+ }
+
+ function disconnectSSE() {
+ if (state.eventSource) {
+ state.eventSource.close();
+ state.eventSource = null;
+ }
+ }
+
+ function handleMessage(msg) {
+ if (!msg || typeof msg !== 'object') return;
+
+ var type = msg.type;
+
+ if (type === 'scope') {
+ handleScope(msg);
+ applyMetrics(msg, false);
+ return;
+ }
+
+ if (type === 'morse_char') {
+ appendChar(msg.char, msg.morse, msg.timestamp || '--:--:--');
+ return;
+ }
+
+ if (type === 'morse_space') {
+ appendSpace();
+ appendRawToken(' // ');
+ return;
+ }
+
+ if (type === 'morse_element') {
+ appendRawToken(msg.element || '');
+ return;
+ }
+
+ if (type === 'morse_gap') {
+ if (msg.gap === 'char') {
+ appendRawToken(' / ');
+ } else if (msg.gap === 'word') {
+ appendRawToken(' // ');
+ }
+ return;
+ }
+
+ if (type === 'status') {
+ handleStatus(msg);
+ return;
+ }
+
+ if (type === 'info') {
+ appendDiagLine(msg.text || '[info]');
+ return;
+ }
+
+ if (type === 'error') {
+ appendDiagLine('[error] ' + (msg.text || 'Decoder error'));
+ return;
+ }
+ }
+
+ function handleStatus(msg) {
+ var stateValue = String(msg.state || msg.status || '').toLowerCase();
+ if (stateValue === 'starting') {
+ setLifecycle('starting');
+ setStatusText('Starting...');
+ } else if (stateValue === 'running') {
+ setLifecycle('running');
+ setStatusText('Listening');
+ } else if (stateValue === 'stopping') {
+ setLifecycle('stopping');
+ setStatusText('Stopping...');
+ }
+
+ if (msg.metrics) {
+ applyMetrics(msg.metrics, false);
+ }
+
+ if (msg.stop_ms !== undefined) {
+ state.lastMetrics.stop_ms = Number(msg.stop_ms);
+ updateMetricLabel('morseMetricStopMs', 'STOP ' + Math.round(state.lastMetrics.stop_ms) + ' ms');
+ }
+
+ if (msg.cleanup_steps && Array.isArray(msg.cleanup_steps)) {
+ appendDiagLine('[cleanup] ' + msg.cleanup_steps.join(' | '));
+ }
+
+ if (msg.alive && Array.isArray(msg.alive) && msg.alive.length) {
+ appendDiagLine('[cleanup] alive: ' + msg.alive.join(', '));
+ }
+
+ if (msg.status === 'stopped' || stateValue === 'idle') {
+ disconnectSSE();
+ stopScope();
+ setLifecycle('idle');
+ setStatusText('Standby');
+ if (typeof releaseDevice === 'function') {
+ releaseDevice('morse');
+ }
+ }
+ }
+
+ function handleScope(msg) {
+ var amps = Array.isArray(msg.amplitudes) ? msg.amplitudes : [];
+
+ if (msg.waiting && amps.length === 0) {
+ if (!scopeWaiting) {
+ scopeWaiting = true;
+ waitingStart = Date.now();
+ appendDiagLine('[morse] waiting for PCM stream...');
+ }
+ var waitElapsedMs = waitingStart ? (Date.now() - waitingStart) : 0;
+ if (waitElapsedMs > 10000 && el('morseDiagLog') && el('morseDiagLog').children.length < 6) {
+ appendDiagLine('[hint] No samples after 10s. Check SDR device, frequency, and HF direct sampling path.');
+ }
+ } else if (amps.length > 0) {
+ scopeWaiting = false;
+ waitingStart = 0;
+ }
+
+ for (var i = 0; i < amps.length; i++) {
+ scopeHistory.push(amps[i]);
+ if (scopeHistory.length > SCOPE_HISTORY_LEN) {
+ scopeHistory.shift();
+ }
+ }
+
+ scopeThreshold = Number(msg.threshold) || 0;
+ scopeToneOn = !!msg.tone_on;
+
+ if (msg.tone_freq !== undefined) {
+ state.lastMetrics.tone_freq = Number(msg.tone_freq) || state.lastMetrics.tone_freq;
+ }
+ if (msg.wpm !== undefined) {
+ state.lastMetrics.wpm = Number(msg.wpm) || state.lastMetrics.wpm;
+ }
+ }
+
+ function applyMetrics(metrics, fromConfig) {
+ if (!metrics || typeof metrics !== 'object') return;
+
+ if (metrics.wpm !== undefined) {
+ state.lastMetrics.wpm = Number(metrics.wpm) || state.lastMetrics.wpm;
+ }
+
+ if (metrics.tone_freq !== undefined) {
+ state.lastMetrics.tone_freq = Number(metrics.tone_freq) || state.lastMetrics.tone_freq;
+ }
+
+ if (metrics.level !== undefined) {
+ state.lastMetrics.level = Number(metrics.level) || 0;
+ }
+
+ if (metrics.threshold !== undefined) {
+ state.lastMetrics.threshold = Number(metrics.threshold) || 0;
+ } else if (fromConfig && metrics.manual_threshold !== undefined) {
+ state.lastMetrics.threshold = Number(metrics.manual_threshold) || state.lastMetrics.threshold;
+ }
+
+ if (metrics.noise_floor !== undefined) {
+ state.lastMetrics.noise_floor = Number(metrics.noise_floor) || 0;
+ }
+
+ if (metrics.snr !== undefined) {
+ state.lastMetrics.snr = Number(metrics.snr) || 0;
+ }
+ if (metrics.noise_ref !== undefined) {
+ state.lastMetrics.noise_ref = Number(metrics.noise_ref) || 0;
+ }
+ if (metrics.snr_on !== undefined) {
+ state.lastMetrics.snr_on = Number(metrics.snr_on) || 0;
+ }
+ if (metrics.snr_off !== undefined) {
+ state.lastMetrics.snr_off = Number(metrics.snr_off) || 0;
+ }
+
+ updateMetricLabel('morseMetricTone', 'TONE ' + Math.round(state.lastMetrics.tone_freq || 700) + ' Hz');
+ updateMetricLabel('morseMetricLevel', 'SNR ' + (state.lastMetrics.snr || 0).toFixed(2) + ' (on>' + (state.lastMetrics.snr_on || 0).toFixed(2) + ' off>' + (state.lastMetrics.snr_off || 0).toFixed(2) + ')');
+ updateMetricLabel('morseMetricThreshold', 'THRESH ' + (state.lastMetrics.threshold || 0).toFixed(2));
+ updateMetricLabel('morseMetricNoise', 'NOISE_REF ' + (state.lastMetrics.noise_ref || 0).toFixed(4));
+
+ var toneScope = el('morseScopeToneLabel');
+ if (toneScope) {
+ toneScope.textContent = scopeToneOn ? 'ON' : '--';
+ }
+
+ var thresholdScope = el('morseScopeThreshLabel');
+ if (thresholdScope) {
+ thresholdScope.textContent = state.lastMetrics.threshold > 0
+ ? Math.round(state.lastMetrics.threshold)
+ : '--';
+ }
+
+ var barWpm = el('morseStatusBarWpm');
+ if (barWpm) barWpm.textContent = Math.round(state.lastMetrics.wpm || 0) + ' WPM';
+
+ var barTone = el('morseStatusBarTone');
+ if (barTone) barTone.textContent = Math.round(state.lastMetrics.tone_freq || 700) + ' Hz';
+
+ var metricState = el('morseMetricState');
+ if (metricState) metricState.textContent = 'STATE ' + state.lifecycle;
+ }
+
+ function appendChar(ch, morse, timestamp) {
+ if (!ch) return;
+
+ state.charCount += 1;
+ state.decodedLog.push({
+ timestamp: timestamp || '--:--:--',
+ morse: morse || '',
+ char: ch,
+ });
+
+ var panel = el('morseDecodedText');
+ if (panel) {
+ var span = document.createElement('span');
+ span.className = 'morse-char';
+ span.textContent = ch;
+ span.title = (morse || '') + ' (' + (timestamp || '--:--:--') + ')';
+ panel.appendChild(span);
+ panel.scrollTop = panel.scrollHeight;
+ }
+
+ updateCharCounts();
+ }
+
+ function appendSpace() {
+ var panel = el('morseDecodedText');
+ if (!panel) return;
+
+ var span = document.createElement('span');
+ span.className = 'morse-word-space';
+ span.textContent = ' ';
+ panel.appendChild(span);
+ panel.scrollTop = panel.scrollHeight;
+ }
+
+ function appendRawToken(token) {
+ if (!token) return;
+ state.rawLog.push(token);
+ if (state.rawLog.length > 2000) {
+ state.rawLog.splice(0, state.rawLog.length - 2000);
+ }
+
+ var rawText = el('morseRawText');
+ if (rawText) {
+ rawText.textContent = state.rawLog.join('');
+ rawText.scrollTop = rawText.scrollHeight;
+ }
+ }
+
+ function clearRawText() {
+ state.rawLog = [];
+ var rawText = el('morseRawText');
+ if (rawText) rawText.textContent = '';
+ }
+
+ function updateCharCounts() {
+ var countEl = el('morseCharCount');
+ if (countEl) countEl.textContent = state.charCount + ' chars';
+
+ var barChars = el('morseStatusBarChars');
+ if (barChars) barChars.textContent = state.charCount + ' chars decoded';
+ }
+
+ function clearDecodedText() {
+ state.charCount = 0;
+ state.decodedLog = [];
+
+ var panel = el('morseDecodedText');
+ if (panel) panel.innerHTML = '';
+
+ updateCharCounts();
+ }
+
+ function startScope() {
+ var canvas = el('morseScopeCanvas');
+ if (!canvas) return;
+
+ var rect = canvas.getBoundingClientRect();
+ if (!rect.width) return;
+
+ var dpr = window.devicePixelRatio || 1;
+ canvas.width = Math.max(1, Math.floor(rect.width * dpr));
+ canvas.height = Math.max(1, Math.floor(80 * dpr));
+ canvas.style.height = '80px';
+
+ scopeCtx = canvas.getContext('2d');
+ if (!scopeCtx) return;
+ scopeCtx.setTransform(1, 0, 0, 1, 0, 0);
+ scopeCtx.scale(dpr, dpr);
+
+ scopeHistory = [];
+ scopeRect = rect;
+
+ if (scopeAnim) {
+ cancelAnimationFrame(scopeAnim);
+ scopeAnim = null;
+ }
+
+ function draw() {
+ if (!scopeCtx || !scopeRect) return;
+
+ var w = scopeRect.width;
+ var h = 80;
+
+ scopeCtx.fillStyle = '#050510';
+ scopeCtx.fillRect(0, 0, w, h);
+
+ if (scopeHistory.length === 0) {
+ if (scopeWaiting) {
+ var elapsed = waitingStart ? (Date.now() - waitingStart) / 1000 : 0;
+ var text = elapsed > 10 ? 'No audio data - check SDR log below' : 'Awaiting SDR data...';
+ scopeCtx.fillStyle = elapsed > 10 ? '#887744' : '#556677';
+ scopeCtx.font = '12px monospace';
+ scopeCtx.textAlign = 'center';
+ scopeCtx.fillText(text, w / 2, h / 2);
+ scopeCtx.textAlign = 'start';
+ }
+ scopeAnim = requestAnimationFrame(draw);
+ return;
+ }
+
+ var maxVal = 0;
+ for (var i = 0; i < scopeHistory.length; i++) {
+ if (scopeHistory[i] > maxVal) maxVal = scopeHistory[i];
+ }
+ if (maxVal <= 0) maxVal = 1;
+
+ var barWidth = w / SCOPE_HISTORY_LEN;
+ var thresholdNorm = scopeThreshold / maxVal;
+
+ for (var j = 0; j < scopeHistory.length; j++) {
+ var norm = scopeHistory[j] / maxVal;
+ var barHeight = norm * (h - 10);
+ var x = j * barWidth;
+ var y = h - barHeight;
+
+ scopeCtx.fillStyle = scopeHistory[j] > scopeThreshold ? '#00ff88' : '#334455';
+ scopeCtx.fillRect(x, y, Math.max(barWidth - 1, 1), barHeight);
+ }
+
+ if (scopeThreshold > 0) {
+ var yThresh = h - (thresholdNorm * (h - 10));
+ scopeCtx.strokeStyle = '#ff4444';
+ scopeCtx.lineWidth = 1;
+ scopeCtx.setLineDash([4, 4]);
+ scopeCtx.beginPath();
+ scopeCtx.moveTo(0, yThresh);
+ scopeCtx.lineTo(w, yThresh);
+ scopeCtx.stroke();
+ scopeCtx.setLineDash([]);
+ }
+
+ if (scopeToneOn) {
+ scopeCtx.fillStyle = '#00ff88';
+ scopeCtx.beginPath();
+ scopeCtx.arc(w - 12, 12, 5, 0, Math.PI * 2);
+ scopeCtx.fill();
+ }
+
+ scopeAnim = requestAnimationFrame(draw);
+ }
+
+ draw();
+ }
+
+ function stopScope() {
+ var canvas = el('morseScopeCanvas');
+ if (canvas) {
+ var ctx = canvas.getContext('2d');
+ if (ctx) {
+ var w = canvas.clientWidth || canvas.width || 1;
+ var h = canvas.clientHeight || 80;
+ ctx.clearRect(0, 0, w, h);
+ ctx.fillStyle = '#050510';
+ ctx.fillRect(0, 0, w, h);
+ }
+ }
+ if (scopeAnim) {
+ cancelAnimationFrame(scopeAnim);
+ scopeAnim = null;
+ }
+ scopeCtx = null;
+ scopeRect = null;
+ scopeHistory = [];
+ scopeWaiting = false;
+ waitingStart = 0;
+ }
+
+ function appendDiagLine(text) {
+ var log = el('morseDiagLog');
+ if (!log) return;
+
+ var showDiag = !!(el('morseShowDiag') && el('morseShowDiag').checked);
+ if (!showDiag && scopeWaiting) {
+ showDiag = true;
+ }
+ if (!showDiag) return;
+
+ log.style.display = 'block';
+ var line = document.createElement('div');
+ line.textContent = text;
+ log.appendChild(line);
+
+ while (log.children.length > 32) {
+ log.removeChild(log.firstChild);
+ }
+ log.scrollTop = log.scrollHeight;
+ }
+
+ function clearDiagLog() {
+ var log = el('morseDiagLog');
+ if (!log) return;
+ log.innerHTML = '';
+ log.style.display = 'none';
+ }
+
+ function toggleDiagPanel() {
+ var log = el('morseDiagLog');
+ if (!log) return;
+
+ var showDiag = !!(el('morseShowDiag') && el('morseShowDiag').checked);
+ if (!showDiag) {
+ log.style.display = 'none';
+ } else if (log.children.length > 0) {
+ log.style.display = 'block';
+ }
+ }
+
+ function toggleRawPanel() {
+ var panel = el('morseRawPanel');
+ if (!panel) return;
+
+ var showRaw = !!(el('morseShowRaw') && el('morseShowRaw').checked);
+ panel.style.display = showRaw ? 'block' : 'none';
+ }
+
+ function setStatusText(text) {
+ var statusText = el('morseStatusText');
+ if (statusText) statusText.textContent = text;
+ }
+
+ function updateMetricLabel(id, text) {
+ var node = el(id);
+ if (node) node.textContent = text;
+ }
+
+ function updateUI() {
+ var startBtn = el('morseStartBtn');
+ var stopBtn = el('morseStopBtn');
+ var indicator = el('morseStatusIndicator');
+
+ var running = state.lifecycle === 'running';
+ var starting = state.lifecycle === 'starting';
+ var stopping = state.lifecycle === 'stopping';
+ var busy = isTransition();
+
+ if (startBtn) {
+ startBtn.style.display = running || starting ? 'none' : 'block';
+ startBtn.disabled = busy;
+ }
+
+ if (stopBtn) {
+ stopBtn.style.display = (running || starting || stopping) ? 'block' : 'none';
+ stopBtn.disabled = stopping;
+ stopBtn.textContent = stopping ? 'Stopping...' : 'Stop Decoder';
+ }
+
+ if (indicator) {
+ if (running) {
+ indicator.style.background = '#00ff88';
+ } else if (starting || stopping) {
+ indicator.style.background = '#ffaa00';
+ } else if (state.lifecycle === 'error') {
+ indicator.style.background = '#ff5555';
+ } else {
+ indicator.style.background = 'var(--text-dim)';
+ }
+ }
+
+ if (state.lifecycle === 'idle') setStatusText('Standby');
+ if (state.lifecycle === 'starting') setStatusText('Starting...');
+ if (state.lifecycle === 'running') setStatusText('Listening');
+ if (state.lifecycle === 'stopping') setStatusText('Stopping...');
+ if (state.lifecycle === 'error') setStatusText('Error');
+
+ var scopePanel = el('morseScopePanel');
+ if (scopePanel) scopePanel.style.display = 'block';
+
+ var outputPanel = el('morseOutputPanel');
+ if (outputPanel) outputPanel.style.display = 'block';
+
+ var scopeStatus = el('morseScopeStatusLabel');
+ if (scopeStatus) {
+ if (running) {
+ scopeStatus.textContent = 'ACTIVE';
+ scopeStatus.style.color = '#0f0';
+ } else if (starting) {
+ scopeStatus.textContent = 'STARTING';
+ scopeStatus.style.color = '#ffaa00';
+ } else if (stopping) {
+ scopeStatus.textContent = 'STOPPING';
+ scopeStatus.style.color = '#ffaa00';
+ } else {
+ scopeStatus.textContent = 'IDLE';
+ scopeStatus.style.color = '#444';
+ }
+ }
+
+ var stateBar = el('morseStatusBarState');
+ if (stateBar) {
+ stateBar.textContent = state.lifecycle.toUpperCase();
+ }
+
+ var metricState = el('morseMetricState');
+ if (metricState) {
+ metricState.textContent = 'STATE ' + state.lifecycle;
+ }
+
+ var controls = [
+ 'morseFrequency', 'morseGain', 'morsePPM', 'morseToneFreq', 'morseBandwidth',
+ 'morseAutoToneTrack', 'morseToneLock', 'morseThresholdMode', 'morseManualThreshold',
+ 'morseThresholdMultiplier', 'morseThresholdOffset', 'morseSignalGate', 'morseWpmMode',
+ 'morseWpm', 'morseWpmLock', 'morseShowRaw', 'morseShowDiag',
+ 'morseCalibrateBtn', 'morseDecodeFileBtn', 'morseFileInput'
+ ];
+
+ controls.forEach(function (id) {
+ var node = el(id);
+ if (!node) return;
+ node.disabled = busy;
+ });
+
+ toggleRawPanel();
+ toggleDiagPanel();
+ }
+
+ function updateToneLabel(value) {
+ var toneLabel = el('morseToneFreqLabel');
+ if (toneLabel) toneLabel.textContent = String(value);
+ persistSettings();
+ }
+
+ function updateWpmLabel(value) {
+ var wpmLabel = el('morseWpmLabel');
+ if (wpmLabel) wpmLabel.textContent = String(value);
+ persistSettings();
+ }
+
+ function onThresholdModeChange() {
+ var mode = (el('morseThresholdMode') && el('morseThresholdMode').value) || 'auto';
+ var manualRow = el('morseManualThresholdRow');
+ var autoRow = el('morseThresholdAutoRow');
+ var offsetRow = el('morseThresholdOffsetRow');
+
+ if (manualRow) manualRow.style.display = mode === 'manual' ? 'block' : 'none';
+ if (autoRow) autoRow.style.display = mode === 'manual' ? 'none' : 'block';
+ if (offsetRow) offsetRow.style.display = mode === 'manual' ? 'none' : 'block';
+
+ persistSettings();
+ }
+
+ function onWpmModeChange() {
+ var mode = (el('morseWpmMode') && el('morseWpmMode').value) || 'auto';
+ var manualRow = el('morseWpmManualRow');
+ if (manualRow) {
+ manualRow.style.display = mode === 'manual' ? 'block' : 'none';
+ }
+ persistSettings();
+ }
+
+ function setFreq(mhz) {
+ var freq = el('morseFrequency');
+ if (freq) {
+ freq.value = String(mhz);
+ persistSettings();
+ }
+ }
+
+ function exportTxt() {
+ var text = state.decodedLog.map(function (entry) { return entry.char; }).join('');
+ downloadFile('morse_decoded.txt', text, 'text/plain');
+ }
+
+ function exportCsv() {
+ var lines = ['timestamp,morse,character'];
+ state.decodedLog.forEach(function (entry) {
+ lines.push(entry.timestamp + ',"' + entry.morse + '",' + entry.char);
+ });
+ downloadFile('morse_decoded.csv', lines.join('\n'), 'text/csv');
+ }
+
+ function copyToClipboard() {
+ var text = state.decodedLog.map(function (entry) { return entry.char; }).join('');
+ if (!navigator.clipboard || !navigator.clipboard.writeText) return;
+
+ navigator.clipboard.writeText(text).then(function () {
+ var btn = el('morseCopyBtn');
+ if (!btn) return;
+ var original = btn.textContent;
+ btn.textContent = 'Copied!';
+ setTimeout(function () {
+ btn.textContent = original;
+ }, 1200);
+ }).catch(function () {
+ // Ignore clipboard failures.
+ });
+ }
+
+ function downloadFile(filename, content, type) {
+ var blob = new Blob([content], { type: type });
+ var url = URL.createObjectURL(blob);
+ var anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = filename;
+ anchor.click();
+ URL.revokeObjectURL(url);
+ }
+
+ function calibrate() {
+ if (state.lifecycle !== 'running') {
+ notifyInfo('Morse decoder is not running');
+ return;
+ }
+
+ postJson('/morse/calibrate', {}, 2000)
+ .then(function () {
+ appendDiagLine('[calibrate] estimator reset requested');
+ notifyInfo('Morse estimator reset');
+ })
+ .catch(function (err) {
+ notifyError('Calibration failed: ' + (err && err.message ? err.message : err));
+ });
+ }
+
+ function decodeFile() {
+ var input = el('morseFileInput');
+ if (!input || !input.files || !input.files[0]) {
+ notifyError('Select a WAV file first.');
+ return;
+ }
+
+ var file = input.files[0];
+ var config = collectConfig();
+ var formData = new FormData();
+ formData.append('audio', file);
+
+ formData.append('tone_freq', config.tone_freq);
+ formData.append('wpm', config.wpm);
+ formData.append('bandwidth_hz', config.bandwidth_hz);
+ formData.append('auto_tone_track', String(config.auto_tone_track));
+ formData.append('tone_lock', String(config.tone_lock));
+ formData.append('threshold_mode', config.threshold_mode);
+ formData.append('manual_threshold', config.manual_threshold);
+ formData.append('threshold_multiplier', config.threshold_multiplier);
+ formData.append('threshold_offset', config.threshold_offset);
+ formData.append('wpm_mode', config.wpm_mode);
+ formData.append('wpm_lock', String(config.wpm_lock));
+ formData.append('signal_gate', config.signal_gate);
+
+ var decodeBtn = el('morseDecodeFileBtn');
+ if (decodeBtn) {
+ decodeBtn.disabled = true;
+ decodeBtn.textContent = 'Decoding...';
+ }
+
+ fetch('/morse/decode-file', {
+ method: 'POST',
+ body: formData,
+ }).then(function (response) {
+ return parseJsonSafe(response).then(function (data) {
+ if (!response.ok || data.status !== 'ok') {
+ throw new Error(data.message || ('HTTP ' + response.status));
+ }
+
+ clearDecodedText();
+ clearRawText();
+
+ var text = String(data.text || '');
+ var raw = String(data.raw || '');
+
+ if (text.length > 0) {
+ for (var i = 0; i < text.length; i++) {
+ if (text[i] === ' ') {
+ appendSpace();
+ } else {
+ appendChar(text[i], '', '--:--:--');
+ }
+ }
+ }
+
+ if (raw) {
+ state.rawLog = [raw];
+ var rawText = el('morseRawText');
+ if (rawText) rawText.textContent = raw;
+ }
+
+ if (data.metrics) {
+ applyMetrics(data.metrics, false);
+ }
+
+ toggleRawPanel();
+ notifyInfo('File decode complete: ' + (data.char_count || 0) + ' chars');
+ });
+ }).catch(function (err) {
+ notifyError('WAV decode failed: ' + (err && err.message ? err.message : err));
+ }).finally(function () {
+ if (decodeBtn) {
+ decodeBtn.disabled = false;
+ decodeBtn.textContent = 'Decode File';
+ }
+ });
+ }
+
+ return {
+ init: init,
+ destroy: destroy,
+ start: start,
+ stop: stop,
+ setFreq: setFreq,
+ exportTxt: exportTxt,
+ exportCsv: exportCsv,
+ copyToClipboard: copyToClipboard,
+ clearText: clearDecodedText,
+ calibrate: calibrate,
+ decodeFile: decodeFile,
+ updateToneLabel: updateToneLabel,
+ updateWpmLabel: updateWpmLabel,
+ onThresholdModeChange: onThresholdModeChange,
+ onWpmModeChange: onWpmModeChange,
+ isActive: isActive,
+ };
+})();
diff --git a/static/js/modes/system.js b/static/js/modes/system.js
new file mode 100644
index 0000000..1aab9aa
--- /dev/null
+++ b/static/js/modes/system.js
@@ -0,0 +1,300 @@
+/**
+ * System Health – IIFE module
+ *
+ * Always-on monitoring that auto-connects when the mode is entered.
+ * Streams real-time system metrics via SSE and provides SDR device enumeration.
+ */
+const SystemHealth = (function () {
+ 'use strict';
+
+ let eventSource = null;
+ let connected = false;
+ let lastMetrics = null;
+
+ // -----------------------------------------------------------------------
+ // Helpers
+ // -----------------------------------------------------------------------
+
+ function formatBytes(bytes) {
+ if (bytes == null) return '--';
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ let i = 0;
+ let val = bytes;
+ while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; }
+ return val.toFixed(1) + ' ' + units[i];
+ }
+
+ function barClass(pct) {
+ if (pct >= 85) return 'crit';
+ if (pct >= 60) return 'warn';
+ return 'ok';
+ }
+
+ function barHtml(pct, label) {
+ if (pct == null) return 'N/A ';
+ const cls = barClass(pct);
+ const rounded = Math.round(pct);
+ return '
' +
+ (label ? '
' + label + ' ' : '') +
+ '
' +
+ '
' + rounded + '% ' +
+ '
';
+ }
+
+ // -----------------------------------------------------------------------
+ // Rendering
+ // -----------------------------------------------------------------------
+
+ function renderCpuCard(m) {
+ const el = document.getElementById('sysCardCpu');
+ if (!el) return;
+ const cpu = m.cpu;
+ if (!cpu) { el.innerHTML = 'psutil not available
'; return; }
+ el.innerHTML =
+ '' +
+ '' +
+ barHtml(cpu.percent, '') +
+ '
Load: ' + cpu.load_1 + ' / ' + cpu.load_5 + ' / ' + cpu.load_15 + '
' +
+ '
Cores: ' + cpu.count + '
' +
+ '
';
+ }
+
+ function renderMemoryCard(m) {
+ const el = document.getElementById('sysCardMemory');
+ if (!el) return;
+ const mem = m.memory;
+ if (!mem) { el.innerHTML = 'N/A
'; return; }
+ const swap = m.swap || {};
+ el.innerHTML =
+ '' +
+ '' +
+ barHtml(mem.percent, '') +
+ '
' + formatBytes(mem.used) + ' / ' + formatBytes(mem.total) + '
' +
+ '
Swap: ' + formatBytes(swap.used) + ' / ' + formatBytes(swap.total) + '
' +
+ '
';
+ }
+
+ function renderDiskCard(m) {
+ const el = document.getElementById('sysCardDisk');
+ if (!el) return;
+ const disk = m.disk;
+ if (!disk) { el.innerHTML = 'N/A
'; return; }
+ el.innerHTML =
+ '' +
+ '' +
+ barHtml(disk.percent, '') +
+ '
' + formatBytes(disk.used) + ' / ' + formatBytes(disk.total) + '
' +
+ '
Path: ' + (disk.path || '/') + '
' +
+ '
';
+ }
+
+ function _extractPrimaryTemp(temps) {
+ if (!temps) return null;
+ // Prefer common chip names
+ const preferred = ['cpu_thermal', 'coretemp', 'k10temp', 'acpitz', 'soc_thermal'];
+ for (const name of preferred) {
+ if (temps[name] && temps[name].length) return temps[name][0];
+ }
+ // Fall back to first available
+ for (const key of Object.keys(temps)) {
+ if (temps[key] && temps[key].length) return temps[key][0];
+ }
+ return null;
+ }
+
+ function renderSdrCard(devices) {
+ const el = document.getElementById('sysCardSdr');
+ if (!el) return;
+ let html = '';
+ html += '';
+ if (!devices || !devices.length) {
+ html += '
No devices found ';
+ } else {
+ devices.forEach(function (d) {
+ html += '
' +
+ '
' +
+ '
' + d.type + ' #' + d.index + ' ' +
+ '
' + (d.name || 'Unknown') + '
' +
+ (d.serial ? '
S/N: ' + d.serial + '
' : '') +
+ '
';
+ });
+ }
+ html += '
';
+ el.innerHTML = html;
+ }
+
+ function renderProcessCard(m) {
+ const el = document.getElementById('sysCardProcesses');
+ if (!el) return;
+ const procs = m.processes || {};
+ const keys = Object.keys(procs).sort();
+ let html = '';
+ if (!keys.length) {
+ html += '
No data ';
+ } else {
+ keys.forEach(function (k) {
+ const running = procs[k];
+ const dotCls = running ? 'running' : 'stopped';
+ const label = k.charAt(0).toUpperCase() + k.slice(1);
+ html += '
' +
+ ' ' +
+ '' + label + ' ' +
+ '
';
+ });
+ }
+ html += '
';
+ el.innerHTML = html;
+ }
+
+ function renderSystemInfoCard(m) {
+ const el = document.getElementById('sysCardInfo');
+ if (!el) return;
+ const sys = m.system || {};
+ const temp = _extractPrimaryTemp(m.temperatures);
+ let html = '';
+ html += '
Host: ' + (sys.hostname || '--') + '
';
+ html += '
OS: ' + (sys.platform || '--') + '
';
+ html += '
Python: ' + (sys.python || '--') + '
';
+ html += '
App: v' + (sys.version || '--') + '
';
+ html += '
Uptime: ' + (sys.uptime_human || '--') + '
';
+ if (temp) {
+ html += '
Temp: ' + Math.round(temp.current) + '°C';
+ if (temp.high) html += ' / ' + Math.round(temp.high) + '°C max';
+ html += '
';
+ }
+ html += '
';
+ el.innerHTML = html;
+ }
+
+ function updateSidebarQuickStats(m) {
+ const cpuEl = document.getElementById('sysQuickCpu');
+ const tempEl = document.getElementById('sysQuickTemp');
+ const ramEl = document.getElementById('sysQuickRam');
+ const diskEl = document.getElementById('sysQuickDisk');
+
+ if (cpuEl) cpuEl.textContent = m.cpu ? Math.round(m.cpu.percent) + '%' : '--';
+ if (ramEl) ramEl.textContent = m.memory ? Math.round(m.memory.percent) + '%' : '--';
+ if (diskEl) diskEl.textContent = m.disk ? Math.round(m.disk.percent) + '%' : '--';
+
+ const temp = _extractPrimaryTemp(m.temperatures);
+ if (tempEl) tempEl.innerHTML = temp ? Math.round(temp.current) + '°C' : '--';
+
+ // Color-code values
+ [cpuEl, ramEl, diskEl].forEach(function (el) {
+ if (!el) return;
+ const val = parseInt(el.textContent);
+ el.classList.remove('sys-val-ok', 'sys-val-warn', 'sys-val-crit');
+ if (!isNaN(val)) el.classList.add('sys-val-' + barClass(val));
+ });
+ }
+
+ function updateSidebarProcesses(m) {
+ const el = document.getElementById('sysProcessList');
+ if (!el) return;
+ const procs = m.processes || {};
+ const keys = Object.keys(procs).sort();
+ if (!keys.length) { el.textContent = 'No data'; return; }
+ const running = keys.filter(function (k) { return procs[k]; });
+ const stopped = keys.filter(function (k) { return !procs[k]; });
+ el.innerHTML =
+ (running.length ? '' + running.length + ' running ' : '') +
+ (running.length && stopped.length ? ' · ' : '') +
+ (stopped.length ? '' + stopped.length + ' stopped ' : '');
+ }
+
+ function renderAll(m) {
+ renderCpuCard(m);
+ renderMemoryCard(m);
+ renderDiskCard(m);
+ renderProcessCard(m);
+ renderSystemInfoCard(m);
+ updateSidebarQuickStats(m);
+ updateSidebarProcesses(m);
+ }
+
+ // -----------------------------------------------------------------------
+ // SSE Connection
+ // -----------------------------------------------------------------------
+
+ function connect() {
+ if (eventSource) return;
+ eventSource = new EventSource('/system/stream');
+ eventSource.onmessage = function (e) {
+ try {
+ var data = JSON.parse(e.data);
+ if (data.type === 'keepalive') return;
+ lastMetrics = data;
+ renderAll(data);
+ } catch (_) { /* ignore parse errors */ }
+ };
+ eventSource.onopen = function () {
+ connected = true;
+ };
+ eventSource.onerror = function () {
+ connected = false;
+ };
+ }
+
+ function disconnect() {
+ if (eventSource) {
+ eventSource.close();
+ eventSource = null;
+ }
+ connected = false;
+ }
+
+ // -----------------------------------------------------------------------
+ // SDR Devices
+ // -----------------------------------------------------------------------
+
+ function refreshSdr() {
+ var sidebarEl = document.getElementById('sysSdrList');
+ if (sidebarEl) sidebarEl.innerHTML = 'Scanning…';
+
+ var cardEl = document.getElementById('sysCardSdr');
+ if (cardEl) cardEl.innerHTML = 'Scanning…
';
+
+ fetch('/system/sdr_devices')
+ .then(function (r) { return r.json(); })
+ .then(function (data) {
+ var devices = data.devices || [];
+ renderSdrCard(devices);
+ // Update sidebar
+ if (sidebarEl) {
+ if (!devices.length) {
+ sidebarEl.innerHTML = 'No SDR devices found ';
+ } else {
+ var html = '';
+ devices.forEach(function (d) {
+ html += ' ' +
+ d.type + ' #' + d.index + ' — ' + (d.name || 'Unknown') + '
';
+ });
+ sidebarEl.innerHTML = html;
+ }
+ }
+ })
+ .catch(function () {
+ if (sidebarEl) sidebarEl.innerHTML = 'Detection failed ';
+ renderSdrCard([]);
+ });
+ }
+
+ // -----------------------------------------------------------------------
+ // Public API
+ // -----------------------------------------------------------------------
+
+ function init() {
+ connect();
+ refreshSdr();
+ }
+
+ function destroy() {
+ disconnect();
+ }
+
+ return {
+ init: init,
+ destroy: destroy,
+ refreshSdr: refreshSdr,
+ };
+})();
diff --git a/static/js/modes/weather-satellite.js b/static/js/modes/weather-satellite.js
index b528b71..6aa98ce 100644
--- a/static/js/modes/weather-satellite.js
+++ b/static/js/modes/weather-satellite.js
@@ -39,6 +39,40 @@ const WeatherSat = (function() {
startCountdownTimer();
checkSchedulerStatus();
initGroundMap();
+
+ // Re-filter passes when satellite selection changes
+ const satSelect = document.getElementById('weatherSatSelect');
+ if (satSelect) {
+ satSelect.addEventListener('change', () => {
+ applyPassFilter();
+ });
+ }
+ }
+
+ /**
+ * Get passes filtered by the currently selected satellite.
+ */
+ function getFilteredPasses() {
+ const satSelect = document.getElementById('weatherSatSelect');
+ const selected = satSelect?.value;
+ if (!selected) return passes;
+ return passes.filter(p => p.satellite === selected);
+ }
+
+ /**
+ * Re-render passes, timeline, countdown and polar plot using filtered list.
+ */
+ function applyPassFilter() {
+ const filtered = getFilteredPasses();
+ selectedPassIndex = -1;
+ renderPasses(filtered);
+ renderTimeline(filtered);
+ updateCountdownFromPasses();
+ if (filtered.length > 0) {
+ selectPass(0);
+ } else {
+ updateGroundTrack(null);
+ }
}
/**
@@ -593,9 +627,10 @@ const WeatherSat = (function() {
* Select a pass to display in polar plot and map
*/
function selectPass(index) {
- if (index < 0 || index >= passes.length) return;
+ const filtered = getFilteredPasses();
+ if (index < 0 || index >= filtered.length) return;
selectedPassIndex = index;
- const pass = passes[index];
+ const pass = filtered[index];
// Highlight active card
document.querySelectorAll('.wxsat-pass-card').forEach((card, i) => {
@@ -1048,8 +1083,9 @@ const WeatherSat = (function() {
}
function getSelectedPass() {
- if (selectedPassIndex < 0 || selectedPassIndex >= passes.length) return null;
- return passes[selectedPassIndex];
+ const filtered = getFilteredPasses();
+ if (selectedPassIndex < 0 || selectedPassIndex >= filtered.length) return null;
+ return filtered[selectedPassIndex];
}
function getSatellitePositionForPass(pass, atTime = new Date()) {
@@ -1161,8 +1197,9 @@ const WeatherSat = (function() {
const now = new Date();
let nextPass = null;
let isActive = false;
+ const filtered = getFilteredPasses();
- for (const pass of passes) {
+ for (const pass of filtered) {
const start = parsePassDate(pass.startTimeISO);
const end = parsePassDate(pass.endTimeISO);
if (!start || !end) {
diff --git a/templates/index.html b/templates/index.html
index 955e7eb..ddc1b96 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -66,6 +66,7 @@
+
-
+
+
@@ -3330,6 +3382,7 @@
websdr: { label: 'WebSDR', indicator: 'WEBSDR', outputTitle: 'HF/Shortwave WebSDR', group: 'intel' },
waterfall: { label: 'Waterfall', indicator: 'WATERFALL', outputTitle: 'Spectrum Waterfall', group: 'signals' },
morse: { label: 'Morse', indicator: 'MORSE', outputTitle: 'CW/Morse Decoder', group: 'signals' },
+ system: { label: 'System', indicator: 'SYSTEM', outputTitle: 'System Health Monitor', group: 'system' },
};
const validModes = new Set(Object.keys(modeCatalog));
window.interceptModeCatalog = Object.assign({}, modeCatalog);
@@ -3858,6 +3911,11 @@
return {
pager: Boolean(isRunning),
sensor: Boolean(isSensorRunning),
+ morse: Boolean(
+ typeof MorseMode !== 'undefined'
+ && typeof MorseMode.isActive === 'function'
+ && MorseMode.isActive()
+ ),
wifi: Boolean(
((typeof WiFiMode !== 'undefined' && typeof WiFiMode.isScanning === 'function' && WiFiMode.isScanning()) || isWifiRunning)
),
@@ -3879,6 +3937,12 @@
if (isSensorRunning && typeof stopSensorDecoding === 'function') {
Promise.resolve(stopSensorDecoding()).catch(() => { });
}
+ const morseActive = typeof MorseMode !== 'undefined'
+ && typeof MorseMode.isActive === 'function'
+ && MorseMode.isActive();
+ if (morseActive && typeof MorseMode.stop === 'function') {
+ Promise.resolve(MorseMode.stop()).catch(() => { });
+ }
const wifiScanActive = (
typeof WiFiMode !== 'undefined'
@@ -4004,6 +4068,12 @@
if (isSensorRunning) {
stopTasks.push(awaitStopAction('sensor', () => stopSensorDecoding(), LOCAL_STOP_TIMEOUT_MS));
}
+ const morseActive = typeof MorseMode !== 'undefined'
+ && typeof MorseMode.isActive === 'function'
+ && MorseMode.isActive();
+ if (morseActive && typeof MorseMode.stop === 'function') {
+ stopTasks.push(awaitStopAction('morse', () => MorseMode.stop(), LOCAL_STOP_TIMEOUT_MS));
+ }
const wifiScanActive = (
typeof WiFiMode !== 'undefined'
&& typeof WiFiMode.isScanning === 'function'
@@ -4040,6 +4110,9 @@
if (typeof SubGhz !== 'undefined' && currentMode === 'subghz' && mode !== 'subghz') {
SubGhz.destroy();
}
+ if (typeof MorseMode !== 'undefined' && currentMode === 'morse' && mode !== 'morse' && typeof MorseMode.destroy === 'function') {
+ MorseMode.destroy();
+ }
currentMode = mode;
document.body.setAttribute('data-mode', mode);
@@ -4089,6 +4162,7 @@
document.getElementById('spaceWeatherMode')?.classList.toggle('active', mode === 'spaceweather');
document.getElementById('waterfallMode')?.classList.toggle('active', mode === 'waterfall');
document.getElementById('morseMode')?.classList.toggle('active', mode === 'morse');
+ document.getElementById('systemMode')?.classList.toggle('active', mode === 'system');
const pagerStats = document.getElementById('pagerStats');
@@ -4130,6 +4204,7 @@
const wefaxVisuals = document.getElementById('wefaxVisuals');
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
const waterfallVisuals = document.getElementById('waterfallVisuals');
+ const systemVisuals = document.getElementById('systemVisuals');
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
@@ -4147,6 +4222,7 @@
if (wefaxVisuals) wefaxVisuals.style.display = mode === 'wefax' ? 'flex' : 'none';
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
if (waterfallVisuals) waterfallVisuals.style.display = mode === 'waterfall' ? 'flex' : 'none';
+ if (systemVisuals) systemVisuals.style.display = mode === 'system' ? 'flex' : 'none';
// Prevent Leaflet heatmap redraws on hidden BT Locate map containers.
if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) {
@@ -4172,6 +4248,8 @@
const morseOutputPanel = document.getElementById('morseOutputPanel');
if (morseScopePanel && mode !== 'morse') morseScopePanel.style.display = 'none';
if (morseOutputPanel && mode !== 'morse') morseOutputPanel.style.display = 'none';
+ const morseDiagLog = document.getElementById('morseDiagLog');
+ if (morseDiagLog && mode !== 'morse') morseDiagLog.style.display = 'none';
// Update output panel title based on mode
const outputTitle = document.getElementById('outputTitle');
@@ -4201,11 +4279,16 @@
if (typeof WeFax !== 'undefined' && WeFax.destroy) WeFax.destroy();
}
+ // Disconnect System Health SSE when leaving the mode
+ if (mode !== 'system') {
+ if (typeof SystemHealth !== 'undefined' && SystemHealth.destroy) SystemHealth.destroy();
+ }
+
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm)
const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
const reconPanel = document.getElementById('reconPanel');
- if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'gps' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall') {
+ if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'gps' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall' || mode === 'system') {
if (reconPanel) reconPanel.style.display = 'none';
if (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none';
@@ -4256,8 +4339,8 @@
// Hide output console for modes with their own visualizations
const outputEl = document.getElementById('output');
const statusBar = document.querySelector('.status-bar');
- if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall') ? 'none' : 'block';
- if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall') ? 'none' : 'flex';
+ if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall' || mode === 'morse' || mode === 'system') ? 'none' : 'block';
+ if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall' || mode === 'morse' || mode === 'system') ? 'none' : 'flex';
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
if (mode !== 'meshtastic') {
@@ -4331,6 +4414,8 @@
if (typeof Waterfall !== 'undefined') Waterfall.init();
} else if (mode === 'morse') {
MorseMode.init();
+ } else if (mode === 'system') {
+ SystemHealth.init();
}
// Destroy Waterfall WebSocket when leaving SDR receiver modes
diff --git a/templates/partials/modes/morse.html b/templates/partials/modes/morse.html
index b3bb0f0..84f5042 100644
--- a/templates/partials/modes/morse.html
+++ b/templates/partials/modes/morse.html
@@ -1,98 +1,166 @@
-
-
-
-
CW/Morse Decoder
-
- Decode CW (continuous wave) Morse code from amateur radio HF bands using USB demodulation
- and Goertzel tone detection.
-
-
-
-
-
Frequency
-
- Frequency (MHz)
-
-
-
-
-
-
-
Settings
-
- Gain (dB)
-
-
-
- PPM Correction
-
-
-
-
-
-
CW Settings
-
- Tone Frequency: 700 Hz
-
-
-
- Speed: 15 WPM
-
-
-
-
-
-
-
- Morse Reference (click to toggle)
-
-
-
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 ----.
-
-
-
-
-
-
-
- Standby
- 0 chars
-
-
-
-
-
-
- CW operates on HF bands (1-30 MHz). Requires an HF-capable SDR with direct sampling
- or an upconverter, plus an appropriate HF antenna (dipole, end-fed, or random wire).
-
-
-
-
Start Decoder
-
Stop Decoder
-
+
+
+
+
CW/Morse Decoder
+
+ Decode CW (continuous wave) Morse with USB demod + Goertzel tone detection.
+ Start with 700 Hz tone and 200 Hz bandwidth.
+
+
+
+
+
Frequency
+
+ Frequency (MHz)
+
+ Enter CW center frequency in MHz (e.g., 7.030 for 40m).
+
+
+
+
+
+
Device
+
+ Gain (dB)
+
+
+
+ PPM Correction
+
+
+
+
+
+
+
+
Threshold + WPM
+
+ Threshold Mode
+
+ Auto
+ Manual
+
+
+
+ Threshold Multiplier
+
+
+
+ Threshold Offset
+
+
+
+ Manual Threshold
+
+
+
+ Minimum Signal Gate
+
+
+
+ WPM Mode
+
+ Auto
+ Manual
+
+
+
+ Manual Speed: 15 WPM
+
+
+
+ Lock WPM Estimator
+
+
+
+
+
Output
+
+ Show Raw Morse
+ Show Decoder Logs
+
+
+ Reset / Calibrate
+
+
+
+
+
Decode WAV File
+
+
+ Decode File
+
+
Runs the same CW decoder pipeline against uploaded WAV audio.
+
+
+
+
+ Morse Reference (click to toggle)
+
+
+
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 ----.
+
+
+
+
+
+
+ Standby
+ 0 chars
+
+
+
+
+
+ CW on HF (1-30 MHz) requires an HF-capable SDR path (direct sampling or upconverter)
+ and an appropriate antenna.
+
+
+
+
Start Decoder
+
Stop Decoder
+
diff --git a/templates/partials/modes/system.html b/templates/partials/modes/system.html
new file mode 100644
index 0000000..a750e11
--- /dev/null
+++ b/templates/partials/modes/system.html
@@ -0,0 +1,52 @@
+
+
+
+
System Health
+
+ Real-time monitoring of host resources, active decoders, and SDR hardware.
+ Auto-connects when entering this mode.
+
+
+
+
+
+
Quick Status
+
+
+ CPU
+ --%
+
+
+ Temp
+ --°C
+
+
+ RAM
+ --%
+
+
+ Disk
+ --%
+
+
+
+
+
+
+
SDR Devices
+
+ Scanning…
+
+
+ Rescan SDR
+
+
+
+
+
+
Active Processes
+
+ Waiting for data…
+
+
+
diff --git a/templates/partials/nav.html b/templates/partials/nav.html
index 7cc4fff..68a6d6c 100644
--- a/templates/partials/nav.html
+++ b/templates/partials/nav.html
@@ -140,6 +140,19 @@
+ {# System Group #}
+
+
{# Dynamic dashboard button (shown when in satellite mode) #}
@@ -230,6 +243,8 @@
{{ mobile_item('websdr', 'WebSDR', ' ') }}
{# New modes #}
{{ mobile_item('waterfall', 'Waterfall', ' ') }}
+ {# System #}
+ {{ mobile_item('system', 'System', ' ') }}
{# JavaScript stub for pages that don't have switchMode defined #}
diff --git a/tests/test_dsc.py b/tests/test_dsc.py
index 9b83663..276a156 100644
--- a/tests/test_dsc.py
+++ b/tests/test_dsc.py
@@ -515,18 +515,16 @@ class TestDSCDecoder:
assert result == '002320001'
def test_decode_mmsi_short_symbols(self, decoder):
- """Test MMSI decoding handles short symbol list."""
+ """Test MMSI decoding returns None for short symbol list."""
result = decoder._decode_mmsi([1, 2, 3])
- assert result == '000000000'
+ assert result is None
def test_decode_mmsi_invalid_symbols(self, decoder):
- """Test MMSI decoding handles invalid symbol values."""
- # Symbols > 99 should be treated as 0
+ """Test MMSI decoding returns None for out-of-range symbols."""
+ # Symbols > 99 should cause decode to fail
symbols = [100, 32, 12, 34, 56]
result = decoder._decode_mmsi(symbols)
- # First symbol (100) becomes 00, padded result "0032123456",
- # trim leading pad digit -> "032123456"
- assert result == '032123456'
+ assert result is None
def test_decode_position_northeast(self, decoder):
"""Test position decoding for NE quadrant."""
@@ -577,8 +575,9 @@ class TestDSCDecoder:
def test_bits_to_symbol(self, decoder):
"""Test bit to symbol conversion."""
# Symbol value is first 7 bits (LSB first)
- # Value 100 = 0b1100100 -> bits [0,0,1,0,0,1,1, x,x,x]
- bits = [0, 0, 1, 0, 0, 1, 1, 0, 0, 0]
+ # Value 100 = 0b1100100 -> bits [0,0,1,0,0,1,1] -> 3 ones
+ # Check bits must make total even -> need 1 more one -> [1,0,0]
+ bits = [0, 0, 1, 0, 0, 1, 1, 1, 0, 0]
result = decoder._bits_to_symbol(bits)
assert result == 100
@@ -588,14 +587,14 @@ class TestDSCDecoder:
assert result == -1
def test_detect_dot_pattern(self, decoder):
- """Test dot pattern detection."""
- # Dot pattern is alternating 1010101...
- decoder.bit_buffer = [1, 0] * 25 # 50 alternating bits
+ """Test dot pattern detection with 200+ alternating bits."""
+ # Dot pattern requires at least 200 bits / 100 alternations
+ decoder.bit_buffer = [1, 0] * 110 # 220 alternating bits
assert decoder._detect_dot_pattern() is True
def test_detect_dot_pattern_insufficient(self, decoder):
"""Test dot pattern not detected with insufficient alternations."""
- decoder.bit_buffer = [1, 0] * 5 # Only 10 bits
+ decoder.bit_buffer = [1, 0] * 40 # Only 80 bits, below 200 threshold
assert decoder._detect_dot_pattern() is False
def test_detect_dot_pattern_not_alternating(self, decoder):
@@ -603,6 +602,84 @@ class TestDSCDecoder:
decoder.bit_buffer = [1, 1, 1, 1, 0, 0, 0, 0] * 5
assert decoder._detect_dot_pattern() is False
+ def test_bounded_phasing_strip(self, decoder):
+ """Test that >7 phasing symbols causes decode to return None."""
+ # Build message bits: 10 phasing symbols (120) + format + data
+ # Each symbol is 10 bits. Phasing symbol 120 = 0b1111000 LSB first
+ # 120 in 7 bits LSB-first: 0,0,0,1,1,1,1 + 3 check bits
+ # 120 = 0b1111000 -> LSB first: 0,0,0,1,1,1,1 -> ones=4 (even) -> check [0,0,0]
+ phasing_bits = [0, 0, 0, 1, 1, 1, 1, 0, 0, 0] # symbol 120
+ # 10 phasing symbols (>7 max)
+ decoder.message_bits = phasing_bits * 10
+ # Add some non-phasing symbols after (enough for a message)
+ # Symbol 112 (INDIVIDUAL) = 0b1110000 LSB-first: 0,0,0,0,1,1,1 -> ones=3 (odd) -> need odd check
+ # For simplicity, just add enough bits for the decoder to attempt
+ for _ in range(20):
+ decoder.message_bits.extend([0, 0, 0, 0, 1, 1, 1, 1, 0, 0])
+ result = decoder._try_decode_message()
+ assert result is None
+
+ def test_eos_minimum_length(self, decoder):
+ """Test that EOS found too early in the symbol stream is skipped."""
+ # Build a message where EOS appears at position 5 (< MIN_SYMBOLS_FOR_FORMAT=12)
+ # This should not be accepted as a valid message end
+ # Symbol 127 (EOS) = 0b1111111 LSB-first: 1,1,1,1,1,1,1 -> ones=7 (odd) -> check needs 1 one
+ # Use a simple approach: create symbols directly via _try_decode_message
+ # Create 5 normal symbols + EOS at position 5 — should be skipped
+ # Followed by more symbols and a real EOS at position 15
+ from utils.dsc.decoder import DSCDecoder
+ d = DSCDecoder()
+
+ # Build symbols manually: we need _try_decode_message to find EOS too early
+ # Symbol 112 = format code. We'll build 10 bits per symbol.
+ # Since check bit validation is now active, we need valid check bits.
+ # Symbol value 10 = 0b0001010 LSB-first: 0,1,0,1,0,0,0, ones=2 (even) -> check [0,0,0]
+ sym_10 = [0, 1, 0, 1, 0, 0, 0, 0, 0, 0]
+ # Symbol 127 (EOS) = 0b1111111, ones=7 (odd) -> check needs odd total -> [1,0,0]
+ sym_eos = [1, 1, 1, 1, 1, 1, 1, 1, 0, 0]
+
+ # 5 normal symbols + early EOS (should be skipped) + 8 more normal + real EOS
+ d.message_bits = sym_10 * 5 + sym_eos + sym_10 * 8 + sym_eos
+ result = d._try_decode_message()
+ # The early EOS at index 5 should be skipped; the one at index 14
+ # is past MIN_SYMBOLS_FOR_FORMAT so it can be accepted.
+ # But the message content is garbage, so _decode_symbols will likely
+ # return None for other reasons. The key test: it doesn't return a
+ # message truncated at position 5.
+ # Just verify no crash and either None or a valid longer message
+ # (not truncated at the early EOS)
+ assert result is None or len(result.get('raw', '')) > 18
+
+ def test_bits_to_symbol_check_bit_validation(self, decoder):
+ """Test that _bits_to_symbol rejects symbols with invalid check bits."""
+ # Symbol 100 = 0b1100100 LSB-first: 0,0,1,0,0,1,1
+ # ones in data = 3, need total even -> check bits need 1 one
+ # Valid: [0,0,1,0,0,1,1, 1,0,0] -> total ones = 4 (even) -> valid
+ valid_bits = [0, 0, 1, 0, 0, 1, 1, 1, 0, 0]
+ assert decoder._bits_to_symbol(valid_bits) == 100
+
+ # Invalid: flip one check bit -> total ones = 5 (odd) -> invalid
+ invalid_bits = [0, 0, 1, 0, 0, 1, 1, 0, 0, 0]
+ assert decoder._bits_to_symbol(invalid_bits) == -1
+
+ def test_safety_is_critical(self):
+ """Test that SAFETY category is marked as critical."""
+ import json
+
+ from utils.dsc.parser import parse_dsc_message
+
+ raw = json.dumps({
+ 'type': 'dsc',
+ 'format': 123,
+ 'source_mmsi': '232123456',
+ 'category': 'SAFETY',
+ 'timestamp': '2025-01-15T12:00:00Z',
+ 'raw': '123232123456100122',
+ })
+ msg = parse_dsc_message(raw)
+ assert msg is not None
+ assert msg['is_critical'] is True
+
class TestDSCConstants:
"""Tests for DSC constants."""
@@ -670,3 +747,27 @@ class TestDSCConstants:
assert DSC_BAUD_RATE == 1200
assert DSC_MARK_FREQ == 2100
assert DSC_SPACE_FREQ == 1300
+
+ def test_telecommand_codes_full(self):
+ """Test TELECOMMAND_CODES_FULL covers 0-127 range."""
+ from utils.dsc.constants import TELECOMMAND_CODES_FULL
+
+ assert len(TELECOMMAND_CODES_FULL) == 128
+ # Known codes map correctly
+ assert TELECOMMAND_CODES_FULL[100] == 'F3E_G3E_ALL'
+ assert TELECOMMAND_CODES_FULL[107] == 'DISTRESS_ACK'
+ # Unknown codes map to "UNKNOWN"
+ assert TELECOMMAND_CODES_FULL[0] == 'UNKNOWN'
+ assert TELECOMMAND_CODES_FULL[99] == 'UNKNOWN'
+
+ def test_telecommand_formats(self):
+ """Test TELECOMMAND_FORMATS contains correct format codes."""
+ from utils.dsc.constants import TELECOMMAND_FORMATS
+
+ assert {112, 114, 116, 120, 123} == TELECOMMAND_FORMATS
+
+ def test_min_symbols_for_format(self):
+ """Test MIN_SYMBOLS_FOR_FORMAT constant."""
+ from utils.dsc.constants import MIN_SYMBOLS_FOR_FORMAT
+
+ assert MIN_SYMBOLS_FOR_FORMAT == 12
diff --git a/tests/test_morse.py b/tests/test_morse.py
index bb6da32..b17c7de 100644
--- a/tests/test_morse.py
+++ b/tests/test_morse.py
@@ -1,393 +1,546 @@
-"""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')
+"""Tests for Morse code decoder pipeline and lifecycle routes."""
+
+from __future__ import annotations
+
+import io
+import math
+import os
+import queue
+import struct
+import threading
+import time
+import wave
+from collections import Counter
+
+import pytest
+
+import app as app_module
+import routes.morse as morse_routes
+from utils.morse import (
+ CHAR_TO_MORSE,
+ MORSE_TABLE,
+ GoertzelFilter,
+ MorseDecoder,
+ decode_morse_wav_file,
+ 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 synthetic CW PCM for the given text."""
+ 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)
+
+ # Leading/trailing silence for threshold settling.
+ return generate_silence(0.3, sample_rate) + audio + generate_silence(0.3, sample_rate)
+
+
+def write_wav(path, pcm_bytes: bytes, sample_rate: int = 8000) -> None:
+ """Write mono 16-bit PCM bytes to a WAV file."""
+ with wave.open(str(path), 'wb') as wf:
+ wf.setnchannels(1)
+ wf.setsampwidth(2)
+ wf.setframerate(sample_rate)
+ wf.writeframes(pcm_bytes)
+
+
+def decode_text_from_events(events) -> str:
+ out = []
+ for ev in events:
+ if ev.get('type') == 'morse_char':
+ out.append(str(ev.get('char', '')))
+ elif ev.get('type') == 'morse_space':
+ out.append(' ')
+ return ''.join(out)
+
+
+# ---------------------------------------------------------------------------
+# Unit tests
+# ---------------------------------------------------------------------------
+
+class TestMorseTable:
+ def test_morse_table_contains_letters_and_digits(self):
+ chars = set(MORSE_TABLE.values())
+ for ch in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789':
+ assert ch in chars
+
+ def test_round_trip_morse_lookup(self):
+ for morse, char in MORSE_TABLE.items():
+ if char in CHAR_TO_MORSE:
+ assert CHAR_TO_MORSE[char] == morse
+
+
+class TestToneDetector:
+ def test_goertzel_prefers_target_frequency(self):
+ gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160)
+ on_tone = [0.8 * math.sin(2 * math.pi * 700.0 * i / 8000.0) for i in range(160)]
+ off_tone = [0.8 * math.sin(2 * math.pi * 1500.0 * i / 8000.0) for i in range(160)]
+ assert gf.magnitude(on_tone) > gf.magnitude(off_tone) * 3.0
+
+
+class TestTimingAndWpmEstimator:
+ def test_timing_classifier_distinguishes_dit_and_dah(self):
+ decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
+ dit = 1.2 / 15.0
+ dah = dit * 3.0
+
+ audio = (
+ generate_silence(0.35)
+ + generate_tone(700.0, dit)
+ + generate_silence(dit * 1.5)
+ + generate_tone(700.0, dah)
+ + generate_silence(0.35)
+ )
+
+ events = decoder.process_block(audio)
+ events.extend(decoder.flush())
+ elements = [e['element'] for e in events if e.get('type') == 'morse_element']
+
+ assert '.' in elements
+ assert '-' in elements
+
+ def test_wpm_estimator_sanity(self):
+ target_wpm = 18
+ audio = generate_morse_audio('PARIS PARIS PARIS', wpm=target_wpm)
+ decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=12, wpm_mode='auto')
+
+ events = decoder.process_block(audio)
+ events.extend(decoder.flush())
+
+ metrics = decoder.get_metrics()
+ assert metrics['wpm'] >= 10.0
+ assert metrics['wpm'] <= 35.0
+
+
+# ---------------------------------------------------------------------------
+# Decoder thread tests
+# ---------------------------------------------------------------------------
+
+class TestMorseDecoderThread:
+ def test_thread_emits_waiting_heartbeat_on_no_data(self):
+ stop_event = threading.Event()
+ output_queue = queue.Queue(maxsize=64)
+
+ read_fd, write_fd = os.pipe()
+ read_file = os.fdopen(read_fd, 'rb', 0)
+
+ worker = threading.Thread(
+ target=morse_decoder_thread,
+ args=(read_file, output_queue, stop_event),
+ daemon=True,
+ )
+ worker.start()
+
+ got_waiting = False
+ deadline = time.monotonic() + 3.5
+ while time.monotonic() < deadline:
+ try:
+ msg = output_queue.get(timeout=0.3)
+ except queue.Empty:
+ continue
+ if msg.get('type') == 'scope' and msg.get('waiting'):
+ got_waiting = True
+ break
+
+ stop_event.set()
+ os.close(write_fd)
+ read_file.close()
+ worker.join(timeout=2.0)
+
+ assert got_waiting is True
+ assert not worker.is_alive()
+
+ def test_thread_produces_character_events(self):
+ stop_event = threading.Event()
+ output_queue = queue.Queue(maxsize=512)
+ audio = generate_morse_audio('SOS', wpm=15)
+
+ worker = threading.Thread(
+ target=morse_decoder_thread,
+ args=(io.BytesIO(audio), output_queue, stop_event),
+ daemon=True,
+ )
+ worker.start()
+ worker.join(timeout=4.0)
+
+ events = []
+ while not output_queue.empty():
+ events.append(output_queue.get_nowait())
+
+ chars = [e for e in events if e.get('type') == 'morse_char']
+ assert len(chars) >= 1
+
+
+# ---------------------------------------------------------------------------
+# Route lifecycle regression
+# ---------------------------------------------------------------------------
+
+class TestMorseLifecycleRoutes:
+ def _reset_route_state(self):
+ with app_module.morse_lock:
+ app_module.morse_process = None
+ while not app_module.morse_queue.empty():
+ try:
+ app_module.morse_queue.get_nowait()
+ except queue.Empty:
+ break
+
+ morse_routes.morse_active_device = None
+ morse_routes.morse_decoder_worker = None
+ morse_routes.morse_stderr_worker = None
+ morse_routes.morse_relay_worker = None
+ morse_routes.morse_stop_event = None
+ morse_routes.morse_control_queue = None
+ morse_routes.morse_runtime_config = {}
+ morse_routes.morse_last_error = ''
+ morse_routes.morse_state = morse_routes.MORSE_IDLE
+ morse_routes.morse_state_message = 'Idle'
+
+ def test_start_stop_reaches_idle_and_releases_resources(self, client, monkeypatch):
+ _login_session(client)
+ self._reset_route_state()
+
+ released_devices = []
+
+ monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None)
+ monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx))
+
+ class DummyDevice:
+ sdr_type = morse_routes.SDRType.RTL_SDR
+
+ class DummyBuilder:
+ def build_fm_demod_command(self, **kwargs):
+ return ['rtl_fm', '-f', '14060000', '-']
+
+ monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice()))
+ monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder()))
+ monkeypatch.setattr(morse_routes.SDRFactory, 'detect_devices', staticmethod(lambda: []))
+
+ pcm = generate_morse_audio('E', wpm=15, sample_rate=22050)
+
+ class FakeRtlProc:
+ def __init__(self, payload: bytes):
+ self.stdout = io.BytesIO(payload)
+ self.stderr = io.BytesIO(b'')
+ self.returncode = None
+
+ def poll(self):
+ return self.returncode
+
+ def terminate(self):
+ self.returncode = 0
+
+ def wait(self, timeout=None):
+ self.returncode = 0
+ return 0
+
+ def kill(self):
+ self.returncode = -9
+
+ def fake_popen(cmd, *args, **kwargs):
+ return FakeRtlProc(pcm)
+
+ monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen)
+ monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None)
+ monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None)
+ monkeypatch.setattr(
+ morse_routes,
+ 'safe_terminate',
+ lambda proc, timeout=0.0: setattr(proc, 'returncode', 0),
+ )
+
+ start_resp = client.post('/morse/start', json={
+ 'frequency': '14.060',
+ 'gain': '20',
+ 'ppm': '0',
+ 'device': '0',
+ 'tone_freq': '700',
+ 'wpm': '15',
+ })
+ assert start_resp.status_code == 200
+ assert start_resp.get_json()['status'] == 'started'
+
+ status_resp = client.get('/morse/status')
+ assert status_resp.status_code == 200
+ assert status_resp.get_json()['state'] in {'running', 'starting', 'stopping', 'idle'}
+
+ stop_resp = client.post('/morse/stop')
+ assert stop_resp.status_code == 200
+ stop_data = stop_resp.get_json()
+ assert stop_data['status'] == 'stopped'
+ assert stop_data['state'] == 'idle'
+ assert stop_data['alive'] == []
+
+ final_status = client.get('/morse/status').get_json()
+ assert final_status['running'] is False
+ assert final_status['state'] == 'idle'
+ assert 0 in released_devices
+
+ def test_start_retries_after_early_process_exit(self, client, monkeypatch):
+ _login_session(client)
+ self._reset_route_state()
+
+ released_devices = []
+
+ monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None)
+ monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx))
+
+ class DummyDevice:
+ sdr_type = morse_routes.SDRType.RTL_SDR
+
+ class DummyBuilder:
+ def build_fm_demod_command(self, **kwargs):
+ cmd = ['rtl_fm', '-f', '14.060M', '-M', 'usb', '-s', '22050']
+ if kwargs.get('direct_sampling') is not None:
+ cmd.extend(['--direct', str(kwargs['direct_sampling'])])
+ cmd.append('-')
+ return cmd
+
+ monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice()))
+ monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder()))
+ monkeypatch.setattr(morse_routes.SDRFactory, 'detect_devices', staticmethod(lambda: []))
+
+ pcm = generate_morse_audio('E', wpm=15, sample_rate=22050)
+ rtl_cmds = []
+
+ class FakeRtlProc:
+ def __init__(self, stdout_bytes: bytes, returncode: int | None):
+ self.stdout = io.BytesIO(stdout_bytes)
+ self.stderr = io.BytesIO(b'')
+ self.returncode = returncode
+
+ def poll(self):
+ return self.returncode
+
+ def terminate(self):
+ self.returncode = 0
+
+ def wait(self, timeout=None):
+ self.returncode = 0
+ return 0
+
+ def kill(self):
+ self.returncode = -9
+
+ def fake_popen(cmd, *args, **kwargs):
+ rtl_cmds.append(cmd)
+ if len(rtl_cmds) == 1:
+ return FakeRtlProc(b'', 1)
+ return FakeRtlProc(pcm, None)
+
+ monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen)
+ monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None)
+ monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None)
+ monkeypatch.setattr(
+ morse_routes,
+ 'safe_terminate',
+ lambda proc, timeout=0.0: setattr(proc, 'returncode', 0),
+ )
+
+ start_resp = client.post('/morse/start', json={
+ 'frequency': '14.060',
+ 'gain': '20',
+ 'ppm': '0',
+ 'device': '0',
+ 'tone_freq': '700',
+ 'wpm': '15',
+ })
+ assert start_resp.status_code == 200
+ assert start_resp.get_json()['status'] == 'started'
+ assert len(rtl_cmds) >= 2
+ assert rtl_cmds[0][0] == 'rtl_fm'
+ assert '--direct' in rtl_cmds[0]
+ assert '2' in rtl_cmds[0]
+ assert rtl_cmds[1][0] == 'rtl_fm'
+ assert '--direct' in rtl_cmds[1]
+ assert '1' in rtl_cmds[1]
+
+ stop_resp = client.post('/morse/stop')
+ assert stop_resp.status_code == 200
+ assert stop_resp.get_json()['status'] == 'stopped'
+ assert 0 in released_devices
+
+ def test_start_falls_back_to_next_device_when_selected_device_has_no_pcm(self, client, monkeypatch):
+ _login_session(client)
+ self._reset_route_state()
+
+ released_devices = []
+
+ monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None)
+ monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx))
+
+ class DummyDevice:
+ def __init__(self, index: int):
+ self.sdr_type = morse_routes.SDRType.RTL_SDR
+ self.index = index
+
+ class DummyDetected:
+ def __init__(self, index: int, serial: str):
+ self.sdr_type = morse_routes.SDRType.RTL_SDR
+ self.index = index
+ self.name = f'RTL {index}'
+ self.serial = serial
+
+ class DummyBuilder:
+ def build_fm_demod_command(self, **kwargs):
+ cmd = ['rtl_fm', '-d', str(kwargs['device'].index), '-f', '14.060M', '-M', 'usb', '-s', '22050']
+ if kwargs.get('direct_sampling') is not None:
+ cmd.extend(['--direct', str(kwargs['direct_sampling'])])
+ cmd.append('-')
+ return cmd
+
+ monkeypatch.setattr(
+ morse_routes.SDRFactory,
+ 'create_default_device',
+ staticmethod(lambda sdr_type, index: DummyDevice(int(index))),
+ )
+ monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder()))
+ monkeypatch.setattr(
+ morse_routes.SDRFactory,
+ 'detect_devices',
+ staticmethod(lambda: [DummyDetected(0, 'AAA00000'), DummyDetected(1, 'BBB11111')]),
+ )
+
+ pcm = generate_morse_audio('E', wpm=15, sample_rate=22050)
+
+ class FakeRtlProc:
+ def __init__(self, stdout_bytes: bytes, returncode: int | None):
+ self.stdout = io.BytesIO(stdout_bytes)
+ self.stderr = io.BytesIO(b'')
+ self.returncode = returncode
+
+ def poll(self):
+ return self.returncode
+
+ def terminate(self):
+ self.returncode = 0
+
+ def wait(self, timeout=None):
+ self.returncode = 0
+ return 0
+
+ def kill(self):
+ self.returncode = -9
+
+ def fake_popen(cmd, *args, **kwargs):
+ try:
+ dev = int(cmd[cmd.index('-d') + 1])
+ except Exception:
+ dev = 0
+ if dev == 0:
+ return FakeRtlProc(b'', 1)
+ return FakeRtlProc(pcm, None)
+
+ monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen)
+ monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None)
+ monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None)
+ monkeypatch.setattr(
+ morse_routes,
+ 'safe_terminate',
+ lambda proc, timeout=0.0: setattr(proc, 'returncode', 0),
+ )
+
+ start_resp = client.post('/morse/start', json={
+ 'frequency': '14.060',
+ 'gain': '20',
+ 'ppm': '0',
+ 'device': '0',
+ 'tone_freq': '700',
+ 'wpm': '15',
+ })
+ assert start_resp.status_code == 200
+ start_data = start_resp.get_json()
+ assert start_data['status'] == 'started'
+ assert start_data['config']['active_device'] == 1
+ assert start_data['config']['device_serial'] == 'BBB11111'
+ assert 0 in released_devices
+
+ stop_resp = client.post('/morse/stop')
+ assert stop_resp.status_code == 200
+ assert stop_resp.get_json()['status'] == 'stopped'
+ assert 1 in released_devices
+
+
+# ---------------------------------------------------------------------------
+# Integration: synthetic CW -> WAV decode
+# ---------------------------------------------------------------------------
+
+class TestMorseIntegration:
+ def test_decode_morse_wav_contains_expected_phrase(self, tmp_path):
+ wav_path = tmp_path / 'cq_test_123.wav'
+ pcm = generate_morse_audio('CQ TEST 123', wpm=15, tone_freq=700.0)
+ write_wav(wav_path, pcm, sample_rate=8000)
+
+ result = decode_morse_wav_file(
+ wav_path,
+ sample_rate=8000,
+ tone_freq=700.0,
+ wpm=15,
+ bandwidth_hz=200,
+ auto_tone_track=True,
+ threshold_mode='auto',
+ wpm_mode='auto',
+ min_signal_gate=0.0,
+ )
+
+ decoded = ' '.join(str(result.get('text', '')).split())
+ assert 'CQ TEST 123' in decoded
+
+ events = result.get('events', [])
+ event_counts = Counter(e.get('type') for e in events)
+ assert event_counts['morse_char'] >= len('CQTEST123')
diff --git a/tests/test_system.py b/tests/test_system.py
new file mode 100644
index 0000000..6d2ea59
--- /dev/null
+++ b/tests/test_system.py
@@ -0,0 +1,89 @@
+"""Tests for the System Health monitoring blueprint."""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+
+def _login(client):
+ """Mark the Flask test session as authenticated."""
+ with client.session_transaction() as sess:
+ sess['logged_in'] = True
+ sess['username'] = 'test'
+ sess['role'] = 'admin'
+
+
+def test_metrics_returns_expected_keys(client):
+ """GET /system/metrics returns top-level metric keys."""
+ _login(client)
+ resp = client.get('/system/metrics')
+ assert resp.status_code == 200
+ data = resp.get_json()
+ assert 'system' in data
+ assert 'processes' in data
+ assert 'cpu' in data
+ assert 'memory' in data
+ assert 'disk' in data
+ assert data['system']['hostname']
+ assert 'version' in data['system']
+ assert 'uptime_seconds' in data['system']
+ assert 'uptime_human' in data['system']
+
+
+def test_metrics_without_psutil(client):
+ """Metrics degrade gracefully when psutil is unavailable."""
+ _login(client)
+ import routes.system as mod
+
+ orig = mod._HAS_PSUTIL
+ mod._HAS_PSUTIL = False
+ try:
+ resp = client.get('/system/metrics')
+ assert resp.status_code == 200
+ data = resp.get_json()
+ # These fields should be None without psutil
+ assert data['cpu'] is None
+ assert data['memory'] is None
+ assert data['disk'] is None
+ finally:
+ mod._HAS_PSUTIL = orig
+
+
+def test_sdr_devices_returns_list(client):
+ """GET /system/sdr_devices returns a devices list."""
+ _login(client)
+ mock_device = MagicMock()
+ mock_device.sdr_type = MagicMock()
+ mock_device.sdr_type.value = 'rtlsdr'
+ mock_device.index = 0
+ mock_device.name = 'Generic RTL2832U'
+ mock_device.serial = '00000001'
+ mock_device.driver = 'rtlsdr'
+
+ with patch('utils.sdr.detection.detect_all_devices', return_value=[mock_device]):
+ resp = client.get('/system/sdr_devices')
+ assert resp.status_code == 200
+ data = resp.get_json()
+ assert 'devices' in data
+ assert len(data['devices']) == 1
+ assert data['devices'][0]['type'] == 'rtlsdr'
+ assert data['devices'][0]['name'] == 'Generic RTL2832U'
+
+
+def test_sdr_devices_handles_detection_failure(client):
+ """SDR detection failure returns empty list with error."""
+ _login(client)
+ with patch('utils.sdr.detection.detect_all_devices', side_effect=RuntimeError('no devices')):
+ resp = client.get('/system/sdr_devices')
+ assert resp.status_code == 200
+ data = resp.get_json()
+ assert data['devices'] == []
+ assert 'error' in data
+
+
+def test_stream_returns_sse_content_type(client):
+ """GET /system/stream returns text/event-stream."""
+ _login(client)
+ resp = client.get('/system/stream')
+ assert resp.status_code == 200
+ assert 'text/event-stream' in resp.content_type
diff --git a/utils/dependencies.py b/utils/dependencies.py
index e595103..933be4b 100644
--- a/utils/dependencies.py
+++ b/utils/dependencies.py
@@ -1,10 +1,11 @@
from __future__ import annotations
-import logging
-import os
-import shutil
-import subprocess
-from typing import Any
+import logging
+import os
+import platform
+import shutil
+import subprocess
+from typing import Any
logger = logging.getLogger('intercept.dependencies')
@@ -17,12 +18,32 @@ def check_tool(name: str) -> bool:
return get_tool_path(name) is not None
-def get_tool_path(name: str) -> str | None:
- """Get the full path to a tool, checking standard PATH and extra locations."""
- # First check standard PATH
- path = shutil.which(name)
- if path:
- return path
+def get_tool_path(name: str) -> str | None:
+ """Get the full path to a tool, checking standard PATH and extra locations."""
+ # Optional explicit override, e.g. INTERCEPT_RTL_FM_PATH=/opt/homebrew/bin/rtl_fm
+ env_key = f"INTERCEPT_{name.upper().replace('-', '_')}_PATH"
+ env_path = os.environ.get(env_key)
+ if env_path and os.path.isfile(env_path) and os.access(env_path, os.X_OK):
+ return env_path
+
+ # Prefer native Homebrew binaries on Apple Silicon to avoid mixing Rosetta
+ # /usr/local tools with arm64 Python/runtime.
+ if platform.system() == 'Darwin':
+ machine = platform.machine().lower()
+ preferred_paths: list[str] = []
+ if machine in {'arm64', 'aarch64'}:
+ preferred_paths.append('/opt/homebrew/bin')
+ preferred_paths.append('/usr/local/bin')
+
+ for base in preferred_paths:
+ full_path = os.path.join(base, name)
+ if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
+ return full_path
+
+ # First check standard PATH
+ path = shutil.which(name)
+ if path:
+ return path
# Check additional paths (e.g., /usr/sbin for aircrack-ng on Debian)
for extra_path in EXTRA_TOOL_PATHS:
diff --git a/utils/dsc/constants.py b/utils/dsc/constants.py
index d8372f9..727f596 100644
--- a/utils/dsc/constants.py
+++ b/utils/dsc/constants.py
@@ -89,6 +89,15 @@ TELECOMMAND_CODES = {
201: 'POLL_RESPONSE', # Poll response
}
+# Full 0-127 telecommand lookup (maps unknown codes to "UNKNOWN")
+TELECOMMAND_CODES_FULL = {i: TELECOMMAND_CODES.get(i, "UNKNOWN") for i in range(128)}
+
+# Format codes that carry telecommand fields
+TELECOMMAND_FORMATS = {112, 114, 116, 120, 123}
+
+# Minimum symbols (after phasing strip) before an EOS can be accepted
+MIN_SYMBOLS_FOR_FORMAT = 12
+
# =============================================================================
# DSC Symbol Definitions
diff --git a/utils/dsc/decoder.py b/utils/dsc/decoder.py
index 3f6399e..bab7720 100644
--- a/utils/dsc/decoder.py
+++ b/utils/dsc/decoder.py
@@ -43,6 +43,8 @@ from .constants import (
FORMAT_CODES,
DISTRESS_NATURE_CODES,
VALID_EOS,
+ TELECOMMAND_FORMATS,
+ MIN_SYMBOLS_FOR_FORMAT,
)
# Configure logging
@@ -222,13 +224,14 @@ class DSCDecoder:
Detect DSC dot pattern for synchronization.
The dot pattern is at least 200 alternating bits (1010101...).
- We look for at least 20 consecutive alternations.
+ We require at least 100 consecutive alternations to avoid
+ false sync triggers from noise.
"""
- if len(self.bit_buffer) < 40:
+ if len(self.bit_buffer) < 200:
return False
- # Check last 40 bits for alternating pattern
- last_bits = self.bit_buffer[-40:]
+ # Check last 200 bits for alternating pattern
+ last_bits = self.bit_buffer[-200:]
alternations = 0
for i in range(1, len(last_bits)):
@@ -237,7 +240,7 @@ class DSCDecoder:
else:
alternations = 0
- if alternations >= 20:
+ if alternations >= 100:
return True
return False
@@ -263,27 +266,37 @@ class DSCDecoder:
if end <= len(self.message_bits):
symbol_bits = self.message_bits[start:end]
symbol_value = self._bits_to_symbol(symbol_bits)
+ if symbol_value == -1:
+ logger.debug("DSC symbol check bit failure, aborting decode")
+ return None
symbols.append(symbol_value)
# Strip phasing sequence (RX/DX symbols 120-126) from the
# start of the message. Per ITU-R M.493, after the dot pattern
# there are 7 phasing symbols before the format specifier.
+ # Bound to max 7 — if more are present, this is a bad sync.
msg_start = 0
for i, sym in enumerate(symbols):
if 120 <= sym <= 126:
msg_start = i + 1
else:
break
+ if msg_start > 7:
+ logger.debug("DSC bad sync: >7 phasing symbols stripped")
+ return None
symbols = symbols[msg_start:]
if len(symbols) < 5:
return None
# Look for EOS (End of Sequence) - symbols 117, 122, or 127
+ # EOS must appear after at least MIN_SYMBOLS_FOR_FORMAT symbols
eos_found = False
eos_index = -1
for i, sym in enumerate(symbols):
if sym in VALID_EOS:
+ if i < MIN_SYMBOLS_FOR_FORMAT:
+ continue # Too early — not a real EOS
eos_found = True
eos_index = i
break
@@ -300,7 +313,9 @@ class DSCDecoder:
Convert 10 bits to symbol value.
DSC uses 10-bit symbols: 7 information bits + 3 error bits.
- We extract the 7-bit value.
+ The 3 check bits provide parity such that the total number of
+ '1' bits across all 10 bits should be even (even parity).
+ Returns -1 if the check bits are invalid.
"""
if len(bits) != 10:
return -1
@@ -311,6 +326,11 @@ class DSCDecoder:
if bits[i]:
value |= (1 << i)
+ # Validate check bits: total number of 1s should be even
+ ones = sum(bits)
+ if ones % 2 != 0:
+ return -1
+
return value
def _decode_symbols(self, symbols: list[int]) -> dict | None:
@@ -356,9 +376,13 @@ class DSCDecoder:
# Decode MMSI from symbols 1-5 (destination/address)
dest_mmsi = self._decode_mmsi(symbols[1:6])
+ if dest_mmsi is None:
+ return None
# Decode self-ID from symbols 6-10 (source)
source_mmsi = self._decode_mmsi(symbols[6:11])
+ if source_mmsi is None:
+ return None
message = {
'type': 'dsc',
@@ -387,8 +411,9 @@ class DSCDecoder:
if position:
message['position'] = position
- # Telecommand fields (usually last two before EOS)
- if len(remaining) >= 2:
+ # Telecommand fields (last two before EOS) — only for formats
+ # that carry telecommand fields per ITU-R M.493
+ if format_code in TELECOMMAND_FORMATS and len(remaining) >= 2:
message['telecommand1'] = remaining[-2]
message['telecommand2'] = remaining[-1]
@@ -402,20 +427,21 @@ class DSCDecoder:
logger.warning(f"DSC decode error: {e}")
return None
- def _decode_mmsi(self, symbols: list[int]) -> str:
+ def _decode_mmsi(self, symbols: list[int]) -> str | None:
"""
Decode MMSI from 5 DSC symbols.
Each symbol represents 2 BCD digits (00-99).
5 symbols = 10 digits, but MMSI is 9 digits (first symbol has leading 0).
+ Returns None if any symbol is out of valid BCD range.
"""
if len(symbols) < 5:
- return '000000000'
+ return None
digits = []
for sym in symbols:
if sym < 0 or sym > 99:
- sym = 0
+ return None
# Each symbol is 2 BCD digits
digits.append(f'{sym:02d}')
diff --git a/utils/dsc/parser.py b/utils/dsc/parser.py
index 30f7dd3..0e1f87e 100644
--- a/utils/dsc/parser.py
+++ b/utils/dsc/parser.py
@@ -248,7 +248,10 @@ 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', 'ALL_SHIPS_URGENCY_SAFETY')
+ msg['is_critical'] = msg['category'] in (
+ 'DISTRESS', 'DISTRESS_ACK', 'DISTRESS_RELAY',
+ 'URGENCY', 'SAFETY', 'ALL_SHIPS_URGENCY_SAFETY',
+ )
return msg
diff --git a/utils/morse.py b/utils/morse.py
index cd354f3..a0be082 100644
--- a/utils/morse.py
+++ b/utils/morse.py
@@ -1,276 +1,1279 @@
-"""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)
+"""Morse code (CW) decoding helpers.
+
+Signal chain:
+- SDR audio from `rtl_fm -M usb` (16-bit LE PCM)
+- Goertzel tone detection with optional auto-tone tracking
+- Adaptive threshold + hysteresis + minimum signal gate
+- Timing estimator (auto/manual WPM) and Morse symbol decoding
+"""
+
+from __future__ import annotations
+
+import contextlib
+import math
+import os
+import queue
+import select
+import struct
+import threading
+import time
+import wave
+from collections import deque
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+
+try:
+ # Reuse existing Goertzel helper when available.
+ from utils.sstv.dsp import goertzel_mag as _shared_goertzel_mag
+except Exception: # pragma: no cover - fallback path
+ _shared_goertzel_mag = None
+
+# 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."""
+
+ def __init__(self, target_freq: float, sample_rate: int, block_size: int):
+ self.target_freq = float(target_freq)
+ self.sample_rate = int(sample_rate)
+ self.block_size = int(block_size)
+ # Generalized coefficient (does not quantize to integer FFT bins)
+ omega = 2.0 * math.pi * self.target_freq / self.sample_rate
+ self.coeff = 2.0 * math.cos(omega)
+
+ def magnitude(self, samples: list[float] | tuple[float, ...] | np.ndarray) -> 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 = float(sample) + coeff * s1 - s2
+ s2 = s1
+ s1 = s0
+ power = s1 * s1 + s2 * s2 - coeff * s1 * s2
+ return math.sqrt(max(power, 0.0))
+
+
+def _goertzel_mag(samples: np.ndarray, target_freq: float, sample_rate: int) -> float:
+ """Compute Goertzel magnitude, preferring shared DSP helper."""
+ if _shared_goertzel_mag is not None:
+ try:
+ return float(_shared_goertzel_mag(samples, float(target_freq), int(sample_rate)))
+ except Exception:
+ pass
+ filt = GoertzelFilter(target_freq=target_freq, sample_rate=sample_rate, block_size=len(samples))
+ return filt.magnitude(samples)
+
+
+def _coerce_bool(value: Any, default: bool = False) -> bool:
+ """Convert arbitrary JSON-ish values to bool."""
+ if isinstance(value, bool):
+ return value
+ if value is None:
+ return default
+ text = str(value).strip().lower()
+ if text in {'1', 'true', 'yes', 'on'}:
+ return True
+ if text in {'0', 'false', 'no', 'off'}:
+ return False
+ return default
+
+
+def _normalize_threshold_mode(value: Any) -> str:
+ mode = str(value or 'auto').strip().lower()
+ return mode if mode in {'auto', 'manual'} else 'auto'
+
+
+def _normalize_wpm_mode(value: Any) -> str:
+ mode = str(value or 'auto').strip().lower()
+ return mode if mode in {'auto', 'manual'} else 'auto'
+
+
+def _clamp(value: float, lo: float, hi: float) -> float:
+ return min(hi, max(lo, value))
+
+
+class MorseDecoder:
+ """Real-time Morse decoder with adaptive threshold and timing estimation."""
+
+ def __init__(
+ self,
+ sample_rate: int = 8000,
+ tone_freq: float = 700.0,
+ wpm: int = 15,
+ bandwidth_hz: int = 200,
+ auto_tone_track: bool = True,
+ tone_lock: bool = False,
+ threshold_mode: str = 'auto',
+ manual_threshold: float = 0.0,
+ threshold_multiplier: float = 2.8,
+ threshold_offset: float = 0.0,
+ wpm_mode: str = 'auto',
+ wpm_lock: bool = False,
+ min_signal_gate: float = 0.0,
+ ):
+ self.sample_rate = int(sample_rate)
+ self.tone_freq = float(tone_freq)
+ self.wpm = int(wpm)
+
+ self.bandwidth_hz = int(_clamp(float(bandwidth_hz), 50, 400))
+ self.auto_tone_track = bool(auto_tone_track)
+ self.tone_lock = bool(tone_lock)
+ self.threshold_mode = _normalize_threshold_mode(threshold_mode)
+ self.manual_threshold = max(0.0, float(manual_threshold))
+ self.threshold_multiplier = float(_clamp(float(threshold_multiplier), 1.1, 8.0))
+ self.threshold_offset = max(0.0, float(threshold_offset))
+ self.wpm_mode = _normalize_wpm_mode(wpm_mode)
+ self.wpm_lock = bool(wpm_lock)
+ self.min_signal_gate = float(_clamp(float(min_signal_gate), 0.0, 1.0))
+
+ # ~50 analysis windows/s at 8 kHz keeps CPU low and timing stable.
+ self._block_size = max(64, self.sample_rate // 50)
+ self._block_duration = self._block_size / float(self.sample_rate)
+
+ self._active_tone_freq = float(_clamp(self.tone_freq, 300.0, 1200.0))
+ self._tone_anchor_freq = self._active_tone_freq
+ self._tone_scan_range_hz = 180.0
+ self._tone_scan_step_hz = 10.0
+ self._tone_scan_interval_blocks = 8
+
+ self._detector = GoertzelFilter(self._active_tone_freq, self.sample_rate, self._block_size)
+ self._noise_detector_low = GoertzelFilter(
+ _clamp(self._active_tone_freq - max(150.0, self.bandwidth_hz), 150.0, 2000.0),
+ self.sample_rate,
+ self._block_size,
+ )
+ self._noise_detector_high = GoertzelFilter(
+ _clamp(self._active_tone_freq + max(150.0, self.bandwidth_hz), 150.0, 2000.0),
+ self.sample_rate,
+ self._block_size,
+ )
+
+ # AGC for weak HF/direct-sampling signals.
+ self._agc_target = 0.22
+ self._agc_gain = 1.0
+ self._agc_alpha = 0.06
+
+ # Envelope smoothing.
+ self._attack_alpha = 0.55
+ self._release_alpha = 0.45
+ self._envelope = 0.0
+
+ # Adaptive threshold model.
+ self._noise_floor = 0.0
+ self._signal_peak = 0.0
+ self._threshold = 0.0
+ self._hysteresis = 0.12
+
+ # Warm-up bootstrap.
+ self._WARMUP_BLOCKS = 16
+ self._SETTLE_BLOCKS = 140
+ self._mag_min = float('inf')
+ self._mag_max = 0.0
+ self._blocks_processed = 0
+
+ # Timing model (in block units, kept for backward compatibility with tests).
+ dit_sec = 1.2 / max(self.wpm, 1)
+ dit_blocks = max(1.0, dit_sec / self._block_duration)
+ self._dah_threshold = 2.2 * dit_blocks
+ self._dit_min = 0.38 * dit_blocks
+ self._char_gap = 2.6 * dit_blocks
+ self._word_gap = 6.0 * dit_blocks
+ self._dit_observations: deque[float] = deque(maxlen=32)
+ self._estimated_wpm = float(self.wpm)
+
+ # State machine.
+ self._tone_on = False
+ self._tone_blocks = 0.0
+ self._silence_blocks = 0.0
+ self._current_symbol = ''
+ self._pending_buffer: list[int] = []
+
+ # Output / diagnostics.
+ self._last_level = 0.0
+ self._last_noise_ref = 0.0
+
+ def reset_calibration(self) -> None:
+ """Reset adaptive threshold and timing estimator state."""
+ self._noise_floor = 0.0
+ self._signal_peak = 0.0
+ self._threshold = 0.0
+ self._mag_min = float('inf')
+ self._mag_max = 0.0
+ self._blocks_processed = 0
+ self._dit_observations.clear()
+ self._estimated_wpm = float(self.wpm)
+ self._tone_on = False
+ self._tone_blocks = 0.0
+ self._silence_blocks = 0.0
+ self._current_symbol = ''
+
+ def get_metrics(self) -> dict[str, float | bool]:
+ """Return latest decoder metrics for UI/status messages."""
+ snr_mult = max(1.15, self.threshold_multiplier * 0.5)
+ snr_on = snr_mult * (1.0 + self._hysteresis)
+ snr_off = snr_mult * (1.0 - self._hysteresis)
+ return {
+ 'wpm': float(self._estimated_wpm),
+ 'tone_freq': float(self._active_tone_freq),
+ 'level': float(self._last_level),
+ 'noise_floor': float(self._noise_floor),
+ 'threshold': float(self._threshold),
+ 'tone_on': bool(self._tone_on),
+ 'dit_ms': float((self._effective_dit_blocks() * self._block_duration) * 1000.0),
+ 'snr': float(self._last_level / max(self._noise_floor, 1e-6)),
+ 'noise_ref': float(self._noise_floor),
+ 'snr_on': float(snr_on),
+ 'snr_off': float(snr_off),
+ }
+
+ def _rebuild_detectors(self) -> None:
+ """Rebuild target/noise Goertzel filters after tone updates."""
+ self._detector = GoertzelFilter(self._active_tone_freq, self.sample_rate, self._block_size)
+ ref_offset = max(150.0, self.bandwidth_hz)
+ self._noise_detector_low = GoertzelFilter(
+ _clamp(self._active_tone_freq - ref_offset, 150.0, 2000.0),
+ self.sample_rate,
+ self._block_size,
+ )
+ self._noise_detector_high = GoertzelFilter(
+ _clamp(self._active_tone_freq + ref_offset, 150.0, 2000.0),
+ self.sample_rate,
+ self._block_size,
+ )
+
+ def _estimate_tone_frequency(
+ self,
+ normalized: np.ndarray,
+ signal_mag: float,
+ noise_ref: float,
+ ) -> bool:
+ """Track dominant CW tone in a local window when a valid tone is present.
+
+ Returns True when the detector frequency changed.
+ """
+ if not self.auto_tone_track or self.tone_lock:
+ return False
+
+ # Skip retunes when the detector is mostly seeing noise.
+ if signal_mag <= max(noise_ref * 1.8, 0.02):
+ return False
+
+ lo = _clamp(self._active_tone_freq - self._tone_scan_range_hz, 300.0, 1200.0)
+ hi = _clamp(self._active_tone_freq + self._tone_scan_range_hz, 300.0, 1200.0)
+ if hi <= lo:
+ return False
+
+ best_freq = self._active_tone_freq
+ best_mag = float(signal_mag)
+
+ freq = lo
+ while freq <= hi + 1e-6:
+ mag = _goertzel_mag(normalized, freq, self.sample_rate)
+ if mag > best_mag:
+ best_mag = mag
+ best_freq = freq
+ freq += self._tone_scan_step_hz
+
+ # Require a meaningful improvement before moving off the current tone.
+ if best_mag <= (signal_mag * 1.12):
+ return False
+
+ # Smooth and cap per-step movement to avoid jumps on noisy windows.
+ delta = _clamp(best_freq - self._active_tone_freq, -30.0, 30.0)
+ smoothed = self._active_tone_freq + (0.35 * delta)
+ # Do not drift too far from the configured tone unless the user retunes.
+ smoothed = _clamp(
+ smoothed,
+ max(300.0, self._tone_anchor_freq - 240.0),
+ min(1200.0, self._tone_anchor_freq + 240.0),
+ )
+
+ if abs(smoothed - self._active_tone_freq) >= 2.5:
+ self._active_tone_freq = smoothed
+ self._rebuild_detectors()
+ return True
+ return False
+
+ def _effective_dit_blocks(self) -> float:
+ """Return current dit estimate in block units."""
+ if self.wpm_mode == 'manual' or self.wpm_lock:
+ wpm = max(5.0, min(50.0, float(self.wpm)))
+ dit_blocks = max(1.0, (1.2 / wpm) / self._block_duration)
+ self._estimated_wpm = wpm
+ return dit_blocks
+
+ if self._dit_observations:
+ ordered = sorted(self._dit_observations)
+ mid = ordered[len(ordered) // 2]
+ dit_blocks = max(1.0, float(mid))
+ est_wpm = 1.2 / (dit_blocks * self._block_duration)
+ self._estimated_wpm = _clamp(est_wpm, 5.0, 60.0)
+ return dit_blocks
+
+ self._estimated_wpm = float(self.wpm)
+ return max(1.0, (1.2 / max(self.wpm, 1)) / self._block_duration)
+
+ def _record_dit_candidate(self, blocks: float) -> None:
+ """Feed a possible dit duration into the estimator."""
+ if blocks <= 0:
+ return
+ if self.wpm_mode == 'manual' or self.wpm_lock:
+ return
+ if blocks > 20:
+ return
+ self._dit_observations.append(float(blocks))
+
+ def _decode_symbol(self, symbol: str, timestamp: str) -> dict[str, Any] | None:
+ char = MORSE_TABLE.get(symbol)
+ if char is None:
+ return None
+ return {
+ 'type': 'morse_char',
+ 'char': char,
+ 'morse': symbol,
+ 'timestamp': timestamp,
+ }
+
+ def process_block(self, pcm_bytes: bytes) -> list[dict[str, Any]]:
+ """Process PCM bytes and return decode/scope events."""
+ events: list[dict[str, Any]] = []
+
+ n_samples = len(pcm_bytes) // 2
+ if n_samples <= 0:
+ return events
+
+ samples = struct.unpack(f'<{n_samples}h', pcm_bytes[:n_samples * 2])
+ self._pending_buffer.extend(samples)
+
+ amplitudes: list[float] = []
+
+ while len(self._pending_buffer) >= self._block_size:
+ block = np.array(self._pending_buffer[:self._block_size], dtype=np.float64)
+ del self._pending_buffer[:self._block_size]
+
+ normalized = block / 32768.0
+
+ # AGC
+ rms = float(np.sqrt(np.mean(np.square(normalized))))
+ if rms > 1e-7:
+ desired_gain = self._agc_target / rms
+ self._agc_gain += self._agc_alpha * (desired_gain - self._agc_gain)
+ self._agc_gain = _clamp(self._agc_gain, 0.2, 450.0)
+ normalized *= self._agc_gain
+
+ self._blocks_processed += 1
+
+ mag = self._detector.magnitude(normalized)
+ noise_low = self._noise_detector_low.magnitude(normalized)
+ noise_high = self._noise_detector_high.magnitude(normalized)
+ noise_ref = max(1e-9, (noise_low + noise_high) * 0.5)
+
+ if (
+ self.auto_tone_track
+ and not self.tone_lock
+ and self._blocks_processed > self._WARMUP_BLOCKS
+ and (self._blocks_processed % self._tone_scan_interval_blocks == 0)
+ and self._estimate_tone_frequency(normalized, mag, noise_ref)
+ ):
+ # Detector changed; refresh magnitudes for this window.
+ mag = self._detector.magnitude(normalized)
+ noise_low = self._noise_detector_low.magnitude(normalized)
+ noise_high = self._noise_detector_high.magnitude(normalized)
+ noise_ref = max(1e-9, (noise_low + noise_high) * 0.5)
+
+ level = float(mag)
+ alpha = self._attack_alpha if level >= self._envelope else self._release_alpha
+ self._envelope += alpha * (level - self._envelope)
+ self._last_level = self._envelope
+ self._last_noise_ref = noise_ref
+ amplitudes.append(level)
+
+ if self._blocks_processed <= self._WARMUP_BLOCKS:
+ self._mag_min = min(self._mag_min, level)
+ self._mag_max = max(self._mag_max, level)
+ if self._blocks_processed == self._WARMUP_BLOCKS:
+ self._noise_floor = self._mag_min if math.isfinite(self._mag_min) else 0.0
+ if self._mag_max <= (self._noise_floor * 1.2):
+ self._signal_peak = max(self._noise_floor + 0.5, self._noise_floor * 2.5)
+ else:
+ self._signal_peak = max(self._mag_max, self._noise_floor * 1.8)
+ self._threshold = self._noise_floor + 0.22 * (
+ self._signal_peak - self._noise_floor
+ )
+ tone_detected = False
+ else:
+ settle_alpha = 0.30 if self._blocks_processed < (self._WARMUP_BLOCKS + self._SETTLE_BLOCKS) else 0.06
+
+ detector_level = level
+
+ if detector_level <= self._threshold:
+ self._noise_floor += settle_alpha * (detector_level - self._noise_floor)
+ else:
+ self._signal_peak += settle_alpha * (detector_level - self._signal_peak)
+
+ self._signal_peak = max(self._signal_peak, self._noise_floor * 1.05)
+
+ # Always blend adjacent-band noise reference into noise floor.
+ # Adjacent bands track the same AGC gain but exclude the tone,
+ # so this prevents noise floor from staying stuck at warmup-era
+ # low values after AGC converges.
+ self._noise_floor += (settle_alpha * 0.25) * (noise_ref - self._noise_floor)
+
+ if self.threshold_mode == 'manual':
+ self._threshold = max(0.0, self.manual_threshold)
+ else:
+ self._threshold = (
+ max(0.0, self._noise_floor * self.threshold_multiplier)
+ + self.threshold_offset
+ )
+ self._threshold = max(self._threshold, self._noise_floor + 0.35)
+
+ dynamic_span = max(0.0, self._signal_peak - self._noise_floor)
+ gate_level = self._noise_floor + (self.min_signal_gate * dynamic_span)
+ gate_ok = self.min_signal_gate <= 0.0 or detector_level >= gate_level
+
+ # Use SNR (tone mag / adjacent-band noise) for tone detection.
+ # Both bands are equally amplified by AGC, so the ratio is
+ # gain-invariant — fixes stuck-ON tone when AGC amplifies
+ # inter-element silence above the raw magnitude threshold.
+ snr = level / max(noise_ref, 1e-6)
+ snr_mult = max(1.15, self.threshold_multiplier * 0.5)
+ snr_on = snr_mult * (1.0 + self._hysteresis)
+ snr_off = snr_mult * (1.0 - self._hysteresis)
+
+ if self._tone_on:
+ tone_detected = gate_ok and snr >= snr_off
+ else:
+ tone_detected = gate_ok and snr >= snr_on
+
+ dit_blocks = self._effective_dit_blocks()
+ self._dah_threshold = 2.2 * dit_blocks
+ self._dit_min = max(1.0, 0.38 * dit_blocks)
+ self._char_gap = 2.6 * dit_blocks
+ self._word_gap = 6.0 * dit_blocks
+
+ if tone_detected and not self._tone_on:
+ # Tone edge up.
+ self._tone_on = True
+ silence_count = self._silence_blocks
+ self._silence_blocks = 0.0
+ self._tone_blocks = 0.0
+
+ if self._current_symbol and silence_count >= self._char_gap:
+ timestamp = datetime.now().strftime('%H:%M:%S')
+ decoded = self._decode_symbol(self._current_symbol, timestamp)
+ if decoded is not None:
+ events.append(decoded)
+
+ if silence_count >= self._word_gap:
+ events.append({
+ 'type': 'morse_space',
+ 'timestamp': timestamp,
+ })
+ events.append({
+ 'type': 'morse_gap',
+ 'gap': 'word',
+ 'duration_ms': round(silence_count * self._block_duration * 1000.0, 1),
+ })
+ else:
+ events.append({
+ 'type': 'morse_gap',
+ 'gap': 'char',
+ 'duration_ms': round(silence_count * self._block_duration * 1000.0, 1),
+ })
+
+ self._current_symbol = ''
+ elif silence_count >= 1.0:
+ # Intra-symbol gap candidate improves dit estimate for Farnsworth-style spacing.
+ if silence_count <= (self._char_gap * 0.95):
+ self._record_dit_candidate(silence_count)
+
+ elif (not tone_detected) and self._tone_on:
+ # Tone edge down.
+ self._tone_on = False
+ tone_count = max(1.0, self._tone_blocks)
+ self._tone_blocks = 0.0
+ self._silence_blocks = 0.0
+
+ element = ''
+ if tone_count >= self._dah_threshold:
+ element = '-'
+ elif tone_count >= self._dit_min:
+ element = '.'
+
+ if element:
+ self._current_symbol += element
+ events.append({
+ 'type': 'morse_element',
+ 'element': element,
+ 'duration_ms': round(tone_count * self._block_duration * 1000.0, 1),
+ })
+ if element == '.':
+ self._record_dit_candidate(tone_count)
+ elif tone_count <= (self._dah_threshold * 1.6):
+ # Some operators send short-ish dahs; still useful for tracking.
+ self._record_dit_candidate(tone_count / 3.0)
+
+ elif tone_detected and self._tone_on:
+ self._tone_blocks += 1.0
+
+ elif (not tone_detected) and (not self._tone_on):
+ self._silence_blocks += 1.0
+
+ if amplitudes:
+ snr_mult = max(1.15, self.threshold_multiplier * 0.5)
+ snr_on = snr_mult * (1.0 + self._hysteresis)
+ snr_off = snr_mult * (1.0 - self._hysteresis)
+ events.append({
+ 'type': 'scope',
+ 'amplitudes': amplitudes,
+ 'threshold': self._threshold,
+ 'tone_on': self._tone_on,
+ 'tone_freq': round(self._active_tone_freq, 1),
+ 'level': self._last_level,
+ 'noise_floor': self._noise_floor,
+ 'wpm': round(self._estimated_wpm, 1),
+ 'dit_ms': round(self._effective_dit_blocks() * self._block_duration * 1000.0, 1),
+ 'snr': round(self._last_level / max(self._noise_floor, 1e-6), 2),
+ 'noise_ref': round(self._noise_floor, 4),
+ 'snr_on': round(snr_on, 2),
+ 'snr_off': round(snr_off, 2),
+ })
+
+ return events
+
+ def flush(self) -> list[dict[str, Any]]:
+ """Flush pending symbols at end-of-stream."""
+ events: list[dict[str, Any]] = []
+
+ if self._tone_on and self._tone_blocks >= self._dit_min:
+ tone_count = self._tone_blocks
+ element = '-' if tone_count >= self._dah_threshold else '.'
+ self._current_symbol += element
+ events.append({
+ 'type': 'morse_element',
+ 'element': element,
+ 'duration_ms': round(tone_count * self._block_duration * 1000.0, 1),
+ })
+
+ if self._current_symbol:
+ decoded = self._decode_symbol(self._current_symbol, datetime.now().strftime('%H:%M:%S'))
+ if decoded is not None:
+ events.append(decoded)
+ self._current_symbol = ''
+
+ self._tone_on = False
+ self._tone_blocks = 0.0
+ self._silence_blocks = 0.0
+ return events
+
+
+def _wav_to_mono_float(path: Path) -> tuple[np.ndarray, int]:
+ """Load WAV file and return mono float32 samples in [-1, 1]."""
+ with wave.open(str(path), 'rb') as wf:
+ n_channels = wf.getnchannels()
+ sampwidth = wf.getsampwidth()
+ sample_rate = wf.getframerate()
+ n_frames = wf.getnframes()
+ raw = wf.readframes(n_frames)
+
+ if sampwidth == 1:
+ pcm = np.frombuffer(raw, dtype=np.uint8).astype(np.float64)
+ pcm = (pcm - 128.0) / 128.0
+ elif sampwidth == 2:
+ pcm = np.frombuffer(raw, dtype=np.int16).astype(np.float64) / 32768.0
+ elif sampwidth == 4:
+ pcm = np.frombuffer(raw, dtype=np.int32).astype(np.float64) / 2147483648.0
+ else:
+ raise ValueError(f'Unsupported WAV sample width: {sampwidth * 8} bits')
+
+ if n_channels > 1:
+ pcm = pcm.reshape(-1, n_channels).mean(axis=1)
+
+ return pcm.astype(np.float64), int(sample_rate)
+
+
+def _resample_linear(samples: np.ndarray, from_rate: int, to_rate: int) -> np.ndarray:
+ """Linear resampler with no extra dependencies."""
+ if from_rate == to_rate or len(samples) == 0:
+ return samples
+
+ ratio = float(to_rate) / float(from_rate)
+ new_len = max(1, int(round(len(samples) * ratio)))
+ x_old = np.linspace(0.0, 1.0, len(samples), endpoint=False)
+ x_new = np.linspace(0.0, 1.0, new_len, endpoint=False)
+ return np.interp(x_new, x_old, samples).astype(np.float64)
+
+
+def decode_morse_wav_file(
+ wav_path: str | Path,
+ *,
+ sample_rate: int = 8000,
+ tone_freq: float = 700.0,
+ wpm: int = 15,
+ bandwidth_hz: int = 200,
+ auto_tone_track: bool = True,
+ tone_lock: bool = False,
+ threshold_mode: str = 'auto',
+ manual_threshold: float = 0.0,
+ threshold_multiplier: float = 2.8,
+ threshold_offset: float = 0.0,
+ wpm_mode: str = 'auto',
+ wpm_lock: bool = False,
+ min_signal_gate: float = 0.0,
+) -> dict[str, Any]:
+ """Decode Morse from a WAV file and return text/events/metrics."""
+ path = Path(wav_path)
+ if not path.is_file():
+ raise FileNotFoundError(f'WAV file not found: {path}')
+
+ audio, file_rate = _wav_to_mono_float(path)
+ if file_rate != sample_rate:
+ audio = _resample_linear(audio, file_rate, sample_rate)
+
+ pcm = np.clip(audio, -1.0, 1.0)
+ pcm16 = (pcm * 32767.0).astype(np.int16)
+
+ decoder = MorseDecoder(
+ sample_rate=sample_rate,
+ tone_freq=tone_freq,
+ wpm=wpm,
+ bandwidth_hz=bandwidth_hz,
+ auto_tone_track=auto_tone_track,
+ tone_lock=tone_lock,
+ threshold_mode=threshold_mode,
+ manual_threshold=manual_threshold,
+ threshold_multiplier=threshold_multiplier,
+ threshold_offset=threshold_offset,
+ wpm_mode=wpm_mode,
+ wpm_lock=wpm_lock,
+ min_signal_gate=min_signal_gate,
+ )
+
+ events: list[dict[str, Any]] = []
+ chunk_samples = 2048
+ idx = 0
+ while idx < len(pcm16):
+ chunk = pcm16[idx:idx + chunk_samples]
+ if len(chunk) == 0:
+ break
+ events.extend(decoder.process_block(chunk.tobytes()))
+ idx += chunk_samples
+
+ events.extend(decoder.flush())
+
+ text_parts: list[str] = []
+ raw_parts: list[str] = []
+ for event in events:
+ et = event.get('type')
+ if et == 'morse_char':
+ text_parts.append(str(event.get('char', '')))
+ elif et == 'morse_space':
+ text_parts.append(' ')
+ elif et == 'morse_element':
+ raw_parts.append(str(event.get('element', '')))
+ elif et == 'morse_gap':
+ gap = str(event.get('gap', ''))
+ if gap == 'char':
+ raw_parts.append(' / ')
+ elif gap == 'word':
+ raw_parts.append(' // ')
+
+ text = ''.join(text_parts)
+ raw = ''.join(raw_parts).strip()
+
+ return {
+ 'text': text,
+ 'raw': raw,
+ 'events': events,
+ 'metrics': decoder.get_metrics(),
+ }
+
+
+def _drain_control_queue(control_queue: queue.Queue | None, decoder: MorseDecoder) -> bool:
+ """Process pending control commands; return False to request shutdown."""
+ if control_queue is None:
+ return True
+
+ keep_running = True
+ while True:
+ try:
+ cmd = control_queue.get_nowait()
+ except queue.Empty:
+ break
+
+ if not isinstance(cmd, dict):
+ continue
+ action = str(cmd.get('cmd', '')).strip().lower()
+ if action == 'reset':
+ decoder.reset_calibration()
+ elif action in {'shutdown', 'stop'}:
+ keep_running = False
+
+ return keep_running
+
+
+def _emit_waiting_scope(output_queue: queue.Queue, waiting_since: float) -> None:
+ """Emit waiting heartbeat while no PCM arrives."""
+ with contextlib.suppress(queue.Full):
+ output_queue.put_nowait({
+ 'type': 'scope',
+ 'amplitudes': [],
+ 'threshold': 0,
+ 'tone_on': False,
+ 'waiting': True,
+ 'waiting_seconds': round(max(0.0, time.monotonic() - waiting_since), 1),
+ })
+
+
+def _is_probably_rtl_log_text(data: bytes) -> bool:
+ """Heuristic: identify rtl_fm stderr log chunks when streams are merged."""
+ if not data:
+ return False
+ # PCM usually contains NULLs/non-printables; plain log lines do not.
+ if b'\x00' in data:
+ return False
+ printable = sum(1 for b in data if (32 <= b <= 126) or b in (9, 10, 13))
+ ratio = printable / max(1, len(data))
+ if ratio < 0.92:
+ return False
+ lower = data.lower()
+ keywords = (
+ b'rtl_fm',
+ b'found ',
+ b'using device',
+ b'tuned to',
+ b'sampling at',
+ b'output at',
+ b'buffer size',
+ b'gain',
+ b'direct sampling',
+ b'oversampling',
+ b'exact sample rate',
+ )
+ return any(token in lower for token in keywords)
+
+
+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,
+ decoder_config: dict[str, Any] | None = None,
+ control_queue: queue.Queue | None = None,
+ pcm_ready_event: threading.Event | None = None,
+ stream_ready_event: threading.Event | None = None,
+ strip_text_chunks: bool = False,
+) -> None:
+ """Decode Morse from live PCM stream and push events to *output_queue*."""
+ import logging
+ logger = logging.getLogger('intercept.morse')
+
+ CHUNK = 4096
+ SCOPE_INTERVAL = 0.10
+ WAITING_INTERVAL = 0.25
+ STALLED_AFTER_DATA_SECONDS = 1.5
+
+ cfg = dict(decoder_config or {})
+ decoder = MorseDecoder(
+ sample_rate=int(cfg.get('sample_rate', sample_rate)),
+ tone_freq=float(cfg.get('tone_freq', tone_freq)),
+ wpm=int(cfg.get('wpm', wpm)),
+ bandwidth_hz=int(cfg.get('bandwidth_hz', 200)),
+ auto_tone_track=_coerce_bool(cfg.get('auto_tone_track', True), True),
+ tone_lock=_coerce_bool(cfg.get('tone_lock', False), False),
+ threshold_mode=_normalize_threshold_mode(cfg.get('threshold_mode', 'auto')),
+ manual_threshold=float(cfg.get('manual_threshold', 0.0) or 0.0),
+ threshold_multiplier=float(cfg.get('threshold_multiplier', 2.8) or 2.8),
+ threshold_offset=float(cfg.get('threshold_offset', 0.0) or 0.0),
+ wpm_mode=_normalize_wpm_mode(cfg.get('wpm_mode', 'auto')),
+ wpm_lock=_coerce_bool(cfg.get('wpm_lock', False), False),
+ min_signal_gate=float(cfg.get('min_signal_gate', 0.0) or 0.0),
+ )
+
+ last_scope = time.monotonic()
+ last_waiting_emit = 0.0
+ waiting_since: float | None = None
+ last_pcm_at: float | None = None
+ pcm_bytes = 0
+ pcm_report_at = time.monotonic()
+ first_pcm_logged = False
+ reader_done = threading.Event()
+ reader_thread: threading.Thread | None = None
+ first_raw_logged = False
+
+ raw_queue: queue.Queue[bytes] = queue.Queue(maxsize=96)
+
+ try:
+ def _reader_loop() -> None:
+ """Blocking PCM reader isolated from decode/control loop."""
+ nonlocal first_raw_logged
+ try:
+ fd = None
+ with contextlib.suppress(Exception):
+ fd = rtl_stdout.fileno()
+ while not stop_event.is_set():
+ try:
+ if fd is not None:
+ ready, _, _ = select.select([fd], [], [], 0.20)
+ if not ready:
+ continue
+ data = os.read(fd, CHUNK)
+ elif hasattr(rtl_stdout, 'read1'):
+ data = rtl_stdout.read1(CHUNK)
+ else:
+ data = rtl_stdout.read(CHUNK)
+ except Exception as e:
+ with contextlib.suppress(queue.Full):
+ output_queue.put_nowait({
+ 'type': 'info',
+ 'text': f'[pcm] reader error: {e}',
+ })
+ break
+
+ if data is None:
+ continue
+
+ if not data:
+ break
+
+ if not first_raw_logged:
+ first_raw_logged = True
+ if stream_ready_event is not None:
+ stream_ready_event.set()
+ with contextlib.suppress(queue.Full):
+ output_queue.put_nowait({
+ 'type': 'info',
+ 'text': f'[pcm] first raw chunk: {len(data)} bytes',
+ })
+
+ if strip_text_chunks and _is_probably_rtl_log_text(data):
+ try:
+ text = data.decode('utf-8', errors='replace')
+ except Exception:
+ text = ''
+ if text:
+ for line in text.splitlines():
+ clean = line.strip()
+ if not clean:
+ continue
+ with contextlib.suppress(queue.Full):
+ output_queue.put_nowait({
+ 'type': 'info',
+ 'text': f'[rtl_fm] {clean}',
+ })
+ continue
+
+ try:
+ raw_queue.put(data, timeout=0.2)
+ except queue.Full:
+ # Keep latest PCM flowing even if downstream hiccups.
+ with contextlib.suppress(queue.Empty):
+ raw_queue.get_nowait()
+ with contextlib.suppress(queue.Full):
+ raw_queue.put_nowait(data)
+ finally:
+ reader_done.set()
+ with contextlib.suppress(queue.Full):
+ raw_queue.put_nowait(b'')
+
+ reader_thread = threading.Thread(
+ target=_reader_loop,
+ daemon=True,
+ name='morse-pcm-reader',
+ )
+ reader_thread.start()
+
+ while not stop_event.is_set():
+ if not _drain_control_queue(control_queue, decoder):
+ break
+
+ try:
+ data = raw_queue.get(timeout=0.20)
+ except queue.Empty:
+ now = time.monotonic()
+ should_emit_waiting = False
+ if last_pcm_at is None:
+ should_emit_waiting = True
+ elif (now - last_pcm_at) >= STALLED_AFTER_DATA_SECONDS:
+ should_emit_waiting = True
+
+ if should_emit_waiting and waiting_since is None:
+ waiting_since = now
+ if should_emit_waiting and now - last_waiting_emit >= WAITING_INTERVAL:
+ last_waiting_emit = now
+ _emit_waiting_scope(output_queue, waiting_since)
+
+ if reader_done.is_set():
+ break
+ continue
+
+ if not data:
+ if reader_done.is_set() and last_pcm_at is None:
+ with contextlib.suppress(queue.Full):
+ output_queue.put_nowait({
+ 'type': 'info',
+ 'text': '[pcm] stream ended before samples were received',
+ })
+ break
+
+ waiting_since = None
+ last_pcm_at = time.monotonic()
+ pcm_bytes += len(data)
+
+ if not first_pcm_logged:
+ first_pcm_logged = True
+ if pcm_ready_event is not None:
+ pcm_ready_event.set()
+ with contextlib.suppress(queue.Full):
+ output_queue.put_nowait({
+ 'type': 'info',
+ 'text': f'[pcm] first chunk: {len(data)} bytes',
+ })
+
+ events = decoder.process_block(data)
+ for event in events:
+ if event.get('type') == 'scope':
+ now = time.monotonic()
+ if now - last_scope >= SCOPE_INTERVAL:
+ last_scope = now
+ with contextlib.suppress(queue.Full):
+ output_queue.put_nowait(event)
+ else:
+ with contextlib.suppress(queue.Full):
+ output_queue.put_nowait(event)
+
+ now = time.monotonic()
+ if (now - pcm_report_at) >= 1.0:
+ kbps = (pcm_bytes * 8.0) / max(1e-6, (now - pcm_report_at)) / 1000.0
+ with contextlib.suppress(queue.Full):
+ output_queue.put_nowait({
+ 'type': 'info',
+ 'text': f'[pcm] {pcm_bytes} B in {now - pcm_report_at:.1f}s ({kbps:.1f} kbps)',
+ })
+ pcm_bytes = 0
+ pcm_report_at = now
+
+ except Exception as e: # pragma: no cover - defensive runtime guard
+ logger.debug(f'Morse decoder thread error: {e}')
+ with contextlib.suppress(queue.Full):
+ output_queue.put_nowait({
+ 'type': 'info',
+ 'text': f'[pcm] decoder thread error: {e}',
+ })
+ finally:
+ stop_event.set()
+ if reader_thread is not None:
+ reader_thread.join(timeout=0.35)
+
+ for event in decoder.flush():
+ with contextlib.suppress(queue.Full):
+ output_queue.put_nowait(event)
+
+ with contextlib.suppress(queue.Full):
+ output_queue.put_nowait({
+ 'type': 'status',
+ 'status': 'stopped',
+ 'metrics': decoder.get_metrics(),
+ })
+
+
+def _cu8_to_complex(raw: bytes) -> np.ndarray:
+ """Convert interleaved unsigned 8-bit IQ to complex64 samples."""
+ if len(raw) < 2:
+ return np.empty(0, dtype=np.complex64)
+ usable = len(raw) - (len(raw) % 2)
+ if usable <= 0:
+ return np.empty(0, dtype=np.complex64)
+ u8 = np.frombuffer(raw[:usable], dtype=np.uint8).astype(np.float32)
+ i = (u8[0::2] - 127.5) / 128.0
+ q = (u8[1::2] - 127.5) / 128.0
+ return (i + 1j * q).astype(np.complex64)
+
+
+def _iq_usb_to_pcm16(
+ iq_samples: np.ndarray,
+ iq_sample_rate: int,
+ audio_sample_rate: int,
+) -> bytes:
+ """Minimal USB demod from complex IQ to 16-bit PCM."""
+ if iq_samples.size < 16 or iq_sample_rate <= 0 or audio_sample_rate <= 0:
+ return b''
+
+ audio = np.real(iq_samples).astype(np.float64)
+ audio -= float(np.mean(audio))
+
+ # Cheap decimation first, then linear resample for exact output rate.
+ decim = max(1, int(iq_sample_rate // max(audio_sample_rate, 1)))
+ if decim > 1:
+ usable = (audio.size // decim) * decim
+ if usable < decim:
+ return b''
+ audio = audio[:usable].reshape(-1, decim).mean(axis=1)
+ fs1 = float(iq_sample_rate) / float(decim)
+ if audio.size < 8:
+ return b''
+
+ taps = int(max(1, min(31, fs1 / 12000.0)))
+ if taps > 1:
+ kernel = np.ones(taps, dtype=np.float64) / float(taps)
+ audio = np.convolve(audio, kernel, mode='same')
+
+ if abs(fs1 - float(audio_sample_rate)) > 1.0:
+ out_len = int(audio.size * float(audio_sample_rate) / fs1)
+ if out_len < 8:
+ return b''
+ x_old = np.linspace(0.0, 1.0, audio.size, endpoint=False, dtype=np.float64)
+ x_new = np.linspace(0.0, 1.0, out_len, endpoint=False, dtype=np.float64)
+ audio = np.interp(x_new, x_old, audio)
+
+ peak = float(np.max(np.abs(audio))) if audio.size else 0.0
+ if peak > 0.0:
+ audio = audio * min(8.0, 0.85 / peak)
+
+ pcm = np.clip(audio, -1.0, 1.0)
+ return (pcm * 32767.0).astype(np.int16).tobytes()
+
+
+def morse_iq_decoder_thread(
+ iq_stdout,
+ output_queue: queue.Queue,
+ stop_event: threading.Event,
+ iq_sample_rate: int,
+ sample_rate: int = 22050,
+ tone_freq: float = 700.0,
+ wpm: int = 15,
+ decoder_config: dict[str, Any] | None = None,
+ control_queue: queue.Queue | None = None,
+ pcm_ready_event: threading.Event | None = None,
+ stream_ready_event: threading.Event | None = None,
+) -> None:
+ """Decode Morse from raw IQ (cu8) by in-process USB demodulation."""
+ import logging
+ logger = logging.getLogger('intercept.morse')
+
+ CHUNK = 65536
+ SCOPE_INTERVAL = 0.10
+ WAITING_INTERVAL = 0.25
+ STALLED_AFTER_DATA_SECONDS = 1.5
+
+ cfg = dict(decoder_config or {})
+ decoder = MorseDecoder(
+ sample_rate=int(cfg.get('sample_rate', sample_rate)),
+ tone_freq=float(cfg.get('tone_freq', tone_freq)),
+ wpm=int(cfg.get('wpm', wpm)),
+ bandwidth_hz=int(cfg.get('bandwidth_hz', 200)),
+ auto_tone_track=_coerce_bool(cfg.get('auto_tone_track', True), True),
+ tone_lock=_coerce_bool(cfg.get('tone_lock', False), False),
+ threshold_mode=_normalize_threshold_mode(cfg.get('threshold_mode', 'auto')),
+ manual_threshold=float(cfg.get('manual_threshold', 0.0) or 0.0),
+ threshold_multiplier=float(cfg.get('threshold_multiplier', 2.8) or 2.8),
+ threshold_offset=float(cfg.get('threshold_offset', 0.0) or 0.0),
+ wpm_mode=_normalize_wpm_mode(cfg.get('wpm_mode', 'auto')),
+ wpm_lock=_coerce_bool(cfg.get('wpm_lock', False), False),
+ min_signal_gate=float(cfg.get('min_signal_gate', 0.0) or 0.0),
+ )
+
+ last_scope = time.monotonic()
+ last_waiting_emit = 0.0
+ waiting_since: float | None = None
+ last_pcm_at: float | None = None
+ pcm_bytes = 0
+ pcm_report_at = time.monotonic()
+ first_pcm_logged = False
+ reader_done = threading.Event()
+ reader_thread: threading.Thread | None = None
+ first_raw_logged = False
+
+ raw_queue: queue.Queue[bytes] = queue.Queue(maxsize=96)
+
+ try:
+ def _reader_loop() -> None:
+ nonlocal first_raw_logged
+ try:
+ fd = None
+ with contextlib.suppress(Exception):
+ fd = iq_stdout.fileno()
+ while not stop_event.is_set():
+ try:
+ if fd is not None:
+ ready, _, _ = select.select([fd], [], [], 0.20)
+ if not ready:
+ continue
+ data = os.read(fd, CHUNK)
+ elif hasattr(iq_stdout, 'read1'):
+ data = iq_stdout.read1(CHUNK)
+ else:
+ data = iq_stdout.read(CHUNK)
+ except Exception as e:
+ with contextlib.suppress(queue.Full):
+ output_queue.put_nowait({
+ 'type': 'info',
+ 'text': f'[iq] reader error: {e}',
+ })
+ break
+
+ if data is None:
+ continue
+ if not data:
+ break
+
+ if not first_raw_logged:
+ first_raw_logged = True
+ if stream_ready_event is not None:
+ stream_ready_event.set()
+ with contextlib.suppress(queue.Full):
+ output_queue.put_nowait({
+ 'type': 'info',
+ 'text': f'[iq] first raw chunk: {len(data)} bytes',
+ })
+
+ try:
+ raw_queue.put(data, timeout=0.2)
+ except queue.Full:
+ with contextlib.suppress(queue.Empty):
+ raw_queue.get_nowait()
+ with contextlib.suppress(queue.Full):
+ raw_queue.put_nowait(data)
+ finally:
+ reader_done.set()
+ with contextlib.suppress(queue.Full):
+ raw_queue.put_nowait(b'')
+
+ reader_thread = threading.Thread(
+ target=_reader_loop,
+ daemon=True,
+ name='morse-iq-reader',
+ )
+ reader_thread.start()
+
+ while not stop_event.is_set():
+ if not _drain_control_queue(control_queue, decoder):
+ break
+
+ try:
+ raw = raw_queue.get(timeout=0.20)
+ except queue.Empty:
+ now = time.monotonic()
+ should_emit_waiting = False
+ if last_pcm_at is None:
+ should_emit_waiting = True
+ elif (now - last_pcm_at) >= STALLED_AFTER_DATA_SECONDS:
+ should_emit_waiting = True
+
+ if should_emit_waiting and waiting_since is None:
+ waiting_since = now
+ if should_emit_waiting and now - last_waiting_emit >= WAITING_INTERVAL:
+ last_waiting_emit = now
+ _emit_waiting_scope(output_queue, waiting_since)
+
+ if reader_done.is_set():
+ break
+ continue
+
+ if not raw:
+ if reader_done.is_set() and last_pcm_at is None:
+ with contextlib.suppress(queue.Full):
+ output_queue.put_nowait({
+ 'type': 'info',
+ 'text': '[iq] stream ended before samples were received',
+ })
+ break
+
+ iq = _cu8_to_complex(raw)
+ pcm = _iq_usb_to_pcm16(
+ iq_samples=iq,
+ iq_sample_rate=int(iq_sample_rate),
+ audio_sample_rate=int(decoder.sample_rate),
+ )
+ if not pcm:
+ continue
+
+ waiting_since = None
+ last_pcm_at = time.monotonic()
+ pcm_bytes += len(pcm)
+
+ if not first_pcm_logged:
+ first_pcm_logged = True
+ if pcm_ready_event is not None:
+ pcm_ready_event.set()
+ with contextlib.suppress(queue.Full):
+ output_queue.put_nowait({
+ 'type': 'info',
+ 'text': f'[pcm] first IQ demod chunk: {len(pcm)} bytes',
+ })
+
+ events = decoder.process_block(pcm)
+ for event in events:
+ if event.get('type') == 'scope':
+ now = time.monotonic()
+ if now - last_scope >= SCOPE_INTERVAL:
+ last_scope = now
+ with contextlib.suppress(queue.Full):
+ output_queue.put_nowait(event)
+ else:
+ with contextlib.suppress(queue.Full):
+ output_queue.put_nowait(event)
+
+ now = time.monotonic()
+ if (now - pcm_report_at) >= 1.0:
+ kbps = (pcm_bytes * 8.0) / max(1e-6, (now - pcm_report_at)) / 1000.0
+ with contextlib.suppress(queue.Full):
+ output_queue.put_nowait({
+ 'type': 'info',
+ 'text': f'[pcm] {pcm_bytes} B in {now - pcm_report_at:.1f}s ({kbps:.1f} kbps)',
+ })
+ pcm_bytes = 0
+ pcm_report_at = now
+
+ except Exception as e: # pragma: no cover - runtime safety
+ logger.debug(f'Morse IQ decoder thread error: {e}')
+ with contextlib.suppress(queue.Full):
+ output_queue.put_nowait({
+ 'type': 'info',
+ 'text': f'[iq] decoder thread error: {e}',
+ })
+ finally:
+ stop_event.set()
+ if reader_thread is not None:
+ reader_thread.join(timeout=0.35)
+
+ for event in decoder.flush():
+ with contextlib.suppress(queue.Full):
+ output_queue.put_nowait(event)
+
+ with contextlib.suppress(queue.Full):
+ output_queue.put_nowait({
+ 'type': 'status',
+ 'status': 'stopped',
+ 'metrics': decoder.get_metrics(),
+ })
diff --git a/utils/sdr/rtlsdr.py b/utils/sdr/rtlsdr.py
index 1e68c35..3cede61 100644
--- a/utils/sdr/rtlsdr.py
+++ b/utils/sdr/rtlsdr.py
@@ -14,16 +14,16 @@ from typing import Optional
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
from utils.dependencies import get_tool_path
-logger = logging.getLogger('intercept.sdr.rtlsdr')
-
-
-def _rtl_fm_demod_mode(modulation: str) -> str:
- """Map app/UI modulation names to rtl_fm demod tokens."""
- mod = str(modulation or '').lower().strip()
- return 'wbfm' if mod == 'wfm' else mod
-
-
-def _get_dump1090_bias_t_flag(dump1090_path: str) -> Optional[str]:
+logger = logging.getLogger('intercept.sdr.rtlsdr')
+
+
+def _rtl_fm_demod_mode(modulation: str) -> str:
+ """Map app/UI modulation names to rtl_fm demod tokens."""
+ mod = str(modulation or '').lower().strip()
+ return 'wbfm' if mod == 'wfm' else mod
+
+
+def _get_dump1090_bias_t_flag(dump1090_path: str) -> Optional[str]:
"""Detect the correct bias-t flag for the installed dump1090 variant.
Different dump1090 forks use different flags:
@@ -86,22 +86,27 @@ class RTLSDRCommandBuilder(CommandBuilder):
ppm: Optional[int] = None,
modulation: str = "fm",
squelch: Optional[int] = None,
- bias_t: bool = False
+ bias_t: bool = False,
+ direct_sampling: Optional[int] = None,
) -> list[str]:
"""
Build rtl_fm command for FM demodulation.
Used for pager decoding. Supports local devices and rtl_tcp connections.
+
+ Args:
+ direct_sampling: Enable direct sampling mode (0=off, 1=I-branch,
+ 2=Q-branch). Use 2 for HF reception below 24 MHz.
"""
- rtl_fm_path = get_tool_path('rtl_fm') or 'rtl_fm'
- demod_mode = _rtl_fm_demod_mode(modulation)
- cmd = [
- rtl_fm_path,
- '-d', self._get_device_arg(device),
- '-f', f'{frequency_mhz}M',
- '-M', demod_mode,
- '-s', str(sample_rate),
- ]
+ rtl_fm_path = get_tool_path('rtl_fm') or 'rtl_fm'
+ demod_mode = _rtl_fm_demod_mode(modulation)
+ cmd = [
+ rtl_fm_path,
+ '-d', self._get_device_arg(device),
+ '-f', f'{frequency_mhz}M',
+ '-M', demod_mode,
+ '-s', str(sample_rate),
+ ]
if gain is not None and gain > 0:
cmd.extend(['-g', str(gain)])
@@ -112,6 +117,14 @@ class RTLSDRCommandBuilder(CommandBuilder):
if squelch is not None and squelch > 0:
cmd.extend(['-l', str(squelch)])
+ if direct_sampling is not None:
+ # Older rtl_fm builds (common in Docker/distro packages) don't
+ # support -D; they use -E direct / -E direct2 instead.
+ if direct_sampling == 1:
+ cmd.extend(['-E', 'direct'])
+ elif direct_sampling == 2:
+ cmd.extend(['-E', 'direct2'])
+
if bias_t:
cmd.extend(['-T'])