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:
38
README.md
38
README.md
@@ -50,12 +50,38 @@ Support the developer of this open-source project
|
|||||||
- **Meshtastic** - LoRa mesh network integration
|
- **Meshtastic** - LoRa mesh network integration
|
||||||
- **Space Weather** - Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL (no SDR required)
|
- **Space Weather** - Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL (no SDR required)
|
||||||
- **Spy Stations** - Number stations and diplomatic HF network database
|
- **Spy Stations** - Number stations and diplomatic HF network database
|
||||||
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
||||||
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation / Debian / Ubuntu / MacOS
|
## 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:**
|
**1. Clone and run:**
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -44,3 +44,6 @@ cryptography>=41.0.0
|
|||||||
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
|
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
|
||||||
flask-sock
|
flask-sock
|
||||||
websocket-client>=1.6.0
|
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 import sstv_bp
|
||||||
from .sstv_general import sstv_general_bp
|
from .sstv_general import sstv_general_bp
|
||||||
from .subghz import subghz_bp
|
from .subghz import subghz_bp
|
||||||
|
from .system import system_bp
|
||||||
from .tscm import init_tscm_state, tscm_bp
|
from .tscm import init_tscm_state, tscm_bp
|
||||||
from .updater import updater_bp
|
from .updater import updater_bp
|
||||||
from .vdl2 import vdl2_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(signalid_bp) # External signal ID enrichment
|
||||||
app.register_blueprint(wefax_bp) # WeFax HF weather fax decoder
|
app.register_blueprint(wefax_bp) # WeFax HF weather fax decoder
|
||||||
app.register_blueprint(morse_bp) # CW/Morse code 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
|
# Initialize TSCM state with queue and lock from app
|
||||||
import app as app_module
|
import app as app_module
|
||||||
|
|||||||
@@ -1462,9 +1462,11 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
|||||||
try:
|
try:
|
||||||
app_module.aprs_queue.put({'type': 'status', 'status': 'started'})
|
app_module.aprs_queue.put({'type': 'status', 'status': 'started'})
|
||||||
|
|
||||||
# Read line-by-line in text mode. Empty string '' signals EOF.
|
# Read line-by-line in binary mode. Empty bytes b'' signals EOF.
|
||||||
for line in iter(decoder_process.stdout.readline, ''):
|
# Decode with errors='replace' so corrupted radio bytes (e.g. 0xf7)
|
||||||
line = line.strip()
|
# never crash the stream.
|
||||||
|
for raw in iter(decoder_process.stdout.readline, b''):
|
||||||
|
line = raw.decode('utf-8', errors='replace').strip()
|
||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -1784,15 +1786,15 @@ def start_aprs() -> Response:
|
|||||||
rtl_stderr_thread.start()
|
rtl_stderr_thread.start()
|
||||||
|
|
||||||
# Start decoder with stdin wired to rtl_fm's stdout.
|
# 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.
|
# Merge stderr into stdout to avoid blocking on unbuffered stderr.
|
||||||
decoder_process = subprocess.Popen(
|
decoder_process = subprocess.Popen(
|
||||||
decoder_cmd,
|
decoder_cmd,
|
||||||
stdin=rtl_process.stdout,
|
stdin=rtl_process.stdout,
|
||||||
stdout=PIPE,
|
stdout=PIPE,
|
||||||
stderr=STDOUT,
|
stderr=STDOUT,
|
||||||
text=True,
|
|
||||||
bufsize=1,
|
|
||||||
start_new_session=True
|
start_new_session=True
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1827,7 +1829,8 @@ def start_aprs() -> Response:
|
|||||||
|
|
||||||
if decoder_process.poll() is not None:
|
if decoder_process.poll() is not None:
|
||||||
# Decoder exited early - capture any output
|
# 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'
|
error_msg = f'{decoder_name} failed to start'
|
||||||
if error_output:
|
if error_output:
|
||||||
error_msg += f': {error_output}'
|
error_msg += f': {error_output}'
|
||||||
|
|||||||
1204
routes/morse.py
1204
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 {
|
.control-group select {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: var(--bg-dark);
|
||||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
|
|||||||
@@ -779,7 +779,7 @@ body {
|
|||||||
|
|
||||||
.control-group select {
|
.control-group select {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: var(--bg-dark);
|
||||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
.aprs-strip .strip-select {
|
.aprs-strip .strip-select {
|
||||||
background: rgba(0,0,0,0.3);
|
background: var(--bg-dark);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
|
|||||||
@@ -1,118 +1,201 @@
|
|||||||
/* Morse Code / CW Decoder Styles */
|
/* Morse Code / CW Decoder Styles */
|
||||||
|
|
||||||
/* Scope canvas container */
|
.morse-mode-help,
|
||||||
.morse-scope-container {
|
.morse-help-text {
|
||||||
background: var(--bg-primary);
|
font-size: 11px;
|
||||||
border: 1px solid var(--border-color);
|
color: var(--text-dim);
|
||||||
border-radius: 6px;
|
}
|
||||||
padding: 8px;
|
|
||||||
margin-bottom: 12px;
|
.morse-help-text {
|
||||||
}
|
margin-top: 4px;
|
||||||
|
display: block;
|
||||||
.morse-scope-container canvas {
|
}
|
||||||
width: 100%;
|
|
||||||
height: 80px;
|
.morse-hf-note {
|
||||||
display: block;
|
font-size: 11px;
|
||||||
border-radius: 4px;
|
color: #ffaa00;
|
||||||
}
|
line-height: 1.5;
|
||||||
|
}
|
||||||
/* Decoded text panel */
|
|
||||||
.morse-decoded-panel {
|
.morse-presets {
|
||||||
background: var(--bg-primary);
|
display: flex;
|
||||||
border: 1px solid var(--border-color);
|
flex-wrap: wrap;
|
||||||
border-radius: 6px;
|
gap: 4px;
|
||||||
padding: 12px;
|
}
|
||||||
min-height: 120px;
|
|
||||||
max-height: 400px;
|
.morse-actions-row {
|
||||||
overflow-y: auto;
|
display: flex;
|
||||||
font-family: var(--font-mono);
|
gap: 8px;
|
||||||
font-size: 18px;
|
flex-wrap: wrap;
|
||||||
line-height: 1.6;
|
}
|
||||||
color: var(--text-primary);
|
|
||||||
word-wrap: break-word;
|
.morse-file-row {
|
||||||
}
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
.morse-decoded-panel:empty::before {
|
align-items: center;
|
||||||
content: 'Decoded text will appear here...';
|
flex-wrap: wrap;
|
||||||
color: var(--text-dim);
|
}
|
||||||
font-size: 14px;
|
|
||||||
font-style: italic;
|
.morse-file-row input[type='file'] {
|
||||||
}
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
/* Individual decoded character with fade-in */
|
}
|
||||||
.morse-char {
|
|
||||||
display: inline;
|
.morse-status {
|
||||||
animation: morseFadeIn 0.3s ease-out;
|
display: flex;
|
||||||
position: relative;
|
align-items: center;
|
||||||
}
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
@keyframes morseFadeIn {
|
color: var(--text-dim);
|
||||||
from {
|
}
|
||||||
opacity: 0;
|
|
||||||
color: var(--accent-cyan);
|
.morse-status #morseCharCount {
|
||||||
}
|
margin-left: auto;
|
||||||
to {
|
}
|
||||||
opacity: 1;
|
|
||||||
color: var(--text-primary);
|
.morse-ref-grid {
|
||||||
}
|
transition: max-height 0.3s ease, opacity 0.3s ease;
|
||||||
}
|
max-height: 560px;
|
||||||
|
opacity: 1;
|
||||||
/* Small Morse notation above character */
|
overflow: hidden;
|
||||||
.morse-char-morse {
|
font-family: var(--font-mono);
|
||||||
font-size: 9px;
|
font-size: 10px;
|
||||||
color: var(--text-dim);
|
line-height: 1.8;
|
||||||
letter-spacing: 1px;
|
columns: 2;
|
||||||
display: block;
|
column-gap: 12px;
|
||||||
line-height: 1;
|
color: var(--text-dim);
|
||||||
margin-bottom: -2px;
|
}
|
||||||
}
|
|
||||||
|
.morse-ref-grid.collapsed {
|
||||||
/* Reference grid */
|
max-height: 0;
|
||||||
.morse-ref-grid {
|
opacity: 0;
|
||||||
transition: max-height 0.3s ease, opacity 0.3s ease;
|
}
|
||||||
max-height: 500px;
|
|
||||||
opacity: 1;
|
.morse-ref-toggle {
|
||||||
overflow: hidden;
|
font-size: 10px;
|
||||||
}
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
.morse-ref-grid.collapsed {
|
|
||||||
max-height: 0;
|
.morse-ref-divider {
|
||||||
opacity: 0;
|
margin-top: 4px;
|
||||||
}
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding-top: 4px;
|
||||||
/* Toolbar: export/copy/clear */
|
}
|
||||||
.morse-toolbar {
|
|
||||||
display: flex;
|
.morse-decoded-panel {
|
||||||
gap: 6px;
|
background: var(--bg-primary);
|
||||||
margin-bottom: 8px;
|
border: 1px solid var(--border-color);
|
||||||
flex-wrap: wrap;
|
border-radius: 6px;
|
||||||
}
|
padding: 12px;
|
||||||
|
min-height: 120px;
|
||||||
.morse-toolbar .btn {
|
max-height: 400px;
|
||||||
font-size: 11px;
|
overflow-y: auto;
|
||||||
padding: 4px 10px;
|
font-family: var(--font-mono);
|
||||||
}
|
font-size: 18px;
|
||||||
|
line-height: 1.6;
|
||||||
/* Status bar at bottom */
|
color: var(--text-primary);
|
||||||
.morse-status-bar {
|
word-wrap: break-word;
|
||||||
display: flex;
|
}
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
.morse-decoded-panel:empty::before {
|
||||||
font-size: 11px;
|
content: 'Decoded text will appear here...';
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
padding: 6px 0;
|
font-size: 14px;
|
||||||
border-top: 1px solid var(--border-color);
|
font-style: italic;
|
||||||
margin-top: 8px;
|
}
|
||||||
}
|
|
||||||
|
.morse-char {
|
||||||
.morse-status-bar .status-item {
|
display: inline;
|
||||||
display: flex;
|
animation: morseFadeIn 0.3s ease-out;
|
||||||
align-items: center;
|
position: relative;
|
||||||
gap: 4px;
|
}
|
||||||
}
|
|
||||||
|
@keyframes morseFadeIn {
|
||||||
/* Word space styling */
|
from {
|
||||||
.morse-word-space {
|
opacity: 0;
|
||||||
display: inline;
|
color: var(--accent-cyan);
|
||||||
width: 0.5em;
|
}
|
||||||
}
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.morse-word-space {
|
||||||
|
display: inline;
|
||||||
|
width: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.morse-raw-panel {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #1a1a2e;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #080812;
|
||||||
|
}
|
||||||
|
|
||||||
|
.morse-raw-label {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #667;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.morse-raw-text {
|
||||||
|
min-height: 30px;
|
||||||
|
max-height: 90px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: #8fd0ff;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.morse-metrics-panel {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #7a8694;
|
||||||
|
}
|
||||||
|
|
||||||
|
.morse-metrics-panel span {
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #1a1a2e;
|
||||||
|
background: #080811;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.morse-status-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 6px 0;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
margin-top: 8px;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.morse-status-bar .status-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.morse-metrics-panel {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.morse-file-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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();
|
startCountdownTimer();
|
||||||
checkSchedulerStatus();
|
checkSchedulerStatus();
|
||||||
initGroundMap();
|
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
|
* Select a pass to display in polar plot and map
|
||||||
*/
|
*/
|
||||||
function selectPass(index) {
|
function selectPass(index) {
|
||||||
if (index < 0 || index >= passes.length) return;
|
const filtered = getFilteredPasses();
|
||||||
|
if (index < 0 || index >= filtered.length) return;
|
||||||
selectedPassIndex = index;
|
selectedPassIndex = index;
|
||||||
const pass = passes[index];
|
const pass = filtered[index];
|
||||||
|
|
||||||
// Highlight active card
|
// Highlight active card
|
||||||
document.querySelectorAll('.wxsat-pass-card').forEach((card, i) => {
|
document.querySelectorAll('.wxsat-pass-card').forEach((card, i) => {
|
||||||
@@ -1048,8 +1083,9 @@ const WeatherSat = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSelectedPass() {
|
function getSelectedPass() {
|
||||||
if (selectedPassIndex < 0 || selectedPassIndex >= passes.length) return null;
|
const filtered = getFilteredPasses();
|
||||||
return passes[selectedPassIndex];
|
if (selectedPassIndex < 0 || selectedPassIndex >= filtered.length) return null;
|
||||||
|
return filtered[selectedPassIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSatellitePositionForPass(pass, atTime = new Date()) {
|
function getSatellitePositionForPass(pass, atTime = new Date()) {
|
||||||
@@ -1161,8 +1197,9 @@ const WeatherSat = (function() {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
let nextPass = null;
|
let nextPass = null;
|
||||||
let isActive = false;
|
let isActive = false;
|
||||||
|
const filtered = getFilteredPasses();
|
||||||
|
|
||||||
for (const pass of passes) {
|
for (const pass of filtered) {
|
||||||
const start = parsePassDate(pass.startTimeISO);
|
const start = parsePassDate(pass.startTimeISO);
|
||||||
const end = parsePassDate(pass.endTimeISO);
|
const end = parsePassDate(pass.endTimeISO);
|
||||||
if (!start || !end) {
|
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/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/toast.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/ux-platform.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">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/waterfall.css') }}?v={{ version }}&r=wfdeck19">
|
||||||
<script>
|
<script>
|
||||||
window.INTERCEPT_MODE_STYLE_MAP = {
|
window.INTERCEPT_MODE_STYLE_MAP = {
|
||||||
@@ -81,7 +82,8 @@
|
|||||||
bt_locate: "{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate4",
|
bt_locate: "{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate4",
|
||||||
spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}",
|
spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}",
|
||||||
wefax: "{{ url_for('static', filename='css/modes/wefax.css') }}",
|
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_LOADED = {};
|
||||||
window.INTERCEPT_MODE_STYLE_PROMISES = {};
|
window.INTERCEPT_MODE_STYLE_PROMISES = {};
|
||||||
@@ -704,6 +706,7 @@
|
|||||||
|
|
||||||
{% include 'partials/modes/bt_locate.html' %}
|
{% include 'partials/modes/bt_locate.html' %}
|
||||||
{% include 'partials/modes/waterfall.html' %}
|
{% include 'partials/modes/waterfall.html' %}
|
||||||
|
{% include 'partials/modes/system.html' %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -3085,6 +3088,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Morse Decoded Output -->
|
||||||
<div id="morseOutputPanel" style="display: none; margin-bottom: 12px;">
|
<div id="morseOutputPanel" style="display: none; margin-bottom: 12px;">
|
||||||
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px;">
|
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px;">
|
||||||
@@ -3098,14 +3106,57 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="morseDecodedText" class="morse-decoded-panel"></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">
|
<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="morseStatusBarTone">700 Hz</span>
|
||||||
<span class="status-item" id="morseStatusBarChars">0 chars decoded</span>
|
<span class="status-item" id="morseStatusBarChars">0 chars decoded</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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="output-content signal-feed" id="output">
|
||||||
<div class="placeholder signal-empty-state">
|
<div class="placeholder signal-empty-state">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<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/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/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/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/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/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/keyboard-shortcuts.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/core/cheat-sheets.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' },
|
websdr: { label: 'WebSDR', indicator: 'WEBSDR', outputTitle: 'HF/Shortwave WebSDR', group: 'intel' },
|
||||||
waterfall: { label: 'Waterfall', indicator: 'WATERFALL', outputTitle: 'Spectrum Waterfall', group: 'signals' },
|
waterfall: { label: 'Waterfall', indicator: 'WATERFALL', outputTitle: 'Spectrum Waterfall', group: 'signals' },
|
||||||
morse: { label: 'Morse', indicator: 'MORSE', outputTitle: 'CW/Morse Decoder', 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));
|
const validModes = new Set(Object.keys(modeCatalog));
|
||||||
window.interceptModeCatalog = Object.assign({}, modeCatalog);
|
window.interceptModeCatalog = Object.assign({}, modeCatalog);
|
||||||
@@ -3858,6 +3911,11 @@
|
|||||||
return {
|
return {
|
||||||
pager: Boolean(isRunning),
|
pager: Boolean(isRunning),
|
||||||
sensor: Boolean(isSensorRunning),
|
sensor: Boolean(isSensorRunning),
|
||||||
|
morse: Boolean(
|
||||||
|
typeof MorseMode !== 'undefined'
|
||||||
|
&& typeof MorseMode.isActive === 'function'
|
||||||
|
&& MorseMode.isActive()
|
||||||
|
),
|
||||||
wifi: Boolean(
|
wifi: Boolean(
|
||||||
((typeof WiFiMode !== 'undefined' && typeof WiFiMode.isScanning === 'function' && WiFiMode.isScanning()) || isWifiRunning)
|
((typeof WiFiMode !== 'undefined' && typeof WiFiMode.isScanning === 'function' && WiFiMode.isScanning()) || isWifiRunning)
|
||||||
),
|
),
|
||||||
@@ -3879,6 +3937,12 @@
|
|||||||
if (isSensorRunning && typeof stopSensorDecoding === 'function') {
|
if (isSensorRunning && typeof stopSensorDecoding === 'function') {
|
||||||
Promise.resolve(stopSensorDecoding()).catch(() => { });
|
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 = (
|
const wifiScanActive = (
|
||||||
typeof WiFiMode !== 'undefined'
|
typeof WiFiMode !== 'undefined'
|
||||||
@@ -4004,6 +4068,12 @@
|
|||||||
if (isSensorRunning) {
|
if (isSensorRunning) {
|
||||||
stopTasks.push(awaitStopAction('sensor', () => stopSensorDecoding(), LOCAL_STOP_TIMEOUT_MS));
|
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 = (
|
const wifiScanActive = (
|
||||||
typeof WiFiMode !== 'undefined'
|
typeof WiFiMode !== 'undefined'
|
||||||
&& typeof WiFiMode.isScanning === 'function'
|
&& typeof WiFiMode.isScanning === 'function'
|
||||||
@@ -4040,6 +4110,9 @@
|
|||||||
if (typeof SubGhz !== 'undefined' && currentMode === 'subghz' && mode !== 'subghz') {
|
if (typeof SubGhz !== 'undefined' && currentMode === 'subghz' && mode !== 'subghz') {
|
||||||
SubGhz.destroy();
|
SubGhz.destroy();
|
||||||
}
|
}
|
||||||
|
if (typeof MorseMode !== 'undefined' && currentMode === 'morse' && mode !== 'morse' && typeof MorseMode.destroy === 'function') {
|
||||||
|
MorseMode.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
currentMode = mode;
|
currentMode = mode;
|
||||||
document.body.setAttribute('data-mode', mode);
|
document.body.setAttribute('data-mode', mode);
|
||||||
@@ -4089,6 +4162,7 @@
|
|||||||
document.getElementById('spaceWeatherMode')?.classList.toggle('active', mode === 'spaceweather');
|
document.getElementById('spaceWeatherMode')?.classList.toggle('active', mode === 'spaceweather');
|
||||||
document.getElementById('waterfallMode')?.classList.toggle('active', mode === 'waterfall');
|
document.getElementById('waterfallMode')?.classList.toggle('active', mode === 'waterfall');
|
||||||
document.getElementById('morseMode')?.classList.toggle('active', mode === 'morse');
|
document.getElementById('morseMode')?.classList.toggle('active', mode === 'morse');
|
||||||
|
document.getElementById('systemMode')?.classList.toggle('active', mode === 'system');
|
||||||
|
|
||||||
|
|
||||||
const pagerStats = document.getElementById('pagerStats');
|
const pagerStats = document.getElementById('pagerStats');
|
||||||
@@ -4130,6 +4204,7 @@
|
|||||||
const wefaxVisuals = document.getElementById('wefaxVisuals');
|
const wefaxVisuals = document.getElementById('wefaxVisuals');
|
||||||
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
|
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
|
||||||
const waterfallVisuals = document.getElementById('waterfallVisuals');
|
const waterfallVisuals = document.getElementById('waterfallVisuals');
|
||||||
|
const systemVisuals = document.getElementById('systemVisuals');
|
||||||
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||||
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||||
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
|
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
|
||||||
@@ -4147,6 +4222,7 @@
|
|||||||
if (wefaxVisuals) wefaxVisuals.style.display = mode === 'wefax' ? 'flex' : 'none';
|
if (wefaxVisuals) wefaxVisuals.style.display = mode === 'wefax' ? 'flex' : 'none';
|
||||||
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
|
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
|
||||||
if (waterfallVisuals) waterfallVisuals.style.display = mode === 'waterfall' ? '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.
|
// Prevent Leaflet heatmap redraws on hidden BT Locate map containers.
|
||||||
if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) {
|
if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) {
|
||||||
@@ -4172,6 +4248,8 @@
|
|||||||
const morseOutputPanel = document.getElementById('morseOutputPanel');
|
const morseOutputPanel = document.getElementById('morseOutputPanel');
|
||||||
if (morseScopePanel && mode !== 'morse') morseScopePanel.style.display = 'none';
|
if (morseScopePanel && mode !== 'morse') morseScopePanel.style.display = 'none';
|
||||||
if (morseOutputPanel && mode !== 'morse') morseOutputPanel.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
|
// Update output panel title based on mode
|
||||||
const outputTitle = document.getElementById('outputTitle');
|
const outputTitle = document.getElementById('outputTitle');
|
||||||
@@ -4201,11 +4279,16 @@
|
|||||||
if (typeof WeFax !== 'undefined' && WeFax.destroy) WeFax.destroy();
|
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)
|
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm)
|
||||||
const reconBtn = document.getElementById('reconBtn');
|
const reconBtn = document.getElementById('reconBtn');
|
||||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||||
const reconPanel = document.getElementById('reconPanel');
|
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 (reconPanel) reconPanel.style.display = 'none';
|
||||||
if (reconBtn) reconBtn.style.display = 'none';
|
if (reconBtn) reconBtn.style.display = 'none';
|
||||||
if (intelBtn) intelBtn.style.display = 'none';
|
if (intelBtn) intelBtn.style.display = 'none';
|
||||||
@@ -4256,8 +4339,8 @@
|
|||||||
// Hide output console for modes with their own visualizations
|
// Hide output console for modes with their own visualizations
|
||||||
const outputEl = document.getElementById('output');
|
const outputEl = document.getElementById('output');
|
||||||
const statusBar = document.querySelector('.status-bar');
|
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 (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') ? 'none' : 'flex';
|
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)
|
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
|
||||||
if (mode !== 'meshtastic') {
|
if (mode !== 'meshtastic') {
|
||||||
@@ -4331,6 +4414,8 @@
|
|||||||
if (typeof Waterfall !== 'undefined') Waterfall.init();
|
if (typeof Waterfall !== 'undefined') Waterfall.init();
|
||||||
} else if (mode === 'morse') {
|
} else if (mode === 'morse') {
|
||||||
MorseMode.init();
|
MorseMode.init();
|
||||||
|
} else if (mode === 'system') {
|
||||||
|
SystemHealth.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Destroy Waterfall WebSocket when leaving SDR receiver modes
|
// Destroy Waterfall WebSocket when leaving SDR receiver modes
|
||||||
|
|||||||
@@ -1,98 +1,166 @@
|
|||||||
<!-- MORSE CODE MODE -->
|
<!-- MORSE CODE MODE -->
|
||||||
<div id="morseMode" class="mode-content">
|
<div id="morseMode" class="mode-content">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3>CW/Morse Decoder</h3>
|
<h3>CW/Morse Decoder</h3>
|
||||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
<p class="info-text morse-mode-help">
|
||||||
Decode CW (continuous wave) Morse code from amateur radio HF bands using USB demodulation
|
Decode CW (continuous wave) Morse with USB demod + Goertzel tone detection.
|
||||||
and Goertzel tone detection.
|
Start with 700 Hz tone and 200 Hz bandwidth.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3>Frequency</h3>
|
<h3>Frequency</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Frequency (MHz)</label>
|
<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">
|
||||||
</div>
|
<span class="help-text morse-help-text">Enter CW center frequency in MHz (e.g., 7.030 for 40m).</span>
|
||||||
<div class="form-group">
|
</div>
|
||||||
<label>Band Presets</label>
|
<div class="form-group">
|
||||||
<div class="morse-presets" style="display: flex; flex-wrap: wrap; gap: 4px;">
|
<label>Band Presets</label>
|
||||||
<button class="preset-btn" onclick="MorseMode.setFreq(3.560)">80m</button>
|
<div class="morse-presets">
|
||||||
<button class="preset-btn" onclick="MorseMode.setFreq(7.030)">40m</button>
|
<button class="preset-btn" onclick="MorseMode.setFreq(3.560)">80m</button>
|
||||||
<button class="preset-btn" onclick="MorseMode.setFreq(10.116)">30m</button>
|
<button class="preset-btn" onclick="MorseMode.setFreq(7.030)">40m</button>
|
||||||
<button class="preset-btn" onclick="MorseMode.setFreq(14.060)">20m</button>
|
<button class="preset-btn" onclick="MorseMode.setFreq(10.116)">30m</button>
|
||||||
<button class="preset-btn" onclick="MorseMode.setFreq(18.080)">17m</button>
|
<button class="preset-btn" onclick="MorseMode.setFreq(14.060)">20m</button>
|
||||||
<button class="preset-btn" onclick="MorseMode.setFreq(21.060)">15m</button>
|
<button class="preset-btn" onclick="MorseMode.setFreq(18.080)">17m</button>
|
||||||
<button class="preset-btn" onclick="MorseMode.setFreq(24.910)">12m</button>
|
<button class="preset-btn" onclick="MorseMode.setFreq(21.060)">15m</button>
|
||||||
<button class="preset-btn" onclick="MorseMode.setFreq(28.060)">10m</button>
|
<button class="preset-btn" onclick="MorseMode.setFreq(24.910)">12m</button>
|
||||||
</div>
|
<button class="preset-btn" onclick="MorseMode.setFreq(28.060)">10m</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="section">
|
|
||||||
<h3>Settings</h3>
|
<div class="section">
|
||||||
<div class="form-group">
|
<h3>Device</h3>
|
||||||
<label>Gain (dB)</label>
|
<div class="form-group">
|
||||||
<input type="number" id="morseGain" value="40" step="1" min="0" max="50">
|
<label>Gain (dB)</label>
|
||||||
</div>
|
<input type="number" id="morseGain" value="40" step="1" min="0" max="60">
|
||||||
<div class="form-group">
|
</div>
|
||||||
<label>PPM Correction</label>
|
<div class="form-group">
|
||||||
<input type="number" id="morsePPM" value="0" step="1" min="-100" max="100">
|
<label>PPM Correction</label>
|
||||||
</div>
|
<input type="number" id="morsePPM" value="0" step="1" min="-200" max="200">
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="section">
|
|
||||||
<h3>CW Settings</h3>
|
<div class="section">
|
||||||
<div class="form-group">
|
<h3>CW Detector</h3>
|
||||||
<label>Tone Frequency: <span id="morseToneFreqLabel">700</span> Hz</label>
|
<div class="form-group">
|
||||||
<input type="range" id="morseToneFreq" value="700" min="300" max="1200" step="10"
|
<label>Tone Frequency: <span id="morseToneFreqLabel">700</span> Hz</label>
|
||||||
oninput="document.getElementById('morseToneFreqLabel').textContent = this.value">
|
<input type="range" id="morseToneFreq" value="700" min="300" max="1200" step="10"
|
||||||
</div>
|
oninput="MorseMode.updateToneLabel(this.value)">
|
||||||
<div class="form-group">
|
</div>
|
||||||
<label>Speed: <span id="morseWpmLabel">15</span> WPM</label>
|
<div class="form-group">
|
||||||
<input type="range" id="morseWpm" value="15" min="5" max="50" step="1"
|
<label>Bandwidth</label>
|
||||||
oninput="document.getElementById('morseWpmLabel').textContent = this.value">
|
<select id="morseBandwidth">
|
||||||
</div>
|
<option value="50">50 Hz</option>
|
||||||
</div>
|
<option value="100">100 Hz</option>
|
||||||
|
<option value="200" selected>200 Hz</option>
|
||||||
<!-- Morse Reference -->
|
<option value="400">400 Hz</option>
|
||||||
<div class="section">
|
</select>
|
||||||
<h3 style="cursor: pointer;" onclick="this.parentElement.querySelector('.morse-ref-grid').classList.toggle('collapsed')">
|
</div>
|
||||||
Morse Reference <span style="font-size: 10px; color: var(--text-dim);">(click to toggle)</span>
|
<div class="form-group checkbox-group">
|
||||||
</h3>
|
<label><input type="checkbox" id="morseAutoToneTrack" checked> Auto Tone Track</label>
|
||||||
<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);">
|
<label><input type="checkbox" id="morseToneLock"> Hold Tone Lock</label>
|
||||||
<div>A .-</div><div>B -...</div><div>C -.-.</div><div>D -..</div>
|
</div>
|
||||||
<div>E .</div><div>F ..-.</div><div>G --.</div><div>H ....</div>
|
</div>
|
||||||
<div>I ..</div><div>J .---</div><div>K -.-</div><div>L .-..</div>
|
|
||||||
<div>M --</div><div>N -.</div><div>O ---</div><div>P .--.</div>
|
<div class="section">
|
||||||
<div>Q --.-</div><div>R .-.</div><div>S ...</div><div>T -</div>
|
<h3>Threshold + WPM</h3>
|
||||||
<div>U ..-</div><div>V ...-</div><div>W .--</div><div>X -..-</div>
|
<div class="form-group">
|
||||||
<div>Y -.--</div><div>Z --..</div>
|
<label>Threshold Mode</label>
|
||||||
<div style="margin-top: 4px; border-top: 1px solid var(--border-color); padding-top: 4px;">0 -----</div>
|
<select id="morseThresholdMode" onchange="MorseMode.onThresholdModeChange()">
|
||||||
<div style="margin-top: 4px; border-top: 1px solid var(--border-color); padding-top: 4px;">1 .----</div>
|
<option value="auto" selected>Auto</option>
|
||||||
<div>2 ..---</div><div>3 ...--</div><div>4 ....-</div>
|
<option value="manual">Manual</option>
|
||||||
<div>5 .....</div><div>6 -....</div><div>7 --...</div>
|
</select>
|
||||||
<div>8 ---..</div><div>9 ----.</div>
|
</div>
|
||||||
</div>
|
<div class="form-group" id="morseThresholdAutoRow">
|
||||||
</div>
|
<label>Threshold Multiplier</label>
|
||||||
|
<input type="number" id="morseThresholdMultiplier" value="2.8" min="1.1" max="8" step="0.1">
|
||||||
<!-- Status -->
|
</div>
|
||||||
<div class="section">
|
<div class="form-group" id="morseThresholdOffsetRow">
|
||||||
<div class="morse-status" style="display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim);">
|
<label>Threshold Offset</label>
|
||||||
<span id="morseStatusIndicator" class="status-dot" style="width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim);"></span>
|
<input type="number" id="morseThresholdOffset" value="0" min="0" step="0.1">
|
||||||
<span id="morseStatusText">Standby</span>
|
</div>
|
||||||
<span style="margin-left: auto;" id="morseCharCount">0 chars</span>
|
<div class="form-group" id="morseManualThresholdRow" style="display: none;">
|
||||||
</div>
|
<label>Manual Threshold</label>
|
||||||
</div>
|
<input type="number" id="morseManualThreshold" value="0" min="0" step="0.1">
|
||||||
|
</div>
|
||||||
<!-- HF Antenna Note -->
|
<div class="form-group">
|
||||||
<div class="section">
|
<label>Minimum Signal Gate</label>
|
||||||
<p class="info-text" style="font-size: 11px; color: #ffaa00; line-height: 1.5;">
|
<input type="number" id="morseSignalGate" value="0.05" min="0" max="1" step="0.01">
|
||||||
CW operates on HF bands (1-30 MHz). Requires an HF-capable SDR with direct sampling
|
</div>
|
||||||
or an upconverter, plus an appropriate HF antenna (dipole, end-fed, or random wire).
|
<div class="form-group">
|
||||||
</p>
|
<label>WPM Mode</label>
|
||||||
</div>
|
<select id="morseWpmMode" onchange="MorseMode.onWpmModeChange()">
|
||||||
|
<option value="auto" selected>Auto</option>
|
||||||
<button class="run-btn" id="morseStartBtn" onclick="MorseMode.start()">Start Decoder</button>
|
<option value="manual">Manual</option>
|
||||||
<button class="stop-btn" id="morseStopBtn" onclick="MorseMode.stop()" style="display: none;">Stop Decoder</button>
|
</select>
|
||||||
</div>
|
</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 class="morse-ref-toggle">(click to toggle)</span>
|
||||||
|
</h3>
|
||||||
|
<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>
|
||||||
|
<div>M --</div><div>N -.</div><div>O ---</div><div>P .--.</div>
|
||||||
|
<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 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>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="morse-status">
|
||||||
|
<span id="morseStatusIndicator" class="status-dot"></span>
|
||||||
|
<span id="morseStatusText">Standby</span>
|
||||||
|
<span id="morseCharCount">0 chars</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<button class="run-btn" id="morseStartBtn" onclick="MorseMode.start()">Start Decoder</button>
|
||||||
|
<button class="stop-btn" id="morseStopBtn" onclick="MorseMode.stop()" style="display: none;">Stop Decoder</button>
|
||||||
|
</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>
|
||||||
</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) #}
|
{# Dynamic dashboard button (shown when in satellite mode) #}
|
||||||
<div class="mode-nav-actions">
|
<div class="mode-nav-actions">
|
||||||
<a href="/satellite/dashboard" target="_blank" class="nav-action-btn" id="satelliteDashboardBtn" style="display: none;">
|
<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>') }}
|
{{ 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 #}
|
{# 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>') }}
|
{{ 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>
|
</nav>
|
||||||
|
|
||||||
{# JavaScript stub for pages that don't have switchMode defined #}
|
{# JavaScript stub for pages that don't have switchMode defined #}
|
||||||
|
|||||||
@@ -515,18 +515,16 @@ class TestDSCDecoder:
|
|||||||
assert result == '002320001'
|
assert result == '002320001'
|
||||||
|
|
||||||
def test_decode_mmsi_short_symbols(self, decoder):
|
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])
|
result = decoder._decode_mmsi([1, 2, 3])
|
||||||
assert result == '000000000'
|
assert result is None
|
||||||
|
|
||||||
def test_decode_mmsi_invalid_symbols(self, decoder):
|
def test_decode_mmsi_invalid_symbols(self, decoder):
|
||||||
"""Test MMSI decoding handles invalid symbol values."""
|
"""Test MMSI decoding returns None for out-of-range symbols."""
|
||||||
# Symbols > 99 should be treated as 0
|
# Symbols > 99 should cause decode to fail
|
||||||
symbols = [100, 32, 12, 34, 56]
|
symbols = [100, 32, 12, 34, 56]
|
||||||
result = decoder._decode_mmsi(symbols)
|
result = decoder._decode_mmsi(symbols)
|
||||||
# First symbol (100) becomes 00, padded result "0032123456",
|
assert result is None
|
||||||
# trim leading pad digit -> "032123456"
|
|
||||||
assert result == '032123456'
|
|
||||||
|
|
||||||
def test_decode_position_northeast(self, decoder):
|
def test_decode_position_northeast(self, decoder):
|
||||||
"""Test position decoding for NE quadrant."""
|
"""Test position decoding for NE quadrant."""
|
||||||
@@ -577,8 +575,9 @@ class TestDSCDecoder:
|
|||||||
def test_bits_to_symbol(self, decoder):
|
def test_bits_to_symbol(self, decoder):
|
||||||
"""Test bit to symbol conversion."""
|
"""Test bit to symbol conversion."""
|
||||||
# Symbol value is first 7 bits (LSB first)
|
# Symbol value is first 7 bits (LSB first)
|
||||||
# Value 100 = 0b1100100 -> bits [0,0,1,0,0,1,1, x,x,x]
|
# Value 100 = 0b1100100 -> bits [0,0,1,0,0,1,1] -> 3 ones
|
||||||
bits = [0, 0, 1, 0, 0, 1, 1, 0, 0, 0]
|
# 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)
|
result = decoder._bits_to_symbol(bits)
|
||||||
assert result == 100
|
assert result == 100
|
||||||
|
|
||||||
@@ -588,14 +587,14 @@ class TestDSCDecoder:
|
|||||||
assert result == -1
|
assert result == -1
|
||||||
|
|
||||||
def test_detect_dot_pattern(self, decoder):
|
def test_detect_dot_pattern(self, decoder):
|
||||||
"""Test dot pattern detection."""
|
"""Test dot pattern detection with 200+ alternating bits."""
|
||||||
# Dot pattern is alternating 1010101...
|
# Dot pattern requires at least 200 bits / 100 alternations
|
||||||
decoder.bit_buffer = [1, 0] * 25 # 50 alternating bits
|
decoder.bit_buffer = [1, 0] * 110 # 220 alternating bits
|
||||||
assert decoder._detect_dot_pattern() is True
|
assert decoder._detect_dot_pattern() is True
|
||||||
|
|
||||||
def test_detect_dot_pattern_insufficient(self, decoder):
|
def test_detect_dot_pattern_insufficient(self, decoder):
|
||||||
"""Test dot pattern not detected with insufficient alternations."""
|
"""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
|
assert decoder._detect_dot_pattern() is False
|
||||||
|
|
||||||
def test_detect_dot_pattern_not_alternating(self, decoder):
|
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
|
decoder.bit_buffer = [1, 1, 1, 1, 0, 0, 0, 0] * 5
|
||||||
assert decoder._detect_dot_pattern() is False
|
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:
|
class TestDSCConstants:
|
||||||
"""Tests for DSC constants."""
|
"""Tests for DSC constants."""
|
||||||
@@ -670,3 +747,27 @@ class TestDSCConstants:
|
|||||||
assert DSC_BAUD_RATE == 1200
|
assert DSC_BAUD_RATE == 1200
|
||||||
assert DSC_MARK_FREQ == 2100
|
assert DSC_MARK_FREQ == 2100
|
||||||
assert DSC_SPACE_FREQ == 1300
|
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,393 +1,546 @@
|
|||||||
"""Tests for Morse code decoder (utils/morse.py) and routes."""
|
"""Tests for Morse code decoder pipeline and lifecycle routes."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import io
|
||||||
import queue
|
import math
|
||||||
import struct
|
import os
|
||||||
import threading
|
import queue
|
||||||
|
import struct
|
||||||
import pytest
|
import threading
|
||||||
|
import time
|
||||||
from utils.morse import (
|
import wave
|
||||||
CHAR_TO_MORSE,
|
from collections import Counter
|
||||||
MORSE_TABLE,
|
|
||||||
GoertzelFilter,
|
import pytest
|
||||||
MorseDecoder,
|
|
||||||
morse_decoder_thread,
|
import app as app_module
|
||||||
)
|
import routes.morse as morse_routes
|
||||||
|
from utils.morse import (
|
||||||
# ---------------------------------------------------------------------------
|
CHAR_TO_MORSE,
|
||||||
# Helpers
|
MORSE_TABLE,
|
||||||
# ---------------------------------------------------------------------------
|
GoertzelFilter,
|
||||||
|
MorseDecoder,
|
||||||
def _login_session(client) -> None:
|
decode_morse_wav_file,
|
||||||
"""Mark the Flask test session as authenticated."""
|
morse_decoder_thread,
|
||||||
with client.session_transaction() as sess:
|
)
|
||||||
sess['logged_in'] = True
|
|
||||||
sess['username'] = 'test'
|
|
||||||
sess['role'] = 'admin'
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
def generate_tone(freq: float, duration: float, sample_rate: int = 8000, amplitude: float = 0.8) -> bytes:
|
|
||||||
"""Generate a pure sine wave as 16-bit LE PCM bytes."""
|
def _login_session(client) -> None:
|
||||||
n_samples = int(sample_rate * duration)
|
"""Mark the Flask test session as authenticated."""
|
||||||
samples = []
|
with client.session_transaction() as sess:
|
||||||
for i in range(n_samples):
|
sess['logged_in'] = True
|
||||||
t = i / sample_rate
|
sess['username'] = 'test'
|
||||||
val = int(amplitude * 32767 * math.sin(2 * math.pi * freq * t))
|
sess['role'] = 'admin'
|
||||||
samples.append(max(-32768, min(32767, val)))
|
|
||||||
return struct.pack(f'<{len(samples)}h', *samples)
|
|
||||||
|
def generate_tone(freq: float, duration: float, sample_rate: int = 8000, amplitude: float = 0.8) -> bytes:
|
||||||
|
"""Generate a pure sine wave as 16-bit LE PCM bytes."""
|
||||||
def generate_silence(duration: float, sample_rate: int = 8000) -> bytes:
|
n_samples = int(sample_rate * duration)
|
||||||
"""Generate silence as 16-bit LE PCM bytes."""
|
samples = []
|
||||||
n_samples = int(sample_rate * duration)
|
for i in range(n_samples):
|
||||||
return b'\x00\x00' * n_samples
|
t = i / sample_rate
|
||||||
|
val = int(amplitude * 32767 * math.sin(2 * math.pi * freq * t))
|
||||||
|
samples.append(max(-32768, min(32767, val)))
|
||||||
def generate_morse_audio(text: str, wpm: int = 15, tone_freq: float = 700.0, sample_rate: int = 8000) -> bytes:
|
return struct.pack(f'<{len(samples)}h', *samples)
|
||||||
"""Generate PCM audio for a Morse-encoded string."""
|
|
||||||
dit_dur = 1.2 / wpm
|
|
||||||
dah_dur = 3 * dit_dur
|
def generate_silence(duration: float, sample_rate: int = 8000) -> bytes:
|
||||||
element_gap = dit_dur
|
"""Generate silence as 16-bit LE PCM bytes."""
|
||||||
char_gap = 3 * dit_dur
|
n_samples = int(sample_rate * duration)
|
||||||
word_gap = 7 * dit_dur
|
return b'\x00\x00' * n_samples
|
||||||
|
|
||||||
audio = b''
|
|
||||||
words = text.upper().split()
|
def generate_morse_audio(text: str, wpm: int = 15, tone_freq: float = 700.0, sample_rate: int = 8000) -> bytes:
|
||||||
for wi, word in enumerate(words):
|
"""Generate synthetic CW PCM for the given text."""
|
||||||
for ci, char in enumerate(word):
|
dit_dur = 1.2 / wpm
|
||||||
morse = CHAR_TO_MORSE.get(char)
|
dah_dur = 3 * dit_dur
|
||||||
if morse is None:
|
element_gap = dit_dur
|
||||||
continue
|
char_gap = 3 * dit_dur
|
||||||
for ei, element in enumerate(morse):
|
word_gap = 7 * dit_dur
|
||||||
if element == '.':
|
|
||||||
audio += generate_tone(tone_freq, dit_dur, sample_rate)
|
audio = b''
|
||||||
elif element == '-':
|
words = text.upper().split()
|
||||||
audio += generate_tone(tone_freq, dah_dur, sample_rate)
|
for wi, word in enumerate(words):
|
||||||
if ei < len(morse) - 1:
|
for ci, char in enumerate(word):
|
||||||
audio += generate_silence(element_gap, sample_rate)
|
morse = CHAR_TO_MORSE.get(char)
|
||||||
if ci < len(word) - 1:
|
if morse is None:
|
||||||
audio += generate_silence(char_gap, sample_rate)
|
continue
|
||||||
if wi < len(words) - 1:
|
|
||||||
audio += generate_silence(word_gap, sample_rate)
|
for ei, element in enumerate(morse):
|
||||||
|
if element == '.':
|
||||||
# Add some leading/trailing silence for threshold settling
|
audio += generate_tone(tone_freq, dit_dur, sample_rate)
|
||||||
silence = generate_silence(0.3, sample_rate)
|
elif element == '-':
|
||||||
return silence + audio + silence
|
audio += generate_tone(tone_freq, dah_dur, sample_rate)
|
||||||
|
|
||||||
|
if ei < len(morse) - 1:
|
||||||
# ---------------------------------------------------------------------------
|
audio += generate_silence(element_gap, sample_rate)
|
||||||
# MORSE_TABLE tests
|
|
||||||
# ---------------------------------------------------------------------------
|
if ci < len(word) - 1:
|
||||||
|
audio += generate_silence(char_gap, sample_rate)
|
||||||
class TestMorseTable:
|
|
||||||
def test_all_26_letters_present(self):
|
if wi < len(words) - 1:
|
||||||
chars = set(MORSE_TABLE.values())
|
audio += generate_silence(word_gap, sample_rate)
|
||||||
for letter in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ':
|
|
||||||
assert letter in chars, f"Missing letter: {letter}"
|
# Leading/trailing silence for threshold settling.
|
||||||
|
return generate_silence(0.3, sample_rate) + audio + generate_silence(0.3, sample_rate)
|
||||||
def test_all_10_digits_present(self):
|
|
||||||
chars = set(MORSE_TABLE.values())
|
|
||||||
for digit in '0123456789':
|
def write_wav(path, pcm_bytes: bytes, sample_rate: int = 8000) -> None:
|
||||||
assert digit in chars, f"Missing digit: {digit}"
|
"""Write mono 16-bit PCM bytes to a WAV file."""
|
||||||
|
with wave.open(str(path), 'wb') as wf:
|
||||||
def test_reverse_lookup_consistent(self):
|
wf.setnchannels(1)
|
||||||
for morse, char in MORSE_TABLE.items():
|
wf.setsampwidth(2)
|
||||||
if char in CHAR_TO_MORSE:
|
wf.setframerate(sample_rate)
|
||||||
assert CHAR_TO_MORSE[char] == morse
|
wf.writeframes(pcm_bytes)
|
||||||
|
|
||||||
def test_no_duplicate_morse_codes(self):
|
|
||||||
"""Each morse pattern should map to exactly one character."""
|
def decode_text_from_events(events) -> str:
|
||||||
assert len(MORSE_TABLE) == len(set(MORSE_TABLE.keys()))
|
out = []
|
||||||
|
for ev in events:
|
||||||
|
if ev.get('type') == 'morse_char':
|
||||||
# ---------------------------------------------------------------------------
|
out.append(str(ev.get('char', '')))
|
||||||
# GoertzelFilter tests
|
elif ev.get('type') == 'morse_space':
|
||||||
# ---------------------------------------------------------------------------
|
out.append(' ')
|
||||||
|
return ''.join(out)
|
||||||
class TestGoertzelFilter:
|
|
||||||
def test_detects_target_frequency(self):
|
|
||||||
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160)
|
# ---------------------------------------------------------------------------
|
||||||
# Generate 700 Hz tone
|
# Unit tests
|
||||||
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}"
|
class TestMorseTable:
|
||||||
|
def test_morse_table_contains_letters_and_digits(self):
|
||||||
def test_rejects_off_frequency(self):
|
chars = set(MORSE_TABLE.values())
|
||||||
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160)
|
for ch in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789':
|
||||||
# Generate 1500 Hz tone (well off target)
|
assert ch in chars
|
||||||
samples = [0.8 * math.sin(2 * math.pi * 1500 * i / 8000) for i in range(160)]
|
|
||||||
mag_off = gf.magnitude(samples)
|
def test_round_trip_morse_lookup(self):
|
||||||
|
for morse, char in MORSE_TABLE.items():
|
||||||
# Compare with on-target
|
if char in CHAR_TO_MORSE:
|
||||||
samples_on = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(160)]
|
assert CHAR_TO_MORSE[char] == morse
|
||||||
mag_on = gf.magnitude(samples_on)
|
|
||||||
|
|
||||||
assert mag_on > mag_off * 3, "Target freq should be significantly stronger than off-freq"
|
class TestToneDetector:
|
||||||
|
def test_goertzel_prefers_target_frequency(self):
|
||||||
def test_silence_returns_near_zero(self):
|
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160)
|
||||||
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160)
|
on_tone = [0.8 * math.sin(2 * math.pi * 700.0 * i / 8000.0) for i in range(160)]
|
||||||
samples = [0.0] * 160
|
off_tone = [0.8 * math.sin(2 * math.pi * 1500.0 * i / 8000.0) for i in range(160)]
|
||||||
mag = gf.magnitude(samples)
|
assert gf.magnitude(on_tone) > gf.magnitude(off_tone) * 3.0
|
||||||
assert mag < 0.01, f"Expected near-zero for silence, got {mag}"
|
|
||||||
|
|
||||||
def test_different_block_sizes(self):
|
class TestTimingAndWpmEstimator:
|
||||||
for block_size in [80, 160, 320]:
|
def test_timing_classifier_distinguishes_dit_and_dah(self):
|
||||||
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=block_size)
|
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
|
||||||
samples = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(block_size)]
|
dit = 1.2 / 15.0
|
||||||
mag = gf.magnitude(samples)
|
dah = dit * 3.0
|
||||||
assert mag > 5.0, f"Should detect tone with block_size={block_size}"
|
|
||||||
|
audio = (
|
||||||
|
generate_silence(0.35)
|
||||||
# ---------------------------------------------------------------------------
|
+ generate_tone(700.0, dit)
|
||||||
# MorseDecoder tests
|
+ generate_silence(dit * 1.5)
|
||||||
# ---------------------------------------------------------------------------
|
+ generate_tone(700.0, dah)
|
||||||
|
+ generate_silence(0.35)
|
||||||
class TestMorseDecoder:
|
)
|
||||||
def _make_decoder(self, wpm=15):
|
|
||||||
"""Create decoder with pre-warmed threshold for testing."""
|
events = decoder.process_block(audio)
|
||||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=wpm)
|
events.extend(decoder.flush())
|
||||||
# Warm up noise floor with silence
|
elements = [e['element'] for e in events if e.get('type') == 'morse_element']
|
||||||
silence = generate_silence(0.5)
|
|
||||||
decoder.process_block(silence)
|
assert '.' in elements
|
||||||
# Warm up signal peak with tone
|
assert '-' in elements
|
||||||
tone = generate_tone(700.0, 0.3)
|
|
||||||
decoder.process_block(tone)
|
def test_wpm_estimator_sanity(self):
|
||||||
# More silence to settle
|
target_wpm = 18
|
||||||
silence2 = generate_silence(0.5)
|
audio = generate_morse_audio('PARIS PARIS PARIS', wpm=target_wpm)
|
||||||
decoder.process_block(silence2)
|
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=12, wpm_mode='auto')
|
||||||
# Reset state after warm-up
|
|
||||||
decoder._tone_on = False
|
events = decoder.process_block(audio)
|
||||||
decoder._current_symbol = ''
|
events.extend(decoder.flush())
|
||||||
decoder._tone_blocks = 0
|
|
||||||
decoder._silence_blocks = 0
|
metrics = decoder.get_metrics()
|
||||||
return decoder
|
assert metrics['wpm'] >= 10.0
|
||||||
|
assert metrics['wpm'] <= 35.0
|
||||||
def test_dit_detection(self):
|
|
||||||
"""A single dit should produce a '.' in the symbol buffer."""
|
|
||||||
decoder = self._make_decoder()
|
# ---------------------------------------------------------------------------
|
||||||
dit_dur = 1.2 / 15
|
# Decoder thread tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
# Send a tone burst (dit)
|
|
||||||
tone = generate_tone(700.0, dit_dur)
|
class TestMorseDecoderThread:
|
||||||
decoder.process_block(tone)
|
def test_thread_emits_waiting_heartbeat_on_no_data(self):
|
||||||
|
stop_event = threading.Event()
|
||||||
# Send silence to trigger end of tone
|
output_queue = queue.Queue(maxsize=64)
|
||||||
silence = generate_silence(dit_dur * 2)
|
|
||||||
decoder.process_block(silence)
|
read_fd, write_fd = os.pipe()
|
||||||
|
read_file = os.fdopen(read_fd, 'rb', 0)
|
||||||
# Symbol buffer should have a dot
|
|
||||||
assert '.' in decoder._current_symbol, f"Expected '.' in symbol, got '{decoder._current_symbol}'"
|
worker = threading.Thread(
|
||||||
|
target=morse_decoder_thread,
|
||||||
def test_dah_detection(self):
|
args=(read_file, output_queue, stop_event),
|
||||||
"""A longer tone should produce a '-' in the symbol buffer."""
|
daemon=True,
|
||||||
decoder = self._make_decoder()
|
)
|
||||||
dah_dur = 3 * 1.2 / 15
|
worker.start()
|
||||||
|
|
||||||
tone = generate_tone(700.0, dah_dur)
|
got_waiting = False
|
||||||
decoder.process_block(tone)
|
deadline = time.monotonic() + 3.5
|
||||||
|
while time.monotonic() < deadline:
|
||||||
silence = generate_silence(dah_dur)
|
try:
|
||||||
decoder.process_block(silence)
|
msg = output_queue.get(timeout=0.3)
|
||||||
|
except queue.Empty:
|
||||||
assert '-' in decoder._current_symbol, f"Expected '-' in symbol, got '{decoder._current_symbol}'"
|
continue
|
||||||
|
if msg.get('type') == 'scope' and msg.get('waiting'):
|
||||||
def test_decode_letter_e(self):
|
got_waiting = True
|
||||||
"""E is a single dit - the simplest character."""
|
break
|
||||||
decoder = self._make_decoder()
|
|
||||||
audio = generate_morse_audio('E', wpm=15)
|
stop_event.set()
|
||||||
events = decoder.process_block(audio)
|
os.close(write_fd)
|
||||||
events.extend(decoder.flush())
|
read_file.close()
|
||||||
|
worker.join(timeout=2.0)
|
||||||
chars = [e for e in events if e['type'] == 'morse_char']
|
|
||||||
decoded = ''.join(e['char'] for e in chars)
|
assert got_waiting is True
|
||||||
assert 'E' in decoded, f"Expected 'E' in decoded text, got '{decoded}'"
|
assert not worker.is_alive()
|
||||||
|
|
||||||
def test_decode_letter_t(self):
|
def test_thread_produces_character_events(self):
|
||||||
"""T is a single dah."""
|
stop_event = threading.Event()
|
||||||
decoder = self._make_decoder()
|
output_queue = queue.Queue(maxsize=512)
|
||||||
audio = generate_morse_audio('T', wpm=15)
|
audio = generate_morse_audio('SOS', wpm=15)
|
||||||
events = decoder.process_block(audio)
|
|
||||||
events.extend(decoder.flush())
|
worker = threading.Thread(
|
||||||
|
target=morse_decoder_thread,
|
||||||
chars = [e for e in events if e['type'] == 'morse_char']
|
args=(io.BytesIO(audio), output_queue, stop_event),
|
||||||
decoded = ''.join(e['char'] for e in chars)
|
daemon=True,
|
||||||
assert 'T' in decoded, f"Expected 'T' in decoded text, got '{decoded}'"
|
)
|
||||||
|
worker.start()
|
||||||
def test_word_space_detection(self):
|
worker.join(timeout=4.0)
|
||||||
"""A long silence between words should produce decoded chars with a space."""
|
|
||||||
decoder = self._make_decoder()
|
events = []
|
||||||
dit_dur = 1.2 / 15
|
while not output_queue.empty():
|
||||||
# E = dit
|
events.append(output_queue.get_nowait())
|
||||||
audio = generate_tone(700.0, dit_dur) + generate_silence(7 * dit_dur * 1.5)
|
|
||||||
# T = dah
|
chars = [e for e in events if e.get('type') == 'morse_char']
|
||||||
audio += generate_tone(700.0, 3 * dit_dur) + generate_silence(3 * dit_dur)
|
assert len(chars) >= 1
|
||||||
events = decoder.process_block(audio)
|
|
||||||
events.extend(decoder.flush())
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
spaces = [e for e in events if e['type'] == 'morse_space']
|
# Route lifecycle regression
|
||||||
assert len(spaces) >= 1, "Expected at least one word space"
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_scope_events_generated(self):
|
class TestMorseLifecycleRoutes:
|
||||||
"""Decoder should produce scope events for visualization."""
|
def _reset_route_state(self):
|
||||||
audio = generate_morse_audio('SOS', wpm=15)
|
with app_module.morse_lock:
|
||||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
|
app_module.morse_process = None
|
||||||
|
while not app_module.morse_queue.empty():
|
||||||
events = decoder.process_block(audio)
|
try:
|
||||||
|
app_module.morse_queue.get_nowait()
|
||||||
scope_events = [e for e in events if e['type'] == 'scope']
|
except queue.Empty:
|
||||||
assert len(scope_events) > 0, "Expected scope events"
|
break
|
||||||
# Check scope event structure
|
|
||||||
se = scope_events[0]
|
morse_routes.morse_active_device = None
|
||||||
assert 'amplitudes' in se
|
morse_routes.morse_decoder_worker = None
|
||||||
assert 'threshold' in se
|
morse_routes.morse_stderr_worker = None
|
||||||
assert 'tone_on' in se
|
morse_routes.morse_relay_worker = None
|
||||||
|
morse_routes.morse_stop_event = None
|
||||||
def test_adaptive_threshold_adjusts(self):
|
morse_routes.morse_control_queue = None
|
||||||
"""After processing audio, threshold should be non-zero."""
|
morse_routes.morse_runtime_config = {}
|
||||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
|
morse_routes.morse_last_error = ''
|
||||||
|
morse_routes.morse_state = morse_routes.MORSE_IDLE
|
||||||
# Process some tone + silence
|
morse_routes.morse_state_message = 'Idle'
|
||||||
audio = generate_tone(700.0, 0.3) + generate_silence(0.3)
|
|
||||||
decoder.process_block(audio)
|
def test_start_stop_reaches_idle_and_releases_resources(self, client, monkeypatch):
|
||||||
|
_login_session(client)
|
||||||
assert decoder._threshold > 0, "Threshold should adapt above zero"
|
self._reset_route_state()
|
||||||
|
|
||||||
def test_flush_emits_pending_char(self):
|
released_devices = []
|
||||||
"""flush() should emit any accumulated but not-yet-decoded symbol."""
|
|
||||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
|
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None)
|
||||||
decoder._current_symbol = '.' # Manually set pending dit
|
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx))
|
||||||
events = decoder.flush()
|
|
||||||
assert len(events) == 1
|
class DummyDevice:
|
||||||
assert events[0]['type'] == 'morse_char'
|
sdr_type = morse_routes.SDRType.RTL_SDR
|
||||||
assert events[0]['char'] == 'E'
|
|
||||||
|
class DummyBuilder:
|
||||||
def test_flush_empty_returns_nothing(self):
|
def build_fm_demod_command(self, **kwargs):
|
||||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
|
return ['rtl_fm', '-f', '14060000', '-']
|
||||||
events = decoder.flush()
|
|
||||||
assert events == []
|
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: []))
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# morse_decoder_thread tests
|
pcm = generate_morse_audio('E', wpm=15, sample_rate=22050)
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
class FakeRtlProc:
|
||||||
class TestMorseDecoderThread:
|
def __init__(self, payload: bytes):
|
||||||
def test_thread_stops_on_event(self):
|
self.stdout = io.BytesIO(payload)
|
||||||
"""Thread should exit when stop_event is set."""
|
self.stderr = io.BytesIO(b'')
|
||||||
import io
|
self.returncode = None
|
||||||
# Create a fake stdout that blocks until stop
|
|
||||||
stop = threading.Event()
|
def poll(self):
|
||||||
q = queue.Queue(maxsize=100)
|
return self.returncode
|
||||||
|
|
||||||
# Feed some audio then close
|
def terminate(self):
|
||||||
audio = generate_morse_audio('E', wpm=15)
|
self.returncode = 0
|
||||||
fake_stdout = io.BytesIO(audio)
|
|
||||||
|
def wait(self, timeout=None):
|
||||||
t = threading.Thread(
|
self.returncode = 0
|
||||||
target=morse_decoder_thread,
|
return 0
|
||||||
args=(fake_stdout, q, stop),
|
|
||||||
)
|
def kill(self):
|
||||||
t.daemon = True
|
self.returncode = -9
|
||||||
t.start()
|
|
||||||
t.join(timeout=5)
|
def fake_popen(cmd, *args, **kwargs):
|
||||||
assert not t.is_alive(), "Thread should finish after reading all data"
|
return FakeRtlProc(pcm)
|
||||||
|
|
||||||
def test_thread_produces_events(self):
|
monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen)
|
||||||
"""Thread should push character events to the queue."""
|
monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None)
|
||||||
import io
|
monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None)
|
||||||
from unittest.mock import patch
|
monkeypatch.setattr(
|
||||||
stop = threading.Event()
|
morse_routes,
|
||||||
q = queue.Queue(maxsize=1000)
|
'safe_terminate',
|
||||||
|
lambda proc, timeout=0.0: setattr(proc, 'returncode', 0),
|
||||||
# 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)
|
start_resp = client.post('/morse/start', json={
|
||||||
fake_stdout = io.BytesIO(audio)
|
'frequency': '14.060',
|
||||||
|
'gain': '20',
|
||||||
# Patch SCOPE_INTERVAL to 0 so scope events aren't throttled in fast reads
|
'ppm': '0',
|
||||||
with patch('utils.morse.time') as mock_time:
|
'device': '0',
|
||||||
# Make monotonic() always return increasing values
|
'tone_freq': '700',
|
||||||
counter = [0.0]
|
'wpm': '15',
|
||||||
def fake_monotonic():
|
})
|
||||||
counter[0] += 0.15 # each call advances 150ms
|
assert start_resp.status_code == 200
|
||||||
return counter[0]
|
assert start_resp.get_json()['status'] == 'started'
|
||||||
mock_time.monotonic = fake_monotonic
|
|
||||||
|
status_resp = client.get('/morse/status')
|
||||||
t = threading.Thread(
|
assert status_resp.status_code == 200
|
||||||
target=morse_decoder_thread,
|
assert status_resp.get_json()['state'] in {'running', 'starting', 'stopping', 'idle'}
|
||||||
args=(fake_stdout, q, stop),
|
|
||||||
)
|
stop_resp = client.post('/morse/stop')
|
||||||
t.daemon = True
|
assert stop_resp.status_code == 200
|
||||||
t.start()
|
stop_data = stop_resp.get_json()
|
||||||
t.join(timeout=10)
|
assert stop_data['status'] == 'stopped'
|
||||||
|
assert stop_data['state'] == 'idle'
|
||||||
events = []
|
assert stop_data['alive'] == []
|
||||||
while not q.empty():
|
|
||||||
events.append(q.get_nowait())
|
final_status = client.get('/morse/status').get_json()
|
||||||
|
assert final_status['running'] is False
|
||||||
# Should have at least some events (scope or char)
|
assert final_status['state'] == 'idle'
|
||||||
assert len(events) > 0, "Expected events from thread"
|
assert 0 in released_devices
|
||||||
|
|
||||||
|
def test_start_retries_after_early_process_exit(self, client, monkeypatch):
|
||||||
# ---------------------------------------------------------------------------
|
_login_session(client)
|
||||||
# Route tests
|
self._reset_route_state()
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
released_devices = []
|
||||||
class TestMorseRoutes:
|
|
||||||
def test_start_missing_required_fields(self, client):
|
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None)
|
||||||
"""Start should succeed with defaults."""
|
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx))
|
||||||
_login_session(client)
|
|
||||||
with pytest.MonkeyPatch.context() as m:
|
class DummyDevice:
|
||||||
m.setattr('app.morse_process', None)
|
sdr_type = morse_routes.SDRType.RTL_SDR
|
||||||
# Should fail because rtl_fm won't be found in test env
|
|
||||||
resp = client.post('/morse/start', json={'frequency': '14.060'})
|
class DummyBuilder:
|
||||||
assert resp.status_code in (200, 400, 409, 500)
|
def build_fm_demod_command(self, **kwargs):
|
||||||
|
cmd = ['rtl_fm', '-f', '14.060M', '-M', 'usb', '-s', '22050']
|
||||||
def test_stop_when_not_running(self, client):
|
if kwargs.get('direct_sampling') is not None:
|
||||||
"""Stop when nothing is running should return not_running."""
|
cmd.extend(['--direct', str(kwargs['direct_sampling'])])
|
||||||
_login_session(client)
|
cmd.append('-')
|
||||||
with pytest.MonkeyPatch.context() as m:
|
return cmd
|
||||||
m.setattr('app.morse_process', None)
|
|
||||||
resp = client.post('/morse/stop')
|
monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice()))
|
||||||
data = resp.get_json()
|
monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder()))
|
||||||
assert data['status'] == 'not_running'
|
monkeypatch.setattr(morse_routes.SDRFactory, 'detect_devices', staticmethod(lambda: []))
|
||||||
|
|
||||||
def test_status_when_not_running(self, client):
|
pcm = generate_morse_audio('E', wpm=15, sample_rate=22050)
|
||||||
"""Status should report not running."""
|
rtl_cmds = []
|
||||||
_login_session(client)
|
|
||||||
with pytest.MonkeyPatch.context() as m:
|
class FakeRtlProc:
|
||||||
m.setattr('app.morse_process', None)
|
def __init__(self, stdout_bytes: bytes, returncode: int | None):
|
||||||
resp = client.get('/morse/status')
|
self.stdout = io.BytesIO(stdout_bytes)
|
||||||
data = resp.get_json()
|
self.stderr = io.BytesIO(b'')
|
||||||
assert data['running'] is False
|
self.returncode = returncode
|
||||||
|
|
||||||
def test_invalid_tone_freq(self, client):
|
def poll(self):
|
||||||
"""Tone frequency outside range should be rejected."""
|
return self.returncode
|
||||||
_login_session(client)
|
|
||||||
with pytest.MonkeyPatch.context() as m:
|
def terminate(self):
|
||||||
m.setattr('app.morse_process', None)
|
self.returncode = 0
|
||||||
resp = client.post('/morse/start', json={
|
|
||||||
'frequency': '14.060',
|
def wait(self, timeout=None):
|
||||||
'tone_freq': '50', # too low
|
self.returncode = 0
|
||||||
})
|
return 0
|
||||||
assert resp.status_code == 400
|
|
||||||
|
def kill(self):
|
||||||
def test_invalid_wpm(self, client):
|
self.returncode = -9
|
||||||
"""WPM outside range should be rejected."""
|
|
||||||
_login_session(client)
|
def fake_popen(cmd, *args, **kwargs):
|
||||||
with pytest.MonkeyPatch.context() as m:
|
rtl_cmds.append(cmd)
|
||||||
m.setattr('app.morse_process', None)
|
if len(rtl_cmds) == 1:
|
||||||
resp = client.post('/morse/start', json={
|
return FakeRtlProc(b'', 1)
|
||||||
'frequency': '14.060',
|
return FakeRtlProc(pcm, None)
|
||||||
'wpm': '100', # too high
|
|
||||||
})
|
monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen)
|
||||||
assert resp.status_code == 400
|
monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None)
|
||||||
|
monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None)
|
||||||
def test_stream_endpoint_exists(self, client):
|
monkeypatch.setattr(
|
||||||
"""Stream endpoint should return SSE content type."""
|
morse_routes,
|
||||||
_login_session(client)
|
'safe_terminate',
|
||||||
resp = client.get('/morse/stream')
|
lambda proc, timeout=0.0: setattr(proc, 'returncode', 0),
|
||||||
assert resp.content_type.startswith('text/event-stream')
|
)
|
||||||
|
|
||||||
|
start_resp = client.post('/morse/start', json={
|
||||||
|
'frequency': '14.060',
|
||||||
|
'gain': '20',
|
||||||
|
'ppm': '0',
|
||||||
|
'device': '0',
|
||||||
|
'tone_freq': '700',
|
||||||
|
'wpm': '15',
|
||||||
|
})
|
||||||
|
assert start_resp.status_code == 200
|
||||||
|
assert start_resp.get_json()['status'] == 'started'
|
||||||
|
assert len(rtl_cmds) >= 2
|
||||||
|
assert rtl_cmds[0][0] == 'rtl_fm'
|
||||||
|
assert '--direct' in rtl_cmds[0]
|
||||||
|
assert '2' in rtl_cmds[0]
|
||||||
|
assert rtl_cmds[1][0] == 'rtl_fm'
|
||||||
|
assert '--direct' in rtl_cmds[1]
|
||||||
|
assert '1' in rtl_cmds[1]
|
||||||
|
|
||||||
|
stop_resp = client.post('/morse/stop')
|
||||||
|
assert stop_resp.status_code == 200
|
||||||
|
assert stop_resp.get_json()['status'] == 'stopped'
|
||||||
|
assert 0 in released_devices
|
||||||
|
|
||||||
|
def test_start_falls_back_to_next_device_when_selected_device_has_no_pcm(self, client, monkeypatch):
|
||||||
|
_login_session(client)
|
||||||
|
self._reset_route_state()
|
||||||
|
|
||||||
|
released_devices = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None)
|
||||||
|
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx))
|
||||||
|
|
||||||
|
class DummyDevice:
|
||||||
|
def __init__(self, index: int):
|
||||||
|
self.sdr_type = morse_routes.SDRType.RTL_SDR
|
||||||
|
self.index = index
|
||||||
|
|
||||||
|
class DummyDetected:
|
||||||
|
def __init__(self, index: int, serial: str):
|
||||||
|
self.sdr_type = morse_routes.SDRType.RTL_SDR
|
||||||
|
self.index = index
|
||||||
|
self.name = f'RTL {index}'
|
||||||
|
self.serial = serial
|
||||||
|
|
||||||
|
class DummyBuilder:
|
||||||
|
def build_fm_demod_command(self, **kwargs):
|
||||||
|
cmd = ['rtl_fm', '-d', str(kwargs['device'].index), '-f', '14.060M', '-M', 'usb', '-s', '22050']
|
||||||
|
if kwargs.get('direct_sampling') is not None:
|
||||||
|
cmd.extend(['--direct', str(kwargs['direct_sampling'])])
|
||||||
|
cmd.append('-')
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
morse_routes.SDRFactory,
|
||||||
|
'create_default_device',
|
||||||
|
staticmethod(lambda sdr_type, index: DummyDevice(int(index))),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder()))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
morse_routes.SDRFactory,
|
||||||
|
'detect_devices',
|
||||||
|
staticmethod(lambda: [DummyDetected(0, 'AAA00000'), DummyDetected(1, 'BBB11111')]),
|
||||||
|
)
|
||||||
|
|
||||||
|
pcm = generate_morse_audio('E', wpm=15, sample_rate=22050)
|
||||||
|
|
||||||
|
class FakeRtlProc:
|
||||||
|
def __init__(self, stdout_bytes: bytes, returncode: int | None):
|
||||||
|
self.stdout = io.BytesIO(stdout_bytes)
|
||||||
|
self.stderr = io.BytesIO(b'')
|
||||||
|
self.returncode = returncode
|
||||||
|
|
||||||
|
def poll(self):
|
||||||
|
return self.returncode
|
||||||
|
|
||||||
|
def terminate(self):
|
||||||
|
self.returncode = 0
|
||||||
|
|
||||||
|
def wait(self, timeout=None):
|
||||||
|
self.returncode = 0
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
self.returncode = -9
|
||||||
|
|
||||||
|
def fake_popen(cmd, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
dev = int(cmd[cmd.index('-d') + 1])
|
||||||
|
except Exception:
|
||||||
|
dev = 0
|
||||||
|
if dev == 0:
|
||||||
|
return FakeRtlProc(b'', 1)
|
||||||
|
return FakeRtlProc(pcm, None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen)
|
||||||
|
monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None)
|
||||||
|
monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
morse_routes,
|
||||||
|
'safe_terminate',
|
||||||
|
lambda proc, timeout=0.0: setattr(proc, 'returncode', 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
start_resp = client.post('/morse/start', json={
|
||||||
|
'frequency': '14.060',
|
||||||
|
'gain': '20',
|
||||||
|
'ppm': '0',
|
||||||
|
'device': '0',
|
||||||
|
'tone_freq': '700',
|
||||||
|
'wpm': '15',
|
||||||
|
})
|
||||||
|
assert start_resp.status_code == 200
|
||||||
|
start_data = start_resp.get_json()
|
||||||
|
assert start_data['status'] == 'started'
|
||||||
|
assert start_data['config']['active_device'] == 1
|
||||||
|
assert start_data['config']['device_serial'] == 'BBB11111'
|
||||||
|
assert 0 in released_devices
|
||||||
|
|
||||||
|
stop_resp = client.post('/morse/stop')
|
||||||
|
assert stop_resp.status_code == 200
|
||||||
|
assert stop_resp.get_json()['status'] == 'stopped'
|
||||||
|
assert 1 in released_devices
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Integration: synthetic CW -> WAV decode
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestMorseIntegration:
|
||||||
|
def test_decode_morse_wav_contains_expected_phrase(self, tmp_path):
|
||||||
|
wav_path = tmp_path / 'cq_test_123.wav'
|
||||||
|
pcm = generate_morse_audio('CQ TEST 123', wpm=15, tone_freq=700.0)
|
||||||
|
write_wav(wav_path, pcm, sample_rate=8000)
|
||||||
|
|
||||||
|
result = decode_morse_wav_file(
|
||||||
|
wav_path,
|
||||||
|
sample_rate=8000,
|
||||||
|
tone_freq=700.0,
|
||||||
|
wpm=15,
|
||||||
|
bandwidth_hz=200,
|
||||||
|
auto_tone_track=True,
|
||||||
|
threshold_mode='auto',
|
||||||
|
wpm_mode='auto',
|
||||||
|
min_signal_gate=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
decoded = ' '.join(str(result.get('text', '')).split())
|
||||||
|
assert 'CQ TEST 123' in decoded
|
||||||
|
|
||||||
|
events = result.get('events', [])
|
||||||
|
event_counts = Counter(e.get('type') for e in events)
|
||||||
|
assert event_counts['morse_char'] >= len('CQTEST123')
|
||||||
|
|||||||
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
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import platform
|
||||||
import subprocess
|
import shutil
|
||||||
from typing import Any
|
import subprocess
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.dependencies')
|
logger = logging.getLogger('intercept.dependencies')
|
||||||
|
|
||||||
@@ -17,12 +18,32 @@ def check_tool(name: str) -> bool:
|
|||||||
return get_tool_path(name) is not None
|
return get_tool_path(name) is not None
|
||||||
|
|
||||||
|
|
||||||
def get_tool_path(name: str) -> str | None:
|
def get_tool_path(name: str) -> str | None:
|
||||||
"""Get the full path to a tool, checking standard PATH and extra locations."""
|
"""Get the full path to a tool, checking standard PATH and extra locations."""
|
||||||
# First check standard PATH
|
# Optional explicit override, e.g. INTERCEPT_RTL_FM_PATH=/opt/homebrew/bin/rtl_fm
|
||||||
path = shutil.which(name)
|
env_key = f"INTERCEPT_{name.upper().replace('-', '_')}_PATH"
|
||||||
if path:
|
env_path = os.environ.get(env_key)
|
||||||
return path
|
if env_path and os.path.isfile(env_path) and os.access(env_path, os.X_OK):
|
||||||
|
return env_path
|
||||||
|
|
||||||
|
# Prefer native Homebrew binaries on Apple Silicon to avoid mixing Rosetta
|
||||||
|
# /usr/local tools with arm64 Python/runtime.
|
||||||
|
if platform.system() == 'Darwin':
|
||||||
|
machine = platform.machine().lower()
|
||||||
|
preferred_paths: list[str] = []
|
||||||
|
if machine in {'arm64', 'aarch64'}:
|
||||||
|
preferred_paths.append('/opt/homebrew/bin')
|
||||||
|
preferred_paths.append('/usr/local/bin')
|
||||||
|
|
||||||
|
for base in preferred_paths:
|
||||||
|
full_path = os.path.join(base, name)
|
||||||
|
if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
|
||||||
|
return full_path
|
||||||
|
|
||||||
|
# First check standard PATH
|
||||||
|
path = shutil.which(name)
|
||||||
|
if path:
|
||||||
|
return path
|
||||||
|
|
||||||
# Check additional paths (e.g., /usr/sbin for aircrack-ng on Debian)
|
# Check additional paths (e.g., /usr/sbin for aircrack-ng on Debian)
|
||||||
for extra_path in EXTRA_TOOL_PATHS:
|
for extra_path in EXTRA_TOOL_PATHS:
|
||||||
|
|||||||
@@ -89,6 +89,15 @@ TELECOMMAND_CODES = {
|
|||||||
201: 'POLL_RESPONSE', # Poll response
|
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
|
# DSC Symbol Definitions
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ from .constants import (
|
|||||||
FORMAT_CODES,
|
FORMAT_CODES,
|
||||||
DISTRESS_NATURE_CODES,
|
DISTRESS_NATURE_CODES,
|
||||||
VALID_EOS,
|
VALID_EOS,
|
||||||
|
TELECOMMAND_FORMATS,
|
||||||
|
MIN_SYMBOLS_FOR_FORMAT,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@@ -222,13 +224,14 @@ class DSCDecoder:
|
|||||||
Detect DSC dot pattern for synchronization.
|
Detect DSC dot pattern for synchronization.
|
||||||
|
|
||||||
The dot pattern is at least 200 alternating bits (1010101...).
|
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
|
return False
|
||||||
|
|
||||||
# Check last 40 bits for alternating pattern
|
# Check last 200 bits for alternating pattern
|
||||||
last_bits = self.bit_buffer[-40:]
|
last_bits = self.bit_buffer[-200:]
|
||||||
alternations = 0
|
alternations = 0
|
||||||
|
|
||||||
for i in range(1, len(last_bits)):
|
for i in range(1, len(last_bits)):
|
||||||
@@ -237,7 +240,7 @@ class DSCDecoder:
|
|||||||
else:
|
else:
|
||||||
alternations = 0
|
alternations = 0
|
||||||
|
|
||||||
if alternations >= 20:
|
if alternations >= 100:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
@@ -263,27 +266,37 @@ class DSCDecoder:
|
|||||||
if end <= len(self.message_bits):
|
if end <= len(self.message_bits):
|
||||||
symbol_bits = self.message_bits[start:end]
|
symbol_bits = self.message_bits[start:end]
|
||||||
symbol_value = self._bits_to_symbol(symbol_bits)
|
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)
|
symbols.append(symbol_value)
|
||||||
|
|
||||||
# Strip phasing sequence (RX/DX symbols 120-126) from the
|
# Strip phasing sequence (RX/DX symbols 120-126) from the
|
||||||
# start of the message. Per ITU-R M.493, after the dot pattern
|
# start of the message. Per ITU-R M.493, after the dot pattern
|
||||||
# there are 7 phasing symbols before the format specifier.
|
# 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
|
msg_start = 0
|
||||||
for i, sym in enumerate(symbols):
|
for i, sym in enumerate(symbols):
|
||||||
if 120 <= sym <= 126:
|
if 120 <= sym <= 126:
|
||||||
msg_start = i + 1
|
msg_start = i + 1
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
if msg_start > 7:
|
||||||
|
logger.debug("DSC bad sync: >7 phasing symbols stripped")
|
||||||
|
return None
|
||||||
symbols = symbols[msg_start:]
|
symbols = symbols[msg_start:]
|
||||||
|
|
||||||
if len(symbols) < 5:
|
if len(symbols) < 5:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Look for EOS (End of Sequence) - symbols 117, 122, or 127
|
# 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_found = False
|
||||||
eos_index = -1
|
eos_index = -1
|
||||||
for i, sym in enumerate(symbols):
|
for i, sym in enumerate(symbols):
|
||||||
if sym in VALID_EOS:
|
if sym in VALID_EOS:
|
||||||
|
if i < MIN_SYMBOLS_FOR_FORMAT:
|
||||||
|
continue # Too early — not a real EOS
|
||||||
eos_found = True
|
eos_found = True
|
||||||
eos_index = i
|
eos_index = i
|
||||||
break
|
break
|
||||||
@@ -300,7 +313,9 @@ class DSCDecoder:
|
|||||||
Convert 10 bits to symbol value.
|
Convert 10 bits to symbol value.
|
||||||
|
|
||||||
DSC uses 10-bit symbols: 7 information bits + 3 error bits.
|
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:
|
if len(bits) != 10:
|
||||||
return -1
|
return -1
|
||||||
@@ -311,6 +326,11 @@ class DSCDecoder:
|
|||||||
if bits[i]:
|
if bits[i]:
|
||||||
value |= (1 << 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
|
return value
|
||||||
|
|
||||||
def _decode_symbols(self, symbols: list[int]) -> dict | None:
|
def _decode_symbols(self, symbols: list[int]) -> dict | None:
|
||||||
@@ -356,9 +376,13 @@ class DSCDecoder:
|
|||||||
|
|
||||||
# Decode MMSI from symbols 1-5 (destination/address)
|
# Decode MMSI from symbols 1-5 (destination/address)
|
||||||
dest_mmsi = self._decode_mmsi(symbols[1:6])
|
dest_mmsi = self._decode_mmsi(symbols[1:6])
|
||||||
|
if dest_mmsi is None:
|
||||||
|
return None
|
||||||
|
|
||||||
# Decode self-ID from symbols 6-10 (source)
|
# Decode self-ID from symbols 6-10 (source)
|
||||||
source_mmsi = self._decode_mmsi(symbols[6:11])
|
source_mmsi = self._decode_mmsi(symbols[6:11])
|
||||||
|
if source_mmsi is None:
|
||||||
|
return None
|
||||||
|
|
||||||
message = {
|
message = {
|
||||||
'type': 'dsc',
|
'type': 'dsc',
|
||||||
@@ -387,8 +411,9 @@ class DSCDecoder:
|
|||||||
if position:
|
if position:
|
||||||
message['position'] = position
|
message['position'] = position
|
||||||
|
|
||||||
# Telecommand fields (usually last two before EOS)
|
# Telecommand fields (last two before EOS) — only for formats
|
||||||
if len(remaining) >= 2:
|
# that carry telecommand fields per ITU-R M.493
|
||||||
|
if format_code in TELECOMMAND_FORMATS and len(remaining) >= 2:
|
||||||
message['telecommand1'] = remaining[-2]
|
message['telecommand1'] = remaining[-2]
|
||||||
message['telecommand2'] = remaining[-1]
|
message['telecommand2'] = remaining[-1]
|
||||||
|
|
||||||
@@ -402,20 +427,21 @@ class DSCDecoder:
|
|||||||
logger.warning(f"DSC decode error: {e}")
|
logger.warning(f"DSC decode error: {e}")
|
||||||
return None
|
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.
|
Decode MMSI from 5 DSC symbols.
|
||||||
|
|
||||||
Each symbol represents 2 BCD digits (00-99).
|
Each symbol represents 2 BCD digits (00-99).
|
||||||
5 symbols = 10 digits, but MMSI is 9 digits (first symbol has leading 0).
|
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:
|
if len(symbols) < 5:
|
||||||
return '000000000'
|
return None
|
||||||
|
|
||||||
digits = []
|
digits = []
|
||||||
for sym in symbols:
|
for sym in symbols:
|
||||||
if sym < 0 or sym > 99:
|
if sym < 0 or sym > 99:
|
||||||
sym = 0
|
return None
|
||||||
# Each symbol is 2 BCD digits
|
# Each symbol is 2 BCD digits
|
||||||
digits.append(f'{sym:02d}')
|
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'])
|
msg['priority'] = get_category_priority(msg['category'])
|
||||||
|
|
||||||
# Mark if this is a critical alert
|
# 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
|
return msg
|
||||||
|
|
||||||
|
|||||||
1555
utils/morse.py
1555
utils/morse.py
File diff suppressed because it is too large
Load Diff
@@ -14,16 +14,16 @@ from typing import Optional
|
|||||||
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
|
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
|
||||||
from utils.dependencies import get_tool_path
|
from utils.dependencies import get_tool_path
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.sdr.rtlsdr')
|
logger = logging.getLogger('intercept.sdr.rtlsdr')
|
||||||
|
|
||||||
|
|
||||||
def _rtl_fm_demod_mode(modulation: str) -> str:
|
def _rtl_fm_demod_mode(modulation: str) -> str:
|
||||||
"""Map app/UI modulation names to rtl_fm demod tokens."""
|
"""Map app/UI modulation names to rtl_fm demod tokens."""
|
||||||
mod = str(modulation or '').lower().strip()
|
mod = str(modulation or '').lower().strip()
|
||||||
return 'wbfm' if mod == 'wfm' else mod
|
return 'wbfm' if mod == 'wfm' else mod
|
||||||
|
|
||||||
|
|
||||||
def _get_dump1090_bias_t_flag(dump1090_path: str) -> Optional[str]:
|
def _get_dump1090_bias_t_flag(dump1090_path: str) -> Optional[str]:
|
||||||
"""Detect the correct bias-t flag for the installed dump1090 variant.
|
"""Detect the correct bias-t flag for the installed dump1090 variant.
|
||||||
|
|
||||||
Different dump1090 forks use different flags:
|
Different dump1090 forks use different flags:
|
||||||
@@ -86,22 +86,27 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
|||||||
ppm: Optional[int] = None,
|
ppm: Optional[int] = None,
|
||||||
modulation: str = "fm",
|
modulation: str = "fm",
|
||||||
squelch: Optional[int] = None,
|
squelch: Optional[int] = None,
|
||||||
bias_t: bool = False
|
bias_t: bool = False,
|
||||||
|
direct_sampling: Optional[int] = None,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Build rtl_fm command for FM demodulation.
|
Build rtl_fm command for FM demodulation.
|
||||||
|
|
||||||
Used for pager decoding. Supports local devices and rtl_tcp connections.
|
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'
|
rtl_fm_path = get_tool_path('rtl_fm') or 'rtl_fm'
|
||||||
demod_mode = _rtl_fm_demod_mode(modulation)
|
demod_mode = _rtl_fm_demod_mode(modulation)
|
||||||
cmd = [
|
cmd = [
|
||||||
rtl_fm_path,
|
rtl_fm_path,
|
||||||
'-d', self._get_device_arg(device),
|
'-d', self._get_device_arg(device),
|
||||||
'-f', f'{frequency_mhz}M',
|
'-f', f'{frequency_mhz}M',
|
||||||
'-M', demod_mode,
|
'-M', demod_mode,
|
||||||
'-s', str(sample_rate),
|
'-s', str(sample_rate),
|
||||||
]
|
]
|
||||||
|
|
||||||
if gain is not None and gain > 0:
|
if gain is not None and gain > 0:
|
||||||
cmd.extend(['-g', str(gain)])
|
cmd.extend(['-g', str(gain)])
|
||||||
@@ -112,6 +117,14 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
|||||||
if squelch is not None and squelch > 0:
|
if squelch is not None and squelch > 0:
|
||||||
cmd.extend(['-l', str(squelch)])
|
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:
|
if bias_t:
|
||||||
cmd.extend(['-T'])
|
cmd.extend(['-T'])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user