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 = + '
CPU
' + + '
' + + 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 = + '
Memory
' + + '
' + + 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 = + '
Disk
' + + '
' + + 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 = '
SDR Devices
'; + 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 = '
Processes
'; + 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 = '
System Info
'; + 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 = '
SDR Devices
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

-
- - -
-
- -
- - - - - - - - -
-
-
- -
-

Settings

-
- - -
-
- - -
-
- -
-

CW Settings

-
- - -
-
- - -
-
- - -
-

- Morse Reference (click to toggle) -

- -
- - -
-
- - 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). -

-
- - - -
+ +
+
+

CW/Morse Decoder

+

+ Decode CW (continuous wave) Morse with USB demod + Goertzel tone detection. + Start with 700 Hz tone and 200 Hz bandwidth. +

+
+ +
+

Frequency

+
+ + + Enter CW center frequency in MHz (e.g., 7.030 for 40m). +
+
+ +
+ + + + + + + + +
+
+
+ +
+

Device

+
+ + +
+
+ + +
+
+ +
+

CW Detector

+
+ + +
+
+ + +
+
+ + +
+
+ +
+

Threshold + WPM

+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ +
+
+ +
+

Output

+
+ + +
+
+ +
+
+ +
+

Decode WAV File

+
+ + +
+ Runs the same CW decoder pipeline against uploaded WAV audio. +
+ +
+

+ Morse Reference (click to toggle) +

+ +
+ +
+
+ + Standby + 0 chars +
+
+ +
+

+ CW on HF (1-30 MHz) requires an HF-capable SDR path (direct sampling or upconverter) + and an appropriate antenna. +

+
+ + + +
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… +
+ +
+ + +
+

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 #} +
+ + +
+ {{ mode_item('system', 'Health', '') }} +
+
+ {# Dynamic dashboard button (shown when in satellite mode) #}