mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Merge upstream/main and resolve weather-satellite.js conflict
Keep allPasses assignment for satellite filtering support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
26
README.md
26
README.md
@@ -55,6 +55,32 @@ Support the developer of this open-source project
|
||||
|
||||
---
|
||||
|
||||
## 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:**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}'
|
||||
|
||||
874
routes/morse.py
874
routes/morse.py
File diff suppressed because it is too large
Load Diff
323
routes/system.py
Normal file
323
routes/system.py
Normal file
@@ -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)})
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,22 +1,87 @@
|
||||
/* 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-mode-help,
|
||||
.morse-help-text {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.morse-scope-container canvas {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
.morse-help-text {
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Decoded text panel */
|
||||
.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);
|
||||
@@ -39,7 +104,6 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Individual decoded character with fade-in */
|
||||
.morse-char {
|
||||
display: inline;
|
||||
animation: morseFadeIn 0.3s ease-out;
|
||||
@@ -57,43 +121,55 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
.morse-word-space {
|
||||
display: inline;
|
||||
width: 0.5em;
|
||||
}
|
||||
|
||||
/* Reference grid */
|
||||
.morse-ref-grid {
|
||||
transition: max-height 0.3s ease, opacity 0.3s ease;
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
overflow: hidden;
|
||||
.morse-raw-panel {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid #1a1a2e;
|
||||
border-radius: 4px;
|
||||
background: #080812;
|
||||
}
|
||||
|
||||
.morse-ref-grid.collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
.morse-raw-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #667;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Toolbar: export/copy/clear */
|
||||
.morse-toolbar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.morse-toolbar .btn {
|
||||
.morse-raw-text {
|
||||
min-height: 30px;
|
||||
max-height: 90px;
|
||||
overflow-y: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
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);
|
||||
}
|
||||
|
||||
/* Status bar at bottom */
|
||||
.morse-status-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -103,6 +179,8 @@
|
||||
padding: 6px 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: 8px;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.morse-status-bar .status-item {
|
||||
@@ -111,8 +189,13 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Word space styling */
|
||||
.morse-word-space {
|
||||
display: inline;
|
||||
width: 0.5em;
|
||||
@media (max-width: 768px) {
|
||||
.morse-metrics-panel {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.morse-file-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
215
static/css/modes/system.css
Normal file
215
static/css/modes/system.css
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
300
static/js/modes/system.js
Normal file
300
static/js/modes/system.js
Normal file
@@ -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 '<span class="sys-metric-na">N/A</span>';
|
||||
const cls = barClass(pct);
|
||||
const rounded = Math.round(pct);
|
||||
return '<div class="sys-metric-bar-wrap">' +
|
||||
(label ? '<span class="sys-metric-bar-label">' + label + '</span>' : '') +
|
||||
'<div class="sys-metric-bar"><div class="sys-metric-bar-fill ' + cls + '" style="width:' + rounded + '%"></div></div>' +
|
||||
'<span class="sys-metric-bar-value">' + rounded + '%</span>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rendering
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function renderCpuCard(m) {
|
||||
const el = document.getElementById('sysCardCpu');
|
||||
if (!el) return;
|
||||
const cpu = m.cpu;
|
||||
if (!cpu) { el.innerHTML = '<div class="sys-card-body"><span class="sys-metric-na">psutil not available</span></div>'; return; }
|
||||
el.innerHTML =
|
||||
'<div class="sys-card-header">CPU</div>' +
|
||||
'<div class="sys-card-body">' +
|
||||
barHtml(cpu.percent, '') +
|
||||
'<div class="sys-card-detail">Load: ' + cpu.load_1 + ' / ' + cpu.load_5 + ' / ' + cpu.load_15 + '</div>' +
|
||||
'<div class="sys-card-detail">Cores: ' + cpu.count + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderMemoryCard(m) {
|
||||
const el = document.getElementById('sysCardMemory');
|
||||
if (!el) return;
|
||||
const mem = m.memory;
|
||||
if (!mem) { el.innerHTML = '<div class="sys-card-body"><span class="sys-metric-na">N/A</span></div>'; return; }
|
||||
const swap = m.swap || {};
|
||||
el.innerHTML =
|
||||
'<div class="sys-card-header">Memory</div>' +
|
||||
'<div class="sys-card-body">' +
|
||||
barHtml(mem.percent, '') +
|
||||
'<div class="sys-card-detail">' + formatBytes(mem.used) + ' / ' + formatBytes(mem.total) + '</div>' +
|
||||
'<div class="sys-card-detail">Swap: ' + formatBytes(swap.used) + ' / ' + formatBytes(swap.total) + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderDiskCard(m) {
|
||||
const el = document.getElementById('sysCardDisk');
|
||||
if (!el) return;
|
||||
const disk = m.disk;
|
||||
if (!disk) { el.innerHTML = '<div class="sys-card-body"><span class="sys-metric-na">N/A</span></div>'; return; }
|
||||
el.innerHTML =
|
||||
'<div class="sys-card-header">Disk</div>' +
|
||||
'<div class="sys-card-body">' +
|
||||
barHtml(disk.percent, '') +
|
||||
'<div class="sys-card-detail">' + formatBytes(disk.used) + ' / ' + formatBytes(disk.total) + '</div>' +
|
||||
'<div class="sys-card-detail">Path: ' + (disk.path || '/') + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
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 = '<div class="sys-card-header">SDR Devices <button class="sys-rescan-btn" onclick="SystemHealth.refreshSdr()">Rescan</button></div>';
|
||||
html += '<div class="sys-card-body">';
|
||||
if (!devices || !devices.length) {
|
||||
html += '<span class="sys-metric-na">No devices found</span>';
|
||||
} else {
|
||||
devices.forEach(function (d) {
|
||||
html += '<div class="sys-sdr-device">' +
|
||||
'<span class="sys-process-dot running"></span> ' +
|
||||
'<strong>' + d.type + ' #' + d.index + '</strong>' +
|
||||
'<div class="sys-card-detail">' + (d.name || 'Unknown') + '</div>' +
|
||||
(d.serial ? '<div class="sys-card-detail">S/N: ' + d.serial + '</div>' : '') +
|
||||
'</div>';
|
||||
});
|
||||
}
|
||||
html += '</div>';
|
||||
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 = '<div class="sys-card-header">Processes</div><div class="sys-card-body">';
|
||||
if (!keys.length) {
|
||||
html += '<span class="sys-metric-na">No data</span>';
|
||||
} else {
|
||||
keys.forEach(function (k) {
|
||||
const running = procs[k];
|
||||
const dotCls = running ? 'running' : 'stopped';
|
||||
const label = k.charAt(0).toUpperCase() + k.slice(1);
|
||||
html += '<div class="sys-process-item">' +
|
||||
'<span class="sys-process-dot ' + dotCls + '"></span> ' +
|
||||
'<span class="sys-process-name">' + label + '</span>' +
|
||||
'</div>';
|
||||
});
|
||||
}
|
||||
html += '</div>';
|
||||
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 = '<div class="sys-card-header">System Info</div><div class="sys-card-body">';
|
||||
html += '<div class="sys-card-detail">Host: ' + (sys.hostname || '--') + '</div>';
|
||||
html += '<div class="sys-card-detail">OS: ' + (sys.platform || '--') + '</div>';
|
||||
html += '<div class="sys-card-detail">Python: ' + (sys.python || '--') + '</div>';
|
||||
html += '<div class="sys-card-detail">App: v' + (sys.version || '--') + '</div>';
|
||||
html += '<div class="sys-card-detail">Uptime: ' + (sys.uptime_human || '--') + '</div>';
|
||||
if (temp) {
|
||||
html += '<div class="sys-card-detail">Temp: ' + Math.round(temp.current) + '°C';
|
||||
if (temp.high) html += ' / ' + Math.round(temp.high) + '°C max';
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
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 ? '<span style="color: var(--accent-green, #00ff88);">' + running.length + ' running</span>' : '') +
|
||||
(running.length && stopped.length ? ' · ' : '') +
|
||||
(stopped.length ? '<span style="color: var(--text-dim);">' + stopped.length + ' stopped</span>' : '');
|
||||
}
|
||||
|
||||
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 = '<div class="sys-card-header">SDR Devices</div><div class="sys-card-body">Scanning…</div>';
|
||||
|
||||
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 = '<span style="color: var(--text-dim);">No SDR devices found</span>';
|
||||
} else {
|
||||
var html = '';
|
||||
devices.forEach(function (d) {
|
||||
html += '<div style="margin-bottom: 4px;"><span class="sys-process-dot running"></span> ' +
|
||||
d.type + ' #' + d.index + ' — ' + (d.name || 'Unknown') + '</div>';
|
||||
});
|
||||
sidebarEl.innerHTML = html;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
if (sidebarEl) sidebarEl.innerHTML = '<span style="color: var(--accent-red, #ff3366);">Detection failed</span>';
|
||||
renderSdrCard([]);
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
function init() {
|
||||
connect();
|
||||
refreshSdr();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
disconnect();
|
||||
}
|
||||
|
||||
return {
|
||||
init: init,
|
||||
destroy: destroy,
|
||||
refreshSdr: refreshSdr,
|
||||
};
|
||||
})();
|
||||
@@ -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) {
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/function-strip.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/ux-platform.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/components.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/waterfall.css') }}?v={{ version }}&r=wfdeck19">
|
||||
<script>
|
||||
window.INTERCEPT_MODE_STYLE_MAP = {
|
||||
@@ -81,7 +82,8 @@
|
||||
bt_locate: "{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate4",
|
||||
spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}",
|
||||
wefax: "{{ url_for('static', filename='css/modes/wefax.css') }}",
|
||||
morse: "{{ url_for('static', filename='css/modes/morse.css') }}"
|
||||
morse: "{{ url_for('static', filename='css/modes/morse.css') }}",
|
||||
system: "{{ url_for('static', filename='css/modes/system.css') }}"
|
||||
};
|
||||
window.INTERCEPT_MODE_STYLE_LOADED = {};
|
||||
window.INTERCEPT_MODE_STYLE_PROMISES = {};
|
||||
@@ -704,6 +706,7 @@
|
||||
|
||||
{% include 'partials/modes/bt_locate.html' %}
|
||||
{% include 'partials/modes/waterfall.html' %}
|
||||
{% include 'partials/modes/system.html' %}
|
||||
|
||||
|
||||
|
||||
@@ -3085,6 +3088,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="morseDiagLog" style="display: none; margin-bottom: 8px; max-height: 60px; overflow-y: auto;
|
||||
background: #080812; border: 1px solid #1a1a2e; border-radius: 4px; padding: 4px 8px;
|
||||
font-family: var(--font-mono); font-size: 10px; color: #556677; line-height: 1.6;">
|
||||
</div>
|
||||
|
||||
<!-- Morse Decoded Output -->
|
||||
<div id="morseOutputPanel" style="display: none; margin-bottom: 12px;">
|
||||
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px;">
|
||||
@@ -3098,14 +3106,57 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="morseDecodedText" class="morse-decoded-panel"></div>
|
||||
<div id="morseRawPanel" class="morse-raw-panel" style="display: none;">
|
||||
<div class="morse-raw-label">Raw Elements</div>
|
||||
<div id="morseRawText" class="morse-raw-text"></div>
|
||||
</div>
|
||||
<div id="morseMetricsPanel" class="morse-metrics-panel">
|
||||
<span id="morseMetricState">STATE idle</span>
|
||||
<span id="morseMetricTone">TONE -- Hz</span>
|
||||
<span id="morseMetricLevel">LEVEL --</span>
|
||||
<span id="morseMetricThreshold">THRESH --</span>
|
||||
<span id="morseMetricNoise">NOISE --</span>
|
||||
<span id="morseMetricStopMs">STOP -- ms</span>
|
||||
</div>
|
||||
<div class="morse-status-bar">
|
||||
<span class="status-item" id="morseStatusBarWpm">15 WPM</span>
|
||||
<span class="status-item" id="morseStatusBarState">IDLE</span>
|
||||
<span class="status-item" id="morseStatusBarWpm">-- WPM</span>
|
||||
<span class="status-item" id="morseStatusBarTone">700 Hz</span>
|
||||
<span class="status-item" id="morseStatusBarChars">0 chars decoded</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Health Visuals -->
|
||||
<div id="systemVisuals" class="sys-visuals-container" style="display: none;">
|
||||
<div class="sys-dashboard">
|
||||
<div class="sys-card" id="sysCardCpu">
|
||||
<div class="sys-card-header">CPU</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||||
</div>
|
||||
<div class="sys-card" id="sysCardMemory">
|
||||
<div class="sys-card-header">Memory</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||||
</div>
|
||||
<div class="sys-card" id="sysCardDisk">
|
||||
<div class="sys-card-header">Disk</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||||
</div>
|
||||
<div class="sys-card" id="sysCardSdr">
|
||||
<div class="sys-card-header">SDR Devices</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Scanning…</span></div>
|
||||
</div>
|
||||
<div class="sys-card" id="sysCardProcesses">
|
||||
<div class="sys-card-header">Processes</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||||
</div>
|
||||
<div class="sys-card" id="sysCardInfo">
|
||||
<div class="sys-card-header">System Info</div>
|
||||
<div class="sys-card-body"><span class="sys-metric-na">Connecting…</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="output-content signal-feed" id="output">
|
||||
<div class="placeholder signal-empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
@@ -3175,8 +3226,9 @@
|
||||
<script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/wefax.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/morse.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/morse.js') }}?v={{ version }}&r=morse_iq12"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/system.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=voicefix2"></script>
|
||||
<script src="{{ url_for('static', filename='js/core/keyboard-shortcuts.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
|
||||
@@ -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
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<div id="morseMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>CW/Morse Decoder</h3>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
||||
Decode CW (continuous wave) Morse code from amateur radio HF bands using USB demodulation
|
||||
and Goertzel tone detection.
|
||||
<p class="info-text morse-mode-help">
|
||||
Decode CW (continuous wave) Morse with USB demod + Goertzel tone detection.
|
||||
Start with 700 Hz tone and 200 Hz bandwidth.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -12,11 +12,12 @@
|
||||
<h3>Frequency</h3>
|
||||
<div class="form-group">
|
||||
<label>Frequency (MHz)</label>
|
||||
<input type="number" id="morseFrequency" value="14.060" step="0.001" min="1" max="30">
|
||||
<input type="number" id="morseFrequency" value="14.060" step="0.001" min="0.5" max="30" placeholder="e.g., 14.060">
|
||||
<span class="help-text morse-help-text">Enter CW center frequency in MHz (e.g., 7.030 for 40m).</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Band Presets</label>
|
||||
<div class="morse-presets" style="display: flex; flex-wrap: wrap; gap: 4px;">
|
||||
<div class="morse-presets">
|
||||
<button class="preset-btn" onclick="MorseMode.setFreq(3.560)">80m</button>
|
||||
<button class="preset-btn" onclick="MorseMode.setFreq(7.030)">40m</button>
|
||||
<button class="preset-btn" onclick="MorseMode.setFreq(10.116)">30m</button>
|
||||
@@ -30,37 +31,106 @@
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Settings</h3>
|
||||
<h3>Device</h3>
|
||||
<div class="form-group">
|
||||
<label>Gain (dB)</label>
|
||||
<input type="number" id="morseGain" value="40" step="1" min="0" max="50">
|
||||
<input type="number" id="morseGain" value="40" step="1" min="0" max="60">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>PPM Correction</label>
|
||||
<input type="number" id="morsePPM" value="0" step="1" min="-100" max="100">
|
||||
<input type="number" id="morsePPM" value="0" step="1" min="-200" max="200">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>CW Settings</h3>
|
||||
<h3>CW Detector</h3>
|
||||
<div class="form-group">
|
||||
<label>Tone Frequency: <span id="morseToneFreqLabel">700</span> Hz</label>
|
||||
<input type="range" id="morseToneFreq" value="700" min="300" max="1200" step="10"
|
||||
oninput="document.getElementById('morseToneFreqLabel').textContent = this.value">
|
||||
oninput="MorseMode.updateToneLabel(this.value)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Speed: <span id="morseWpmLabel">15</span> WPM</label>
|
||||
<input type="range" id="morseWpm" value="15" min="5" max="50" step="1"
|
||||
oninput="document.getElementById('morseWpmLabel').textContent = this.value">
|
||||
<label>Bandwidth</label>
|
||||
<select id="morseBandwidth">
|
||||
<option value="50">50 Hz</option>
|
||||
<option value="100">100 Hz</option>
|
||||
<option value="200" selected>200 Hz</option>
|
||||
<option value="400">400 Hz</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<label><input type="checkbox" id="morseAutoToneTrack" checked> Auto Tone Track</label>
|
||||
<label><input type="checkbox" id="morseToneLock"> Hold Tone Lock</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Morse Reference -->
|
||||
<div class="section">
|
||||
<h3>Threshold + WPM</h3>
|
||||
<div class="form-group">
|
||||
<label>Threshold Mode</label>
|
||||
<select id="morseThresholdMode" onchange="MorseMode.onThresholdModeChange()">
|
||||
<option value="auto" selected>Auto</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="morseThresholdAutoRow">
|
||||
<label>Threshold Multiplier</label>
|
||||
<input type="number" id="morseThresholdMultiplier" value="2.8" min="1.1" max="8" step="0.1">
|
||||
</div>
|
||||
<div class="form-group" id="morseThresholdOffsetRow">
|
||||
<label>Threshold Offset</label>
|
||||
<input type="number" id="morseThresholdOffset" value="0" min="0" step="0.1">
|
||||
</div>
|
||||
<div class="form-group" id="morseManualThresholdRow" style="display: none;">
|
||||
<label>Manual Threshold</label>
|
||||
<input type="number" id="morseManualThreshold" value="0" min="0" step="0.1">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Minimum Signal Gate</label>
|
||||
<input type="number" id="morseSignalGate" value="0.05" min="0" max="1" step="0.01">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>WPM Mode</label>
|
||||
<select id="morseWpmMode" onchange="MorseMode.onWpmModeChange()">
|
||||
<option value="auto" selected>Auto</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="morseWpmManualRow" style="display: none;">
|
||||
<label>Manual Speed: <span id="morseWpmLabel">15</span> WPM</label>
|
||||
<input type="range" id="morseWpm" value="15" min="5" max="50" step="1"
|
||||
oninput="MorseMode.updateWpmLabel(this.value)">
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<label><input type="checkbox" id="morseWpmLock"> Lock WPM Estimator</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Output</h3>
|
||||
<div class="form-group checkbox-group">
|
||||
<label><input type="checkbox" id="morseShowRaw" checked> Show Raw Morse</label>
|
||||
<label><input type="checkbox" id="morseShowDiag"> Show Decoder Logs</label>
|
||||
</div>
|
||||
<div class="morse-actions-row">
|
||||
<button class="btn btn-sm btn-ghost" id="morseCalibrateBtn" onclick="MorseMode.calibrate()">Reset / Calibrate</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Decode WAV File</h3>
|
||||
<div class="morse-file-row">
|
||||
<input type="file" id="morseFileInput" accept="audio/wav,.wav">
|
||||
<button class="btn btn-sm btn-ghost" id="morseDecodeFileBtn" onclick="MorseMode.decodeFile()">Decode File</button>
|
||||
</div>
|
||||
<span class="help-text morse-help-text">Runs the same CW decoder pipeline against uploaded WAV audio.</span>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 style="cursor: pointer;" onclick="this.parentElement.querySelector('.morse-ref-grid').classList.toggle('collapsed')">
|
||||
Morse Reference <span style="font-size: 10px; color: var(--text-dim);">(click to toggle)</span>
|
||||
Morse Reference <span class="morse-ref-toggle">(click to toggle)</span>
|
||||
</h3>
|
||||
<div class="morse-ref-grid collapsed" style="font-family: var(--font-mono); font-size: 10px; line-height: 1.8; columns: 2; column-gap: 12px; color: var(--text-dim);">
|
||||
<div class="morse-ref-grid collapsed">
|
||||
<div>A .-</div><div>B -...</div><div>C -.-.</div><div>D -..</div>
|
||||
<div>E .</div><div>F ..-.</div><div>G --.</div><div>H ....</div>
|
||||
<div>I ..</div><div>J .---</div><div>K -.-</div><div>L .-..</div>
|
||||
@@ -68,28 +138,26 @@
|
||||
<div>Q --.-</div><div>R .-.</div><div>S ...</div><div>T -</div>
|
||||
<div>U ..-</div><div>V ...-</div><div>W .--</div><div>X -..-</div>
|
||||
<div>Y -.--</div><div>Z --..</div>
|
||||
<div style="margin-top: 4px; border-top: 1px solid var(--border-color); padding-top: 4px;">0 -----</div>
|
||||
<div style="margin-top: 4px; border-top: 1px solid var(--border-color); padding-top: 4px;">1 .----</div>
|
||||
<div class="morse-ref-divider">0 -----</div>
|
||||
<div class="morse-ref-divider">1 .----</div>
|
||||
<div>2 ..---</div><div>3 ...--</div><div>4 ....-</div>
|
||||
<div>5 .....</div><div>6 -....</div><div>7 --...</div>
|
||||
<div>8 ---..</div><div>9 ----.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="section">
|
||||
<div class="morse-status" style="display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim);">
|
||||
<span id="morseStatusIndicator" class="status-dot" style="width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim);"></span>
|
||||
<div class="morse-status">
|
||||
<span id="morseStatusIndicator" class="status-dot"></span>
|
||||
<span id="morseStatusText">Standby</span>
|
||||
<span style="margin-left: auto;" id="morseCharCount">0 chars</span>
|
||||
<span id="morseCharCount">0 chars</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HF Antenna Note -->
|
||||
<div class="section">
|
||||
<p class="info-text" style="font-size: 11px; color: #ffaa00; line-height: 1.5;">
|
||||
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).
|
||||
<p class="info-text morse-hf-note">
|
||||
CW on HF (1-30 MHz) requires an HF-capable SDR path (direct sampling or upconverter)
|
||||
and an appropriate antenna.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
52
templates/partials/modes/system.html
Normal file
52
templates/partials/modes/system.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!-- SYSTEM HEALTH MODE -->
|
||||
<div id="systemMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>System Health</h3>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
||||
Real-time monitoring of host resources, active decoders, and SDR hardware.
|
||||
Auto-connects when entering this mode.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Status Grid -->
|
||||
<div class="section">
|
||||
<h3>Quick Status</h3>
|
||||
<div class="sys-quick-grid">
|
||||
<div class="sys-quick-item">
|
||||
<span class="sys-quick-label">CPU</span>
|
||||
<span class="sys-quick-value" id="sysQuickCpu">--%</span>
|
||||
</div>
|
||||
<div class="sys-quick-item">
|
||||
<span class="sys-quick-label">Temp</span>
|
||||
<span class="sys-quick-value" id="sysQuickTemp">--°C</span>
|
||||
</div>
|
||||
<div class="sys-quick-item">
|
||||
<span class="sys-quick-label">RAM</span>
|
||||
<span class="sys-quick-value" id="sysQuickRam">--%</span>
|
||||
</div>
|
||||
<div class="sys-quick-item">
|
||||
<span class="sys-quick-label">Disk</span>
|
||||
<span class="sys-quick-value" id="sysQuickDisk">--%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SDR Devices -->
|
||||
<div class="section">
|
||||
<h3>SDR Devices</h3>
|
||||
<div id="sysSdrList" style="font-size: 11px; color: var(--text-dim);">
|
||||
Scanning…
|
||||
</div>
|
||||
<button class="run-btn" style="width: 100%; margin-top: 8px;" onclick="SystemHealth.refreshSdr()">
|
||||
Rescan SDR
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Active Processes -->
|
||||
<div class="section">
|
||||
<h3>Active Processes</h3>
|
||||
<div id="sysProcessList" style="font-size: 11px; color: var(--text-dim);">
|
||||
Waiting for data…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,6 +140,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# System Group #}
|
||||
<div class="mode-nav-dropdown" data-group="system">
|
||||
<button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('system')"{% endif %}>
|
||||
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></span>
|
||||
<span class="nav-label">System</span>
|
||||
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||
</button>
|
||||
|
||||
<div class="mode-nav-dropdown-menu">
|
||||
{{ mode_item('system', 'Health', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Dynamic dashboard button (shown when in satellite mode) #}
|
||||
<div class="mode-nav-actions">
|
||||
<a href="/satellite/dashboard" target="_blank" class="nav-action-btn" id="satelliteDashboardBtn" style="display: none;">
|
||||
@@ -230,6 +243,8 @@
|
||||
{{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||
{# New modes #}
|
||||
{{ mobile_item('waterfall', 'Waterfall', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h4l3-8 3 16 3-8h4"/></svg>') }}
|
||||
{# System #}
|
||||
{{ mobile_item('system', 'System', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>') }}
|
||||
</nav>
|
||||
|
||||
{# JavaScript stub for pages that don't have switchMode defined #}
|
||||
|
||||
@@ -515,18 +515,16 @@ class TestDSCDecoder:
|
||||
assert result == '002320001'
|
||||
|
||||
def test_decode_mmsi_short_symbols(self, decoder):
|
||||
"""Test MMSI decoding handles short symbol list."""
|
||||
"""Test MMSI decoding returns None for short symbol list."""
|
||||
result = decoder._decode_mmsi([1, 2, 3])
|
||||
assert result == '000000000'
|
||||
assert result is None
|
||||
|
||||
def test_decode_mmsi_invalid_symbols(self, decoder):
|
||||
"""Test MMSI decoding handles invalid symbol values."""
|
||||
# Symbols > 99 should be treated as 0
|
||||
"""Test MMSI decoding returns None for out-of-range symbols."""
|
||||
# Symbols > 99 should cause decode to fail
|
||||
symbols = [100, 32, 12, 34, 56]
|
||||
result = decoder._decode_mmsi(symbols)
|
||||
# First symbol (100) becomes 00, padded result "0032123456",
|
||||
# trim leading pad digit -> "032123456"
|
||||
assert result == '032123456'
|
||||
assert result is None
|
||||
|
||||
def test_decode_position_northeast(self, decoder):
|
||||
"""Test position decoding for NE quadrant."""
|
||||
@@ -577,8 +575,9 @@ class TestDSCDecoder:
|
||||
def test_bits_to_symbol(self, decoder):
|
||||
"""Test bit to symbol conversion."""
|
||||
# Symbol value is first 7 bits (LSB first)
|
||||
# Value 100 = 0b1100100 -> bits [0,0,1,0,0,1,1, x,x,x]
|
||||
bits = [0, 0, 1, 0, 0, 1, 1, 0, 0, 0]
|
||||
# Value 100 = 0b1100100 -> bits [0,0,1,0,0,1,1] -> 3 ones
|
||||
# Check bits must make total even -> need 1 more one -> [1,0,0]
|
||||
bits = [0, 0, 1, 0, 0, 1, 1, 1, 0, 0]
|
||||
result = decoder._bits_to_symbol(bits)
|
||||
assert result == 100
|
||||
|
||||
@@ -588,14 +587,14 @@ class TestDSCDecoder:
|
||||
assert result == -1
|
||||
|
||||
def test_detect_dot_pattern(self, decoder):
|
||||
"""Test dot pattern detection."""
|
||||
# Dot pattern is alternating 1010101...
|
||||
decoder.bit_buffer = [1, 0] * 25 # 50 alternating bits
|
||||
"""Test dot pattern detection with 200+ alternating bits."""
|
||||
# Dot pattern requires at least 200 bits / 100 alternations
|
||||
decoder.bit_buffer = [1, 0] * 110 # 220 alternating bits
|
||||
assert decoder._detect_dot_pattern() is True
|
||||
|
||||
def test_detect_dot_pattern_insufficient(self, decoder):
|
||||
"""Test dot pattern not detected with insufficient alternations."""
|
||||
decoder.bit_buffer = [1, 0] * 5 # Only 10 bits
|
||||
decoder.bit_buffer = [1, 0] * 40 # Only 80 bits, below 200 threshold
|
||||
assert decoder._detect_dot_pattern() is False
|
||||
|
||||
def test_detect_dot_pattern_not_alternating(self, decoder):
|
||||
@@ -603,6 +602,84 @@ class TestDSCDecoder:
|
||||
decoder.bit_buffer = [1, 1, 1, 1, 0, 0, 0, 0] * 5
|
||||
assert decoder._detect_dot_pattern() is False
|
||||
|
||||
def test_bounded_phasing_strip(self, decoder):
|
||||
"""Test that >7 phasing symbols causes decode to return None."""
|
||||
# Build message bits: 10 phasing symbols (120) + format + data
|
||||
# Each symbol is 10 bits. Phasing symbol 120 = 0b1111000 LSB first
|
||||
# 120 in 7 bits LSB-first: 0,0,0,1,1,1,1 + 3 check bits
|
||||
# 120 = 0b1111000 -> LSB first: 0,0,0,1,1,1,1 -> ones=4 (even) -> check [0,0,0]
|
||||
phasing_bits = [0, 0, 0, 1, 1, 1, 1, 0, 0, 0] # symbol 120
|
||||
# 10 phasing symbols (>7 max)
|
||||
decoder.message_bits = phasing_bits * 10
|
||||
# Add some non-phasing symbols after (enough for a message)
|
||||
# Symbol 112 (INDIVIDUAL) = 0b1110000 LSB-first: 0,0,0,0,1,1,1 -> ones=3 (odd) -> need odd check
|
||||
# For simplicity, just add enough bits for the decoder to attempt
|
||||
for _ in range(20):
|
||||
decoder.message_bits.extend([0, 0, 0, 0, 1, 1, 1, 1, 0, 0])
|
||||
result = decoder._try_decode_message()
|
||||
assert result is None
|
||||
|
||||
def test_eos_minimum_length(self, decoder):
|
||||
"""Test that EOS found too early in the symbol stream is skipped."""
|
||||
# Build a message where EOS appears at position 5 (< MIN_SYMBOLS_FOR_FORMAT=12)
|
||||
# This should not be accepted as a valid message end
|
||||
# Symbol 127 (EOS) = 0b1111111 LSB-first: 1,1,1,1,1,1,1 -> ones=7 (odd) -> check needs 1 one
|
||||
# Use a simple approach: create symbols directly via _try_decode_message
|
||||
# Create 5 normal symbols + EOS at position 5 — should be skipped
|
||||
# Followed by more symbols and a real EOS at position 15
|
||||
from utils.dsc.decoder import DSCDecoder
|
||||
d = DSCDecoder()
|
||||
|
||||
# Build symbols manually: we need _try_decode_message to find EOS too early
|
||||
# Symbol 112 = format code. We'll build 10 bits per symbol.
|
||||
# Since check bit validation is now active, we need valid check bits.
|
||||
# Symbol value 10 = 0b0001010 LSB-first: 0,1,0,1,0,0,0, ones=2 (even) -> check [0,0,0]
|
||||
sym_10 = [0, 1, 0, 1, 0, 0, 0, 0, 0, 0]
|
||||
# Symbol 127 (EOS) = 0b1111111, ones=7 (odd) -> check needs odd total -> [1,0,0]
|
||||
sym_eos = [1, 1, 1, 1, 1, 1, 1, 1, 0, 0]
|
||||
|
||||
# 5 normal symbols + early EOS (should be skipped) + 8 more normal + real EOS
|
||||
d.message_bits = sym_10 * 5 + sym_eos + sym_10 * 8 + sym_eos
|
||||
result = d._try_decode_message()
|
||||
# The early EOS at index 5 should be skipped; the one at index 14
|
||||
# is past MIN_SYMBOLS_FOR_FORMAT so it can be accepted.
|
||||
# But the message content is garbage, so _decode_symbols will likely
|
||||
# return None for other reasons. The key test: it doesn't return a
|
||||
# message truncated at position 5.
|
||||
# Just verify no crash and either None or a valid longer message
|
||||
# (not truncated at the early EOS)
|
||||
assert result is None or len(result.get('raw', '')) > 18
|
||||
|
||||
def test_bits_to_symbol_check_bit_validation(self, decoder):
|
||||
"""Test that _bits_to_symbol rejects symbols with invalid check bits."""
|
||||
# Symbol 100 = 0b1100100 LSB-first: 0,0,1,0,0,1,1
|
||||
# ones in data = 3, need total even -> check bits need 1 one
|
||||
# Valid: [0,0,1,0,0,1,1, 1,0,0] -> total ones = 4 (even) -> valid
|
||||
valid_bits = [0, 0, 1, 0, 0, 1, 1, 1, 0, 0]
|
||||
assert decoder._bits_to_symbol(valid_bits) == 100
|
||||
|
||||
# Invalid: flip one check bit -> total ones = 5 (odd) -> invalid
|
||||
invalid_bits = [0, 0, 1, 0, 0, 1, 1, 0, 0, 0]
|
||||
assert decoder._bits_to_symbol(invalid_bits) == -1
|
||||
|
||||
def test_safety_is_critical(self):
|
||||
"""Test that SAFETY category is marked as critical."""
|
||||
import json
|
||||
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
|
||||
raw = json.dumps({
|
||||
'type': 'dsc',
|
||||
'format': 123,
|
||||
'source_mmsi': '232123456',
|
||||
'category': 'SAFETY',
|
||||
'timestamp': '2025-01-15T12:00:00Z',
|
||||
'raw': '123232123456100122',
|
||||
})
|
||||
msg = parse_dsc_message(raw)
|
||||
assert msg is not None
|
||||
assert msg['is_critical'] is True
|
||||
|
||||
|
||||
class TestDSCConstants:
|
||||
"""Tests for DSC constants."""
|
||||
@@ -670,3 +747,27 @@ class TestDSCConstants:
|
||||
assert DSC_BAUD_RATE == 1200
|
||||
assert DSC_MARK_FREQ == 2100
|
||||
assert DSC_SPACE_FREQ == 1300
|
||||
|
||||
def test_telecommand_codes_full(self):
|
||||
"""Test TELECOMMAND_CODES_FULL covers 0-127 range."""
|
||||
from utils.dsc.constants import TELECOMMAND_CODES_FULL
|
||||
|
||||
assert len(TELECOMMAND_CODES_FULL) == 128
|
||||
# Known codes map correctly
|
||||
assert TELECOMMAND_CODES_FULL[100] == 'F3E_G3E_ALL'
|
||||
assert TELECOMMAND_CODES_FULL[107] == 'DISTRESS_ACK'
|
||||
# Unknown codes map to "UNKNOWN"
|
||||
assert TELECOMMAND_CODES_FULL[0] == 'UNKNOWN'
|
||||
assert TELECOMMAND_CODES_FULL[99] == 'UNKNOWN'
|
||||
|
||||
def test_telecommand_formats(self):
|
||||
"""Test TELECOMMAND_FORMATS contains correct format codes."""
|
||||
from utils.dsc.constants import TELECOMMAND_FORMATS
|
||||
|
||||
assert {112, 114, 116, 120, 123} == TELECOMMAND_FORMATS
|
||||
|
||||
def test_min_symbols_for_format(self):
|
||||
"""Test MIN_SYMBOLS_FOR_FORMAT constant."""
|
||||
from utils.dsc.constants import MIN_SYMBOLS_FOR_FORMAT
|
||||
|
||||
assert MIN_SYMBOLS_FOR_FORMAT == 12
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
"""Tests for Morse code decoder (utils/morse.py) and routes."""
|
||||
"""Tests for Morse code decoder pipeline and lifecycle routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import math
|
||||
import os
|
||||
import queue
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
import wave
|
||||
from collections import Counter
|
||||
|
||||
import pytest
|
||||
|
||||
import app as app_module
|
||||
import routes.morse as morse_routes
|
||||
from utils.morse import (
|
||||
CHAR_TO_MORSE,
|
||||
MORSE_TABLE,
|
||||
GoertzelFilter,
|
||||
MorseDecoder,
|
||||
decode_morse_wav_file,
|
||||
morse_decoder_thread,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -47,7 +56,7 @@ def generate_silence(duration: float, sample_rate: int = 8000) -> bytes:
|
||||
|
||||
|
||||
def generate_morse_audio(text: str, wpm: int = 15, tone_freq: float = 700.0, sample_rate: int = 8000) -> bytes:
|
||||
"""Generate PCM audio for a Morse-encoded string."""
|
||||
"""Generate synthetic CW PCM for the given text."""
|
||||
dit_dur = 1.2 / wpm
|
||||
dah_dur = 3 * dit_dur
|
||||
element_gap = dit_dur
|
||||
@@ -61,333 +70,477 @@ def generate_morse_audio(text: str, wpm: int = 15, tone_freq: float = 700.0, sam
|
||||
morse = CHAR_TO_MORSE.get(char)
|
||||
if morse is None:
|
||||
continue
|
||||
|
||||
for ei, element in enumerate(morse):
|
||||
if element == '.':
|
||||
audio += generate_tone(tone_freq, dit_dur, sample_rate)
|
||||
elif element == '-':
|
||||
audio += generate_tone(tone_freq, dah_dur, sample_rate)
|
||||
|
||||
if ei < len(morse) - 1:
|
||||
audio += generate_silence(element_gap, sample_rate)
|
||||
|
||||
if ci < len(word) - 1:
|
||||
audio += generate_silence(char_gap, sample_rate)
|
||||
|
||||
if wi < len(words) - 1:
|
||||
audio += generate_silence(word_gap, sample_rate)
|
||||
|
||||
# Add some leading/trailing silence for threshold settling
|
||||
silence = generate_silence(0.3, sample_rate)
|
||||
return silence + audio + silence
|
||||
# Leading/trailing silence for threshold settling.
|
||||
return generate_silence(0.3, sample_rate) + audio + generate_silence(0.3, sample_rate)
|
||||
|
||||
|
||||
def write_wav(path, pcm_bytes: bytes, sample_rate: int = 8000) -> None:
|
||||
"""Write mono 16-bit PCM bytes to a WAV file."""
|
||||
with wave.open(str(path), 'wb') as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(sample_rate)
|
||||
wf.writeframes(pcm_bytes)
|
||||
|
||||
|
||||
def decode_text_from_events(events) -> str:
|
||||
out = []
|
||||
for ev in events:
|
||||
if ev.get('type') == 'morse_char':
|
||||
out.append(str(ev.get('char', '')))
|
||||
elif ev.get('type') == 'morse_space':
|
||||
out.append(' ')
|
||||
return ''.join(out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MORSE_TABLE tests
|
||||
# Unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMorseTable:
|
||||
def test_all_26_letters_present(self):
|
||||
def test_morse_table_contains_letters_and_digits(self):
|
||||
chars = set(MORSE_TABLE.values())
|
||||
for letter in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ':
|
||||
assert letter in chars, f"Missing letter: {letter}"
|
||||
for ch in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789':
|
||||
assert ch in chars
|
||||
|
||||
def test_all_10_digits_present(self):
|
||||
chars = set(MORSE_TABLE.values())
|
||||
for digit in '0123456789':
|
||||
assert digit in chars, f"Missing digit: {digit}"
|
||||
|
||||
def test_reverse_lookup_consistent(self):
|
||||
def test_round_trip_morse_lookup(self):
|
||||
for morse, char in MORSE_TABLE.items():
|
||||
if char in CHAR_TO_MORSE:
|
||||
assert CHAR_TO_MORSE[char] == morse
|
||||
|
||||
def test_no_duplicate_morse_codes(self):
|
||||
"""Each morse pattern should map to exactly one character."""
|
||||
assert len(MORSE_TABLE) == len(set(MORSE_TABLE.keys()))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GoertzelFilter tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGoertzelFilter:
|
||||
def test_detects_target_frequency(self):
|
||||
class TestToneDetector:
|
||||
def test_goertzel_prefers_target_frequency(self):
|
||||
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160)
|
||||
# Generate 700 Hz tone
|
||||
samples = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(160)]
|
||||
mag = gf.magnitude(samples)
|
||||
assert mag > 10.0, f"Expected high magnitude for target freq, got {mag}"
|
||||
|
||||
def test_rejects_off_frequency(self):
|
||||
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160)
|
||||
# Generate 1500 Hz tone (well off target)
|
||||
samples = [0.8 * math.sin(2 * math.pi * 1500 * i / 8000) for i in range(160)]
|
||||
mag_off = gf.magnitude(samples)
|
||||
|
||||
# Compare with on-target
|
||||
samples_on = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(160)]
|
||||
mag_on = gf.magnitude(samples_on)
|
||||
|
||||
assert mag_on > mag_off * 3, "Target freq should be significantly stronger than off-freq"
|
||||
|
||||
def test_silence_returns_near_zero(self):
|
||||
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160)
|
||||
samples = [0.0] * 160
|
||||
mag = gf.magnitude(samples)
|
||||
assert mag < 0.01, f"Expected near-zero for silence, got {mag}"
|
||||
|
||||
def test_different_block_sizes(self):
|
||||
for block_size in [80, 160, 320]:
|
||||
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=block_size)
|
||||
samples = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(block_size)]
|
||||
mag = gf.magnitude(samples)
|
||||
assert mag > 5.0, f"Should detect tone with block_size={block_size}"
|
||||
on_tone = [0.8 * math.sin(2 * math.pi * 700.0 * i / 8000.0) for i in range(160)]
|
||||
off_tone = [0.8 * math.sin(2 * math.pi * 1500.0 * i / 8000.0) for i in range(160)]
|
||||
assert gf.magnitude(on_tone) > gf.magnitude(off_tone) * 3.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MorseDecoder tests
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestTimingAndWpmEstimator:
|
||||
def test_timing_classifier_distinguishes_dit_and_dah(self):
|
||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
|
||||
dit = 1.2 / 15.0
|
||||
dah = dit * 3.0
|
||||
|
||||
class TestMorseDecoder:
|
||||
def _make_decoder(self, wpm=15):
|
||||
"""Create decoder with pre-warmed threshold for testing."""
|
||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=wpm)
|
||||
# Warm up noise floor with silence
|
||||
silence = generate_silence(0.5)
|
||||
decoder.process_block(silence)
|
||||
# Warm up signal peak with tone
|
||||
tone = generate_tone(700.0, 0.3)
|
||||
decoder.process_block(tone)
|
||||
# More silence to settle
|
||||
silence2 = generate_silence(0.5)
|
||||
decoder.process_block(silence2)
|
||||
# Reset state after warm-up
|
||||
decoder._tone_on = False
|
||||
decoder._current_symbol = ''
|
||||
decoder._tone_blocks = 0
|
||||
decoder._silence_blocks = 0
|
||||
return decoder
|
||||
audio = (
|
||||
generate_silence(0.35)
|
||||
+ generate_tone(700.0, dit)
|
||||
+ generate_silence(dit * 1.5)
|
||||
+ generate_tone(700.0, dah)
|
||||
+ generate_silence(0.35)
|
||||
)
|
||||
|
||||
def test_dit_detection(self):
|
||||
"""A single dit should produce a '.' in the symbol buffer."""
|
||||
decoder = self._make_decoder()
|
||||
dit_dur = 1.2 / 15
|
||||
events = decoder.process_block(audio)
|
||||
events.extend(decoder.flush())
|
||||
elements = [e['element'] for e in events if e.get('type') == 'morse_element']
|
||||
|
||||
# Send a tone burst (dit)
|
||||
tone = generate_tone(700.0, dit_dur)
|
||||
decoder.process_block(tone)
|
||||
assert '.' in elements
|
||||
assert '-' in elements
|
||||
|
||||
# Send silence to trigger end of tone
|
||||
silence = generate_silence(dit_dur * 2)
|
||||
decoder.process_block(silence)
|
||||
def test_wpm_estimator_sanity(self):
|
||||
target_wpm = 18
|
||||
audio = generate_morse_audio('PARIS PARIS PARIS', wpm=target_wpm)
|
||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=12, wpm_mode='auto')
|
||||
|
||||
# Symbol buffer should have a dot
|
||||
assert '.' in decoder._current_symbol, f"Expected '.' in symbol, got '{decoder._current_symbol}'"
|
||||
|
||||
def test_dah_detection(self):
|
||||
"""A longer tone should produce a '-' in the symbol buffer."""
|
||||
decoder = self._make_decoder()
|
||||
dah_dur = 3 * 1.2 / 15
|
||||
|
||||
tone = generate_tone(700.0, dah_dur)
|
||||
decoder.process_block(tone)
|
||||
|
||||
silence = generate_silence(dah_dur)
|
||||
decoder.process_block(silence)
|
||||
|
||||
assert '-' in decoder._current_symbol, f"Expected '-' in symbol, got '{decoder._current_symbol}'"
|
||||
|
||||
def test_decode_letter_e(self):
|
||||
"""E is a single dit - the simplest character."""
|
||||
decoder = self._make_decoder()
|
||||
audio = generate_morse_audio('E', wpm=15)
|
||||
events = decoder.process_block(audio)
|
||||
events.extend(decoder.flush())
|
||||
|
||||
chars = [e for e in events if e['type'] == 'morse_char']
|
||||
decoded = ''.join(e['char'] for e in chars)
|
||||
assert 'E' in decoded, f"Expected 'E' in decoded text, got '{decoded}'"
|
||||
|
||||
def test_decode_letter_t(self):
|
||||
"""T is a single dah."""
|
||||
decoder = self._make_decoder()
|
||||
audio = generate_morse_audio('T', wpm=15)
|
||||
events = decoder.process_block(audio)
|
||||
events.extend(decoder.flush())
|
||||
|
||||
chars = [e for e in events if e['type'] == 'morse_char']
|
||||
decoded = ''.join(e['char'] for e in chars)
|
||||
assert 'T' in decoded, f"Expected 'T' in decoded text, got '{decoded}'"
|
||||
|
||||
def test_word_space_detection(self):
|
||||
"""A long silence between words should produce decoded chars with a space."""
|
||||
decoder = self._make_decoder()
|
||||
dit_dur = 1.2 / 15
|
||||
# E = dit
|
||||
audio = generate_tone(700.0, dit_dur) + generate_silence(7 * dit_dur * 1.5)
|
||||
# T = dah
|
||||
audio += generate_tone(700.0, 3 * dit_dur) + generate_silence(3 * dit_dur)
|
||||
events = decoder.process_block(audio)
|
||||
events.extend(decoder.flush())
|
||||
|
||||
spaces = [e for e in events if e['type'] == 'morse_space']
|
||||
assert len(spaces) >= 1, "Expected at least one word space"
|
||||
|
||||
def test_scope_events_generated(self):
|
||||
"""Decoder should produce scope events for visualization."""
|
||||
audio = generate_morse_audio('SOS', wpm=15)
|
||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
|
||||
|
||||
events = decoder.process_block(audio)
|
||||
|
||||
scope_events = [e for e in events if e['type'] == 'scope']
|
||||
assert len(scope_events) > 0, "Expected scope events"
|
||||
# Check scope event structure
|
||||
se = scope_events[0]
|
||||
assert 'amplitudes' in se
|
||||
assert 'threshold' in se
|
||||
assert 'tone_on' in se
|
||||
|
||||
def test_adaptive_threshold_adjusts(self):
|
||||
"""After processing audio, threshold should be non-zero."""
|
||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
|
||||
|
||||
# Process some tone + silence
|
||||
audio = generate_tone(700.0, 0.3) + generate_silence(0.3)
|
||||
decoder.process_block(audio)
|
||||
|
||||
assert decoder._threshold > 0, "Threshold should adapt above zero"
|
||||
|
||||
def test_flush_emits_pending_char(self):
|
||||
"""flush() should emit any accumulated but not-yet-decoded symbol."""
|
||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
|
||||
decoder._current_symbol = '.' # Manually set pending dit
|
||||
events = decoder.flush()
|
||||
assert len(events) == 1
|
||||
assert events[0]['type'] == 'morse_char'
|
||||
assert events[0]['char'] == 'E'
|
||||
|
||||
def test_flush_empty_returns_nothing(self):
|
||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
|
||||
events = decoder.flush()
|
||||
assert events == []
|
||||
metrics = decoder.get_metrics()
|
||||
assert metrics['wpm'] >= 10.0
|
||||
assert metrics['wpm'] <= 35.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# morse_decoder_thread tests
|
||||
# Decoder thread tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMorseDecoderThread:
|
||||
def test_thread_stops_on_event(self):
|
||||
"""Thread should exit when stop_event is set."""
|
||||
import io
|
||||
# Create a fake stdout that blocks until stop
|
||||
stop = threading.Event()
|
||||
q = queue.Queue(maxsize=100)
|
||||
def test_thread_emits_waiting_heartbeat_on_no_data(self):
|
||||
stop_event = threading.Event()
|
||||
output_queue = queue.Queue(maxsize=64)
|
||||
|
||||
# Feed some audio then close
|
||||
audio = generate_morse_audio('E', wpm=15)
|
||||
fake_stdout = io.BytesIO(audio)
|
||||
read_fd, write_fd = os.pipe()
|
||||
read_file = os.fdopen(read_fd, 'rb', 0)
|
||||
|
||||
t = threading.Thread(
|
||||
worker = threading.Thread(
|
||||
target=morse_decoder_thread,
|
||||
args=(fake_stdout, q, stop),
|
||||
args=(read_file, output_queue, stop_event),
|
||||
daemon=True,
|
||||
)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
t.join(timeout=5)
|
||||
assert not t.is_alive(), "Thread should finish after reading all data"
|
||||
worker.start()
|
||||
|
||||
def test_thread_produces_events(self):
|
||||
"""Thread should push character events to the queue."""
|
||||
import io
|
||||
from unittest.mock import patch
|
||||
stop = threading.Event()
|
||||
q = queue.Queue(maxsize=1000)
|
||||
got_waiting = False
|
||||
deadline = time.monotonic() + 3.5
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
msg = output_queue.get(timeout=0.3)
|
||||
except queue.Empty:
|
||||
continue
|
||||
if msg.get('type') == 'scope' and msg.get('waiting'):
|
||||
got_waiting = True
|
||||
break
|
||||
|
||||
# Generate audio with pre-warmed decoder in mind
|
||||
# The thread creates a fresh decoder, so generate lots of audio
|
||||
audio = generate_silence(0.5) + generate_morse_audio('SOS', wpm=10) + generate_silence(1.0)
|
||||
fake_stdout = io.BytesIO(audio)
|
||||
stop_event.set()
|
||||
os.close(write_fd)
|
||||
read_file.close()
|
||||
worker.join(timeout=2.0)
|
||||
|
||||
# Patch SCOPE_INTERVAL to 0 so scope events aren't throttled in fast reads
|
||||
with patch('utils.morse.time') as mock_time:
|
||||
# Make monotonic() always return increasing values
|
||||
counter = [0.0]
|
||||
def fake_monotonic():
|
||||
counter[0] += 0.15 # each call advances 150ms
|
||||
return counter[0]
|
||||
mock_time.monotonic = fake_monotonic
|
||||
assert got_waiting is True
|
||||
assert not worker.is_alive()
|
||||
|
||||
t = threading.Thread(
|
||||
def test_thread_produces_character_events(self):
|
||||
stop_event = threading.Event()
|
||||
output_queue = queue.Queue(maxsize=512)
|
||||
audio = generate_morse_audio('SOS', wpm=15)
|
||||
|
||||
worker = threading.Thread(
|
||||
target=morse_decoder_thread,
|
||||
args=(fake_stdout, q, stop),
|
||||
args=(io.BytesIO(audio), output_queue, stop_event),
|
||||
daemon=True,
|
||||
)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
t.join(timeout=10)
|
||||
worker.start()
|
||||
worker.join(timeout=4.0)
|
||||
|
||||
events = []
|
||||
while not q.empty():
|
||||
events.append(q.get_nowait())
|
||||
while not output_queue.empty():
|
||||
events.append(output_queue.get_nowait())
|
||||
|
||||
# Should have at least some events (scope or char)
|
||||
assert len(events) > 0, "Expected events from thread"
|
||||
chars = [e for e in events if e.get('type') == 'morse_char']
|
||||
assert len(chars) >= 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route tests
|
||||
# Route lifecycle regression
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMorseRoutes:
|
||||
def test_start_missing_required_fields(self, client):
|
||||
"""Start should succeed with defaults."""
|
||||
_login_session(client)
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
m.setattr('app.morse_process', None)
|
||||
# Should fail because rtl_fm won't be found in test env
|
||||
resp = client.post('/morse/start', json={'frequency': '14.060'})
|
||||
assert resp.status_code in (200, 400, 409, 500)
|
||||
class TestMorseLifecycleRoutes:
|
||||
def _reset_route_state(self):
|
||||
with app_module.morse_lock:
|
||||
app_module.morse_process = None
|
||||
while not app_module.morse_queue.empty():
|
||||
try:
|
||||
app_module.morse_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
def test_stop_when_not_running(self, client):
|
||||
"""Stop when nothing is running should return not_running."""
|
||||
_login_session(client)
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
m.setattr('app.morse_process', None)
|
||||
resp = client.post('/morse/stop')
|
||||
data = resp.get_json()
|
||||
assert data['status'] == 'not_running'
|
||||
morse_routes.morse_active_device = None
|
||||
morse_routes.morse_decoder_worker = None
|
||||
morse_routes.morse_stderr_worker = None
|
||||
morse_routes.morse_relay_worker = None
|
||||
morse_routes.morse_stop_event = None
|
||||
morse_routes.morse_control_queue = None
|
||||
morse_routes.morse_runtime_config = {}
|
||||
morse_routes.morse_last_error = ''
|
||||
morse_routes.morse_state = morse_routes.MORSE_IDLE
|
||||
morse_routes.morse_state_message = 'Idle'
|
||||
|
||||
def test_status_when_not_running(self, client):
|
||||
"""Status should report not running."""
|
||||
def test_start_stop_reaches_idle_and_releases_resources(self, client, monkeypatch):
|
||||
_login_session(client)
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
m.setattr('app.morse_process', None)
|
||||
resp = client.get('/morse/status')
|
||||
data = resp.get_json()
|
||||
assert data['running'] is False
|
||||
self._reset_route_state()
|
||||
|
||||
def test_invalid_tone_freq(self, client):
|
||||
"""Tone frequency outside range should be rejected."""
|
||||
_login_session(client)
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
m.setattr('app.morse_process', None)
|
||||
resp = client.post('/morse/start', json={
|
||||
released_devices = []
|
||||
|
||||
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None)
|
||||
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx))
|
||||
|
||||
class DummyDevice:
|
||||
sdr_type = morse_routes.SDRType.RTL_SDR
|
||||
|
||||
class DummyBuilder:
|
||||
def build_fm_demod_command(self, **kwargs):
|
||||
return ['rtl_fm', '-f', '14060000', '-']
|
||||
|
||||
monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice()))
|
||||
monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder()))
|
||||
monkeypatch.setattr(morse_routes.SDRFactory, 'detect_devices', staticmethod(lambda: []))
|
||||
|
||||
pcm = generate_morse_audio('E', wpm=15, sample_rate=22050)
|
||||
|
||||
class FakeRtlProc:
|
||||
def __init__(self, payload: bytes):
|
||||
self.stdout = io.BytesIO(payload)
|
||||
self.stderr = io.BytesIO(b'')
|
||||
self.returncode = None
|
||||
|
||||
def poll(self):
|
||||
return self.returncode
|
||||
|
||||
def terminate(self):
|
||||
self.returncode = 0
|
||||
|
||||
def wait(self, timeout=None):
|
||||
self.returncode = 0
|
||||
return 0
|
||||
|
||||
def kill(self):
|
||||
self.returncode = -9
|
||||
|
||||
def fake_popen(cmd, *args, **kwargs):
|
||||
return FakeRtlProc(pcm)
|
||||
|
||||
monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen)
|
||||
monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None)
|
||||
monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None)
|
||||
monkeypatch.setattr(
|
||||
morse_routes,
|
||||
'safe_terminate',
|
||||
lambda proc, timeout=0.0: setattr(proc, 'returncode', 0),
|
||||
)
|
||||
|
||||
start_resp = client.post('/morse/start', json={
|
||||
'frequency': '14.060',
|
||||
'tone_freq': '50', # too low
|
||||
'gain': '20',
|
||||
'ppm': '0',
|
||||
'device': '0',
|
||||
'tone_freq': '700',
|
||||
'wpm': '15',
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
assert start_resp.status_code == 200
|
||||
assert start_resp.get_json()['status'] == 'started'
|
||||
|
||||
def test_invalid_wpm(self, client):
|
||||
"""WPM outside range should be rejected."""
|
||||
status_resp = client.get('/morse/status')
|
||||
assert status_resp.status_code == 200
|
||||
assert status_resp.get_json()['state'] in {'running', 'starting', 'stopping', 'idle'}
|
||||
|
||||
stop_resp = client.post('/morse/stop')
|
||||
assert stop_resp.status_code == 200
|
||||
stop_data = stop_resp.get_json()
|
||||
assert stop_data['status'] == 'stopped'
|
||||
assert stop_data['state'] == 'idle'
|
||||
assert stop_data['alive'] == []
|
||||
|
||||
final_status = client.get('/morse/status').get_json()
|
||||
assert final_status['running'] is False
|
||||
assert final_status['state'] == 'idle'
|
||||
assert 0 in released_devices
|
||||
|
||||
def test_start_retries_after_early_process_exit(self, client, monkeypatch):
|
||||
_login_session(client)
|
||||
with pytest.MonkeyPatch.context() as m:
|
||||
m.setattr('app.morse_process', None)
|
||||
resp = client.post('/morse/start', json={
|
||||
self._reset_route_state()
|
||||
|
||||
released_devices = []
|
||||
|
||||
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None)
|
||||
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx))
|
||||
|
||||
class DummyDevice:
|
||||
sdr_type = morse_routes.SDRType.RTL_SDR
|
||||
|
||||
class DummyBuilder:
|
||||
def build_fm_demod_command(self, **kwargs):
|
||||
cmd = ['rtl_fm', '-f', '14.060M', '-M', 'usb', '-s', '22050']
|
||||
if kwargs.get('direct_sampling') is not None:
|
||||
cmd.extend(['--direct', str(kwargs['direct_sampling'])])
|
||||
cmd.append('-')
|
||||
return cmd
|
||||
|
||||
monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice()))
|
||||
monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder()))
|
||||
monkeypatch.setattr(morse_routes.SDRFactory, 'detect_devices', staticmethod(lambda: []))
|
||||
|
||||
pcm = generate_morse_audio('E', wpm=15, sample_rate=22050)
|
||||
rtl_cmds = []
|
||||
|
||||
class FakeRtlProc:
|
||||
def __init__(self, stdout_bytes: bytes, returncode: int | None):
|
||||
self.stdout = io.BytesIO(stdout_bytes)
|
||||
self.stderr = io.BytesIO(b'')
|
||||
self.returncode = returncode
|
||||
|
||||
def poll(self):
|
||||
return self.returncode
|
||||
|
||||
def terminate(self):
|
||||
self.returncode = 0
|
||||
|
||||
def wait(self, timeout=None):
|
||||
self.returncode = 0
|
||||
return 0
|
||||
|
||||
def kill(self):
|
||||
self.returncode = -9
|
||||
|
||||
def fake_popen(cmd, *args, **kwargs):
|
||||
rtl_cmds.append(cmd)
|
||||
if len(rtl_cmds) == 1:
|
||||
return FakeRtlProc(b'', 1)
|
||||
return FakeRtlProc(pcm, None)
|
||||
|
||||
monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen)
|
||||
monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None)
|
||||
monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None)
|
||||
monkeypatch.setattr(
|
||||
morse_routes,
|
||||
'safe_terminate',
|
||||
lambda proc, timeout=0.0: setattr(proc, 'returncode', 0),
|
||||
)
|
||||
|
||||
start_resp = client.post('/morse/start', json={
|
||||
'frequency': '14.060',
|
||||
'wpm': '100', # too high
|
||||
'gain': '20',
|
||||
'ppm': '0',
|
||||
'device': '0',
|
||||
'tone_freq': '700',
|
||||
'wpm': '15',
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
assert start_resp.status_code == 200
|
||||
assert start_resp.get_json()['status'] == 'started'
|
||||
assert len(rtl_cmds) >= 2
|
||||
assert rtl_cmds[0][0] == 'rtl_fm'
|
||||
assert '--direct' in rtl_cmds[0]
|
||||
assert '2' in rtl_cmds[0]
|
||||
assert rtl_cmds[1][0] == 'rtl_fm'
|
||||
assert '--direct' in rtl_cmds[1]
|
||||
assert '1' in rtl_cmds[1]
|
||||
|
||||
def test_stream_endpoint_exists(self, client):
|
||||
"""Stream endpoint should return SSE content type."""
|
||||
stop_resp = client.post('/morse/stop')
|
||||
assert stop_resp.status_code == 200
|
||||
assert stop_resp.get_json()['status'] == 'stopped'
|
||||
assert 0 in released_devices
|
||||
|
||||
def test_start_falls_back_to_next_device_when_selected_device_has_no_pcm(self, client, monkeypatch):
|
||||
_login_session(client)
|
||||
resp = client.get('/morse/stream')
|
||||
assert resp.content_type.startswith('text/event-stream')
|
||||
self._reset_route_state()
|
||||
|
||||
released_devices = []
|
||||
|
||||
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None)
|
||||
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx))
|
||||
|
||||
class DummyDevice:
|
||||
def __init__(self, index: int):
|
||||
self.sdr_type = morse_routes.SDRType.RTL_SDR
|
||||
self.index = index
|
||||
|
||||
class DummyDetected:
|
||||
def __init__(self, index: int, serial: str):
|
||||
self.sdr_type = morse_routes.SDRType.RTL_SDR
|
||||
self.index = index
|
||||
self.name = f'RTL {index}'
|
||||
self.serial = serial
|
||||
|
||||
class DummyBuilder:
|
||||
def build_fm_demod_command(self, **kwargs):
|
||||
cmd = ['rtl_fm', '-d', str(kwargs['device'].index), '-f', '14.060M', '-M', 'usb', '-s', '22050']
|
||||
if kwargs.get('direct_sampling') is not None:
|
||||
cmd.extend(['--direct', str(kwargs['direct_sampling'])])
|
||||
cmd.append('-')
|
||||
return cmd
|
||||
|
||||
monkeypatch.setattr(
|
||||
morse_routes.SDRFactory,
|
||||
'create_default_device',
|
||||
staticmethod(lambda sdr_type, index: DummyDevice(int(index))),
|
||||
)
|
||||
monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder()))
|
||||
monkeypatch.setattr(
|
||||
morse_routes.SDRFactory,
|
||||
'detect_devices',
|
||||
staticmethod(lambda: [DummyDetected(0, 'AAA00000'), DummyDetected(1, 'BBB11111')]),
|
||||
)
|
||||
|
||||
pcm = generate_morse_audio('E', wpm=15, sample_rate=22050)
|
||||
|
||||
class FakeRtlProc:
|
||||
def __init__(self, stdout_bytes: bytes, returncode: int | None):
|
||||
self.stdout = io.BytesIO(stdout_bytes)
|
||||
self.stderr = io.BytesIO(b'')
|
||||
self.returncode = returncode
|
||||
|
||||
def poll(self):
|
||||
return self.returncode
|
||||
|
||||
def terminate(self):
|
||||
self.returncode = 0
|
||||
|
||||
def wait(self, timeout=None):
|
||||
self.returncode = 0
|
||||
return 0
|
||||
|
||||
def kill(self):
|
||||
self.returncode = -9
|
||||
|
||||
def fake_popen(cmd, *args, **kwargs):
|
||||
try:
|
||||
dev = int(cmd[cmd.index('-d') + 1])
|
||||
except Exception:
|
||||
dev = 0
|
||||
if dev == 0:
|
||||
return FakeRtlProc(b'', 1)
|
||||
return FakeRtlProc(pcm, None)
|
||||
|
||||
monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen)
|
||||
monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None)
|
||||
monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None)
|
||||
monkeypatch.setattr(
|
||||
morse_routes,
|
||||
'safe_terminate',
|
||||
lambda proc, timeout=0.0: setattr(proc, 'returncode', 0),
|
||||
)
|
||||
|
||||
start_resp = client.post('/morse/start', json={
|
||||
'frequency': '14.060',
|
||||
'gain': '20',
|
||||
'ppm': '0',
|
||||
'device': '0',
|
||||
'tone_freq': '700',
|
||||
'wpm': '15',
|
||||
})
|
||||
assert start_resp.status_code == 200
|
||||
start_data = start_resp.get_json()
|
||||
assert start_data['status'] == 'started'
|
||||
assert start_data['config']['active_device'] == 1
|
||||
assert start_data['config']['device_serial'] == 'BBB11111'
|
||||
assert 0 in released_devices
|
||||
|
||||
stop_resp = client.post('/morse/stop')
|
||||
assert stop_resp.status_code == 200
|
||||
assert stop_resp.get_json()['status'] == 'stopped'
|
||||
assert 1 in released_devices
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: synthetic CW -> WAV decode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMorseIntegration:
|
||||
def test_decode_morse_wav_contains_expected_phrase(self, tmp_path):
|
||||
wav_path = tmp_path / 'cq_test_123.wav'
|
||||
pcm = generate_morse_audio('CQ TEST 123', wpm=15, tone_freq=700.0)
|
||||
write_wav(wav_path, pcm, sample_rate=8000)
|
||||
|
||||
result = decode_morse_wav_file(
|
||||
wav_path,
|
||||
sample_rate=8000,
|
||||
tone_freq=700.0,
|
||||
wpm=15,
|
||||
bandwidth_hz=200,
|
||||
auto_tone_track=True,
|
||||
threshold_mode='auto',
|
||||
wpm_mode='auto',
|
||||
min_signal_gate=0.0,
|
||||
)
|
||||
|
||||
decoded = ' '.join(str(result.get('text', '')).split())
|
||||
assert 'CQ TEST 123' in decoded
|
||||
|
||||
events = result.get('events', [])
|
||||
event_counts = Counter(e.get('type') for e in events)
|
||||
assert event_counts['morse_char'] >= len('CQTEST123')
|
||||
|
||||
89
tests/test_system.py
Normal file
89
tests/test_system.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Tests for the System Health monitoring blueprint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def _login(client):
|
||||
"""Mark the Flask test session as authenticated."""
|
||||
with client.session_transaction() as sess:
|
||||
sess['logged_in'] = True
|
||||
sess['username'] = 'test'
|
||||
sess['role'] = 'admin'
|
||||
|
||||
|
||||
def test_metrics_returns_expected_keys(client):
|
||||
"""GET /system/metrics returns top-level metric keys."""
|
||||
_login(client)
|
||||
resp = client.get('/system/metrics')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'system' in data
|
||||
assert 'processes' in data
|
||||
assert 'cpu' in data
|
||||
assert 'memory' in data
|
||||
assert 'disk' in data
|
||||
assert data['system']['hostname']
|
||||
assert 'version' in data['system']
|
||||
assert 'uptime_seconds' in data['system']
|
||||
assert 'uptime_human' in data['system']
|
||||
|
||||
|
||||
def test_metrics_without_psutil(client):
|
||||
"""Metrics degrade gracefully when psutil is unavailable."""
|
||||
_login(client)
|
||||
import routes.system as mod
|
||||
|
||||
orig = mod._HAS_PSUTIL
|
||||
mod._HAS_PSUTIL = False
|
||||
try:
|
||||
resp = client.get('/system/metrics')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
# These fields should be None without psutil
|
||||
assert data['cpu'] is None
|
||||
assert data['memory'] is None
|
||||
assert data['disk'] is None
|
||||
finally:
|
||||
mod._HAS_PSUTIL = orig
|
||||
|
||||
|
||||
def test_sdr_devices_returns_list(client):
|
||||
"""GET /system/sdr_devices returns a devices list."""
|
||||
_login(client)
|
||||
mock_device = MagicMock()
|
||||
mock_device.sdr_type = MagicMock()
|
||||
mock_device.sdr_type.value = 'rtlsdr'
|
||||
mock_device.index = 0
|
||||
mock_device.name = 'Generic RTL2832U'
|
||||
mock_device.serial = '00000001'
|
||||
mock_device.driver = 'rtlsdr'
|
||||
|
||||
with patch('utils.sdr.detection.detect_all_devices', return_value=[mock_device]):
|
||||
resp = client.get('/system/sdr_devices')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert 'devices' in data
|
||||
assert len(data['devices']) == 1
|
||||
assert data['devices'][0]['type'] == 'rtlsdr'
|
||||
assert data['devices'][0]['name'] == 'Generic RTL2832U'
|
||||
|
||||
|
||||
def test_sdr_devices_handles_detection_failure(client):
|
||||
"""SDR detection failure returns empty list with error."""
|
||||
_login(client)
|
||||
with patch('utils.sdr.detection.detect_all_devices', side_effect=RuntimeError('no devices')):
|
||||
resp = client.get('/system/sdr_devices')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data['devices'] == []
|
||||
assert 'error' in data
|
||||
|
||||
|
||||
def test_stream_returns_sse_content_type(client):
|
||||
"""GET /system/stream returns text/event-stream."""
|
||||
_login(client)
|
||||
resp = client.get('/system/stream')
|
||||
assert resp.status_code == 200
|
||||
assert 'text/event-stream' in resp.content_type
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Any
|
||||
@@ -19,6 +20,26 @@ def check_tool(name: str) -> bool:
|
||||
|
||||
def get_tool_path(name: str) -> str | None:
|
||||
"""Get the full path to a tool, checking standard PATH and extra locations."""
|
||||
# Optional explicit override, e.g. INTERCEPT_RTL_FM_PATH=/opt/homebrew/bin/rtl_fm
|
||||
env_key = f"INTERCEPT_{name.upper().replace('-', '_')}_PATH"
|
||||
env_path = os.environ.get(env_key)
|
||||
if env_path and os.path.isfile(env_path) and os.access(env_path, os.X_OK):
|
||||
return env_path
|
||||
|
||||
# Prefer native Homebrew binaries on Apple Silicon to avoid mixing Rosetta
|
||||
# /usr/local tools with arm64 Python/runtime.
|
||||
if platform.system() == 'Darwin':
|
||||
machine = platform.machine().lower()
|
||||
preferred_paths: list[str] = []
|
||||
if machine in {'arm64', 'aarch64'}:
|
||||
preferred_paths.append('/opt/homebrew/bin')
|
||||
preferred_paths.append('/usr/local/bin')
|
||||
|
||||
for base in preferred_paths:
|
||||
full_path = os.path.join(base, name)
|
||||
if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
|
||||
return full_path
|
||||
|
||||
# First check standard PATH
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
|
||||
@@ -89,6 +89,15 @@ TELECOMMAND_CODES = {
|
||||
201: 'POLL_RESPONSE', # Poll response
|
||||
}
|
||||
|
||||
# Full 0-127 telecommand lookup (maps unknown codes to "UNKNOWN")
|
||||
TELECOMMAND_CODES_FULL = {i: TELECOMMAND_CODES.get(i, "UNKNOWN") for i in range(128)}
|
||||
|
||||
# Format codes that carry telecommand fields
|
||||
TELECOMMAND_FORMATS = {112, 114, 116, 120, 123}
|
||||
|
||||
# Minimum symbols (after phasing strip) before an EOS can be accepted
|
||||
MIN_SYMBOLS_FOR_FORMAT = 12
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DSC Symbol Definitions
|
||||
|
||||
@@ -43,6 +43,8 @@ from .constants import (
|
||||
FORMAT_CODES,
|
||||
DISTRESS_NATURE_CODES,
|
||||
VALID_EOS,
|
||||
TELECOMMAND_FORMATS,
|
||||
MIN_SYMBOLS_FOR_FORMAT,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
@@ -222,13 +224,14 @@ class DSCDecoder:
|
||||
Detect DSC dot pattern for synchronization.
|
||||
|
||||
The dot pattern is at least 200 alternating bits (1010101...).
|
||||
We look for at least 20 consecutive alternations.
|
||||
We require at least 100 consecutive alternations to avoid
|
||||
false sync triggers from noise.
|
||||
"""
|
||||
if len(self.bit_buffer) < 40:
|
||||
if len(self.bit_buffer) < 200:
|
||||
return False
|
||||
|
||||
# Check last 40 bits for alternating pattern
|
||||
last_bits = self.bit_buffer[-40:]
|
||||
# Check last 200 bits for alternating pattern
|
||||
last_bits = self.bit_buffer[-200:]
|
||||
alternations = 0
|
||||
|
||||
for i in range(1, len(last_bits)):
|
||||
@@ -237,7 +240,7 @@ class DSCDecoder:
|
||||
else:
|
||||
alternations = 0
|
||||
|
||||
if alternations >= 20:
|
||||
if alternations >= 100:
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -263,27 +266,37 @@ class DSCDecoder:
|
||||
if end <= len(self.message_bits):
|
||||
symbol_bits = self.message_bits[start:end]
|
||||
symbol_value = self._bits_to_symbol(symbol_bits)
|
||||
if symbol_value == -1:
|
||||
logger.debug("DSC symbol check bit failure, aborting decode")
|
||||
return None
|
||||
symbols.append(symbol_value)
|
||||
|
||||
# Strip phasing sequence (RX/DX symbols 120-126) from the
|
||||
# start of the message. Per ITU-R M.493, after the dot pattern
|
||||
# there are 7 phasing symbols before the format specifier.
|
||||
# Bound to max 7 — if more are present, this is a bad sync.
|
||||
msg_start = 0
|
||||
for i, sym in enumerate(symbols):
|
||||
if 120 <= sym <= 126:
|
||||
msg_start = i + 1
|
||||
else:
|
||||
break
|
||||
if msg_start > 7:
|
||||
logger.debug("DSC bad sync: >7 phasing symbols stripped")
|
||||
return None
|
||||
symbols = symbols[msg_start:]
|
||||
|
||||
if len(symbols) < 5:
|
||||
return None
|
||||
|
||||
# Look for EOS (End of Sequence) - symbols 117, 122, or 127
|
||||
# EOS must appear after at least MIN_SYMBOLS_FOR_FORMAT symbols
|
||||
eos_found = False
|
||||
eos_index = -1
|
||||
for i, sym in enumerate(symbols):
|
||||
if sym in VALID_EOS:
|
||||
if i < MIN_SYMBOLS_FOR_FORMAT:
|
||||
continue # Too early — not a real EOS
|
||||
eos_found = True
|
||||
eos_index = i
|
||||
break
|
||||
@@ -300,7 +313,9 @@ class DSCDecoder:
|
||||
Convert 10 bits to symbol value.
|
||||
|
||||
DSC uses 10-bit symbols: 7 information bits + 3 error bits.
|
||||
We extract the 7-bit value.
|
||||
The 3 check bits provide parity such that the total number of
|
||||
'1' bits across all 10 bits should be even (even parity).
|
||||
Returns -1 if the check bits are invalid.
|
||||
"""
|
||||
if len(bits) != 10:
|
||||
return -1
|
||||
@@ -311,6 +326,11 @@ class DSCDecoder:
|
||||
if bits[i]:
|
||||
value |= (1 << i)
|
||||
|
||||
# Validate check bits: total number of 1s should be even
|
||||
ones = sum(bits)
|
||||
if ones % 2 != 0:
|
||||
return -1
|
||||
|
||||
return value
|
||||
|
||||
def _decode_symbols(self, symbols: list[int]) -> dict | None:
|
||||
@@ -356,9 +376,13 @@ class DSCDecoder:
|
||||
|
||||
# Decode MMSI from symbols 1-5 (destination/address)
|
||||
dest_mmsi = self._decode_mmsi(symbols[1:6])
|
||||
if dest_mmsi is None:
|
||||
return None
|
||||
|
||||
# Decode self-ID from symbols 6-10 (source)
|
||||
source_mmsi = self._decode_mmsi(symbols[6:11])
|
||||
if source_mmsi is None:
|
||||
return None
|
||||
|
||||
message = {
|
||||
'type': 'dsc',
|
||||
@@ -387,8 +411,9 @@ class DSCDecoder:
|
||||
if position:
|
||||
message['position'] = position
|
||||
|
||||
# Telecommand fields (usually last two before EOS)
|
||||
if len(remaining) >= 2:
|
||||
# Telecommand fields (last two before EOS) — only for formats
|
||||
# that carry telecommand fields per ITU-R M.493
|
||||
if format_code in TELECOMMAND_FORMATS and len(remaining) >= 2:
|
||||
message['telecommand1'] = remaining[-2]
|
||||
message['telecommand2'] = remaining[-1]
|
||||
|
||||
@@ -402,20 +427,21 @@ class DSCDecoder:
|
||||
logger.warning(f"DSC decode error: {e}")
|
||||
return None
|
||||
|
||||
def _decode_mmsi(self, symbols: list[int]) -> str:
|
||||
def _decode_mmsi(self, symbols: list[int]) -> str | None:
|
||||
"""
|
||||
Decode MMSI from 5 DSC symbols.
|
||||
|
||||
Each symbol represents 2 BCD digits (00-99).
|
||||
5 symbols = 10 digits, but MMSI is 9 digits (first symbol has leading 0).
|
||||
Returns None if any symbol is out of valid BCD range.
|
||||
"""
|
||||
if len(symbols) < 5:
|
||||
return '000000000'
|
||||
return None
|
||||
|
||||
digits = []
|
||||
for sym in symbols:
|
||||
if sym < 0 or sym > 99:
|
||||
sym = 0
|
||||
return None
|
||||
# Each symbol is 2 BCD digits
|
||||
digits.append(f'{sym:02d}')
|
||||
|
||||
|
||||
@@ -248,7 +248,10 @@ def parse_dsc_message(raw_line: str) -> dict[str, Any] | None:
|
||||
msg['priority'] = get_category_priority(msg['category'])
|
||||
|
||||
# Mark if this is a critical alert
|
||||
msg['is_critical'] = msg['category'] in ('DISTRESS', 'ALL_SHIPS_URGENCY_SAFETY')
|
||||
msg['is_critical'] = msg['category'] in (
|
||||
'DISTRESS', 'DISTRESS_ACK', 'DISTRESS_RELAY',
|
||||
'URGENCY', 'SAFETY', 'ALL_SHIPS_URGENCY_SAFETY',
|
||||
)
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
1227
utils/morse.py
1227
utils/morse.py
File diff suppressed because it is too large
Load Diff
@@ -86,12 +86,17 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
bias_t: bool = False,
|
||||
direct_sampling: Optional[int] = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build rtl_fm command for FM demodulation.
|
||||
|
||||
Used for pager decoding. Supports local devices and rtl_tcp connections.
|
||||
|
||||
Args:
|
||||
direct_sampling: Enable direct sampling mode (0=off, 1=I-branch,
|
||||
2=Q-branch). Use 2 for HF reception below 24 MHz.
|
||||
"""
|
||||
rtl_fm_path = get_tool_path('rtl_fm') or 'rtl_fm'
|
||||
demod_mode = _rtl_fm_demod_mode(modulation)
|
||||
@@ -112,6 +117,14 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
if squelch is not None and squelch > 0:
|
||||
cmd.extend(['-l', str(squelch)])
|
||||
|
||||
if direct_sampling is not None:
|
||||
# Older rtl_fm builds (common in Docker/distro packages) don't
|
||||
# support -D; they use -E direct / -E direct2 instead.
|
||||
if direct_sampling == 1:
|
||||
cmd.extend(['-E', 'direct'])
|
||||
elif direct_sampling == 2:
|
||||
cmd.extend(['-E', 'direct2'])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['-T'])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user