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:
mitchross
2026-02-26 18:37:09 -05:00
27 changed files with 5771 additions and 1619 deletions

View File

@@ -55,6 +55,32 @@ Support the developer of this open-source project
---
## CW / Morse Decoder Notes
Live backend:
- Uses `rtl_fm` piped into `multimon-ng` (`MORSE_CW`) for real-time decode.
Recommended baseline settings:
- **Tone**: `700 Hz`
- **Bandwidth**: `200 Hz` (use `100 Hz` for crowded bands, `400 Hz` for drifting signals)
- **Threshold Mode**: `Auto`
- **WPM Mode**: `Auto`
Auto Tone Track behavior:
- Continuously measures nearby tone energy around the configured CW pitch.
- Steers the detector toward the strongest valid CW tone when signal-to-noise is sufficient.
- Use **Hold Tone Lock** to freeze tracking once the desired signal is centered.
Troubleshooting (no decode / noisy decode):
- Confirm demod path is **USB/CW-compatible** and frequency is tuned correctly.
- If multiple SDRs are connected and the selected one has no PCM output, Morse startup now auto-tries other detected SDR devices and reports the active device/serial in status logs.
- Match **tone** and **bandwidth** to the actual sidetone/pitch.
- Try **Threshold Auto** first; if needed, switch to manual threshold and recalibrate.
- Use **Reset/Calibrate** after major frequency or band condition changes.
- Raise **Minimum Signal Gate** to suppress random noise keying.
---
## Installation / Debian / Ubuntu / MacOS
**1. Clone and run:**

View File

@@ -44,3 +44,6 @@ cryptography>=41.0.0
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
flask-sock
websocket-client>=1.6.0
# System health monitoring (optional - graceful fallback if unavailable)
psutil>=5.9.0

View File

@@ -30,6 +30,7 @@ def register_blueprints(app):
from .sstv import sstv_bp
from .sstv_general import sstv_general_bp
from .subghz import subghz_bp
from .system import system_bp
from .tscm import init_tscm_state, tscm_bp
from .updater import updater_bp
from .vdl2 import vdl2_bp
@@ -75,6 +76,7 @@ def register_blueprints(app):
app.register_blueprint(signalid_bp) # External signal ID enrichment
app.register_blueprint(wefax_bp) # WeFax HF weather fax decoder
app.register_blueprint(morse_bp) # CW/Morse code decoder
app.register_blueprint(system_bp) # System health monitoring
# Initialize TSCM state with queue and lock from app
import app as app_module

View File

@@ -1462,9 +1462,11 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
try:
app_module.aprs_queue.put({'type': 'status', 'status': 'started'})
# Read line-by-line in text mode. Empty string '' signals EOF.
for line in iter(decoder_process.stdout.readline, ''):
line = line.strip()
# Read line-by-line in binary mode. Empty bytes b'' signals EOF.
# Decode with errors='replace' so corrupted radio bytes (e.g. 0xf7)
# never crash the stream.
for raw in iter(decoder_process.stdout.readline, b''):
line = raw.decode('utf-8', errors='replace').strip()
if not line:
continue
@@ -1784,15 +1786,15 @@ def start_aprs() -> Response:
rtl_stderr_thread.start()
# Start decoder with stdin wired to rtl_fm's stdout.
# Use text mode with line buffering for reliable line-by-line reading.
# Use binary mode to avoid UnicodeDecodeError on raw/corrupted bytes
# from the radio decoder (e.g. 0xf7). Lines are decoded manually
# in stream_aprs_output with errors='replace'.
# Merge stderr into stdout to avoid blocking on unbuffered stderr.
decoder_process = subprocess.Popen(
decoder_cmd,
stdin=rtl_process.stdout,
stdout=PIPE,
stderr=STDOUT,
text=True,
bufsize=1,
start_new_session=True
)
@@ -1827,7 +1829,8 @@ def start_aprs() -> Response:
if decoder_process.poll() is not None:
# Decoder exited early - capture any output
error_output = decoder_process.stdout.read()[:500] if decoder_process.stdout else ''
raw_output = decoder_process.stdout.read()[:500] if decoder_process.stdout else b''
error_output = raw_output.decode('utf-8', errors='replace') if raw_output else ''
error_msg = f'{decoder_name} failed to start'
if error_output:
error_msg += f': {error_output}'

File diff suppressed because it is too large Load Diff

323
routes/system.py Normal file
View 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)})

View File

@@ -1246,7 +1246,7 @@ body {
.control-group select {
padding: 4px 8px;
background: rgba(0, 0, 0, 0.3);
background: var(--bg-dark);
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);

View File

@@ -779,7 +779,7 @@ body {
.control-group select {
padding: 4px 8px;
background: rgba(0, 0, 0, 0.3);
background: var(--bg-dark);
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-cyan);

View File

@@ -60,7 +60,7 @@
gap: 4px;
}
.aprs-strip .strip-select {
background: rgba(0,0,0,0.3);
background: var(--bg-dark);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 4px 8px;

View File

@@ -1,22 +1,87 @@
/* Morse Code / CW Decoder Styles */
/* Scope canvas container */
.morse-scope-container {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px;
margin-bottom: 12px;
.morse-mode-help,
.morse-help-text {
font-size: 11px;
color: var(--text-dim);
}
.morse-scope-container canvas {
width: 100%;
height: 80px;
.morse-help-text {
margin-top: 4px;
display: block;
border-radius: 4px;
}
/* Decoded text panel */
.morse-hf-note {
font-size: 11px;
color: #ffaa00;
line-height: 1.5;
}
.morse-presets {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.morse-actions-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.morse-file-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.morse-file-row input[type='file'] {
width: 100%;
max-width: 100%;
}
.morse-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-dim);
}
.morse-status #morseCharCount {
margin-left: auto;
}
.morse-ref-grid {
transition: max-height 0.3s ease, opacity 0.3s ease;
max-height: 560px;
opacity: 1;
overflow: hidden;
font-family: var(--font-mono);
font-size: 10px;
line-height: 1.8;
columns: 2;
column-gap: 12px;
color: var(--text-dim);
}
.morse-ref-grid.collapsed {
max-height: 0;
opacity: 0;
}
.morse-ref-toggle {
font-size: 10px;
color: var(--text-dim);
}
.morse-ref-divider {
margin-top: 4px;
border-top: 1px solid var(--border-color);
padding-top: 4px;
}
.morse-decoded-panel {
background: var(--bg-primary);
border: 1px solid var(--border-color);
@@ -39,7 +104,6 @@
font-style: italic;
}
/* Individual decoded character with fade-in */
.morse-char {
display: inline;
animation: morseFadeIn 0.3s ease-out;
@@ -57,43 +121,55 @@
}
}
/* Small Morse notation above character */
.morse-char-morse {
font-size: 9px;
color: var(--text-dim);
letter-spacing: 1px;
display: block;
line-height: 1;
margin-bottom: -2px;
.morse-word-space {
display: inline;
width: 0.5em;
}
/* Reference grid */
.morse-ref-grid {
transition: max-height 0.3s ease, opacity 0.3s ease;
max-height: 500px;
opacity: 1;
overflow: hidden;
.morse-raw-panel {
margin-top: 8px;
padding: 8px;
border: 1px solid #1a1a2e;
border-radius: 4px;
background: #080812;
}
.morse-ref-grid.collapsed {
max-height: 0;
opacity: 0;
.morse-raw-label {
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #667;
margin-bottom: 4px;
}
/* Toolbar: export/copy/clear */
.morse-toolbar {
display: flex;
gap: 6px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.morse-toolbar .btn {
.morse-raw-text {
min-height: 30px;
max-height: 90px;
overflow-y: auto;
font-family: var(--font-mono);
font-size: 11px;
padding: 4px 10px;
color: #8fd0ff;
white-space: pre-wrap;
word-break: break-word;
}
.morse-metrics-panel {
margin-top: 8px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
font-size: 10px;
color: #7a8694;
}
.morse-metrics-panel span {
padding: 4px 6px;
border-radius: 4px;
border: 1px solid #1a1a2e;
background: #080811;
font-family: var(--font-mono);
}
/* Status bar at bottom */
.morse-status-bar {
display: flex;
justify-content: space-between;
@@ -103,6 +179,8 @@
padding: 6px 0;
border-top: 1px solid var(--border-color);
margin-top: 8px;
gap: 8px;
flex-wrap: wrap;
}
.morse-status-bar .status-item {
@@ -111,8 +189,13 @@
gap: 4px;
}
/* Word space styling */
.morse-word-space {
display: inline;
width: 0.5em;
@media (max-width: 768px) {
.morse-metrics-panel {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.morse-file-row {
flex-direction: column;
align-items: stretch;
}
}

215
static/css/modes/system.css Normal file
View 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
View 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) + '&deg;C';
if (temp.high) html += ' / ' + Math.round(temp.high) + '&deg;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) + '&deg;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 ? ' &middot; ' : '') +
(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&hellip;';
var cardEl = document.getElementById('sysCardSdr');
if (cardEl) cardEl.innerHTML = '<div class="sys-card-header">SDR Devices</div><div class="sys-card-body">Scanning&hellip;</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 + ' &mdash; ' + (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,
};
})();

View File

@@ -39,6 +39,40 @@ const WeatherSat = (function() {
startCountdownTimer();
checkSchedulerStatus();
initGroundMap();
// Re-filter passes when satellite selection changes
const satSelect = document.getElementById('weatherSatSelect');
if (satSelect) {
satSelect.addEventListener('change', () => {
applyPassFilter();
});
}
}
/**
* Get passes filtered by the currently selected satellite.
*/
function getFilteredPasses() {
const satSelect = document.getElementById('weatherSatSelect');
const selected = satSelect?.value;
if (!selected) return passes;
return passes.filter(p => p.satellite === selected);
}
/**
* Re-render passes, timeline, countdown and polar plot using filtered list.
*/
function applyPassFilter() {
const filtered = getFilteredPasses();
selectedPassIndex = -1;
renderPasses(filtered);
renderTimeline(filtered);
updateCountdownFromPasses();
if (filtered.length > 0) {
selectPass(0);
} else {
updateGroundTrack(null);
}
}
/**
@@ -593,9 +627,10 @@ const WeatherSat = (function() {
* Select a pass to display in polar plot and map
*/
function selectPass(index) {
if (index < 0 || index >= passes.length) return;
const filtered = getFilteredPasses();
if (index < 0 || index >= filtered.length) return;
selectedPassIndex = index;
const pass = passes[index];
const pass = filtered[index];
// Highlight active card
document.querySelectorAll('.wxsat-pass-card').forEach((card, i) => {
@@ -1048,8 +1083,9 @@ const WeatherSat = (function() {
}
function getSelectedPass() {
if (selectedPassIndex < 0 || selectedPassIndex >= passes.length) return null;
return passes[selectedPassIndex];
const filtered = getFilteredPasses();
if (selectedPassIndex < 0 || selectedPassIndex >= filtered.length) return null;
return filtered[selectedPassIndex];
}
function getSatellitePositionForPass(pass, atTime = new Date()) {
@@ -1161,8 +1197,9 @@ const WeatherSat = (function() {
const now = new Date();
let nextPass = null;
let isActive = false;
const filtered = getFilteredPasses();
for (const pass of passes) {
for (const pass of filtered) {
const start = parsePassDate(pass.startTimeISO);
const end = parsePassDate(pass.endTimeISO);
if (!start || !end) {

View File

@@ -66,6 +66,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/function-strip.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/ux-platform.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/components.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/waterfall.css') }}?v={{ version }}&r=wfdeck19">
<script>
window.INTERCEPT_MODE_STYLE_MAP = {
@@ -81,7 +82,8 @@
bt_locate: "{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate4",
spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}",
wefax: "{{ url_for('static', filename='css/modes/wefax.css') }}",
morse: "{{ url_for('static', filename='css/modes/morse.css') }}"
morse: "{{ url_for('static', filename='css/modes/morse.css') }}",
system: "{{ url_for('static', filename='css/modes/system.css') }}"
};
window.INTERCEPT_MODE_STYLE_LOADED = {};
window.INTERCEPT_MODE_STYLE_PROMISES = {};
@@ -704,6 +706,7 @@
{% include 'partials/modes/bt_locate.html' %}
{% include 'partials/modes/waterfall.html' %}
{% include 'partials/modes/system.html' %}
@@ -3085,6 +3088,11 @@
</div>
</div>
<div id="morseDiagLog" style="display: none; margin-bottom: 8px; max-height: 60px; overflow-y: auto;
background: #080812; border: 1px solid #1a1a2e; border-radius: 4px; padding: 4px 8px;
font-family: var(--font-mono); font-size: 10px; color: #556677; line-height: 1.6;">
</div>
<!-- Morse Decoded Output -->
<div id="morseOutputPanel" style="display: none; margin-bottom: 12px;">
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px;">
@@ -3098,14 +3106,57 @@
</div>
</div>
<div id="morseDecodedText" class="morse-decoded-panel"></div>
<div id="morseRawPanel" class="morse-raw-panel" style="display: none;">
<div class="morse-raw-label">Raw Elements</div>
<div id="morseRawText" class="morse-raw-text"></div>
</div>
<div id="morseMetricsPanel" class="morse-metrics-panel">
<span id="morseMetricState">STATE idle</span>
<span id="morseMetricTone">TONE -- Hz</span>
<span id="morseMetricLevel">LEVEL --</span>
<span id="morseMetricThreshold">THRESH --</span>
<span id="morseMetricNoise">NOISE --</span>
<span id="morseMetricStopMs">STOP -- ms</span>
</div>
<div class="morse-status-bar">
<span class="status-item" id="morseStatusBarWpm">15 WPM</span>
<span class="status-item" id="morseStatusBarState">IDLE</span>
<span class="status-item" id="morseStatusBarWpm">-- WPM</span>
<span class="status-item" id="morseStatusBarTone">700 Hz</span>
<span class="status-item" id="morseStatusBarChars">0 chars decoded</span>
</div>
</div>
</div>
<!-- System Health Visuals -->
<div id="systemVisuals" class="sys-visuals-container" style="display: none;">
<div class="sys-dashboard">
<div class="sys-card" id="sysCardCpu">
<div class="sys-card-header">CPU</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</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&hellip;</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&hellip;</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&hellip;</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&hellip;</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&hellip;</span></div>
</div>
</div>
</div>
<div class="output-content signal-feed" id="output">
<div class="placeholder signal-empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
@@ -3175,8 +3226,9 @@
<script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4"></script>
<script src="{{ url_for('static', filename='js/modes/wefax.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/morse.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/morse.js') }}?v={{ version }}&r=morse_iq12"></script>
<script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/system.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=voicefix2"></script>
<script src="{{ url_for('static', filename='js/core/keyboard-shortcuts.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
@@ -3330,6 +3382,7 @@
websdr: { label: 'WebSDR', indicator: 'WEBSDR', outputTitle: 'HF/Shortwave WebSDR', group: 'intel' },
waterfall: { label: 'Waterfall', indicator: 'WATERFALL', outputTitle: 'Spectrum Waterfall', group: 'signals' },
morse: { label: 'Morse', indicator: 'MORSE', outputTitle: 'CW/Morse Decoder', group: 'signals' },
system: { label: 'System', indicator: 'SYSTEM', outputTitle: 'System Health Monitor', group: 'system' },
};
const validModes = new Set(Object.keys(modeCatalog));
window.interceptModeCatalog = Object.assign({}, modeCatalog);
@@ -3858,6 +3911,11 @@
return {
pager: Boolean(isRunning),
sensor: Boolean(isSensorRunning),
morse: Boolean(
typeof MorseMode !== 'undefined'
&& typeof MorseMode.isActive === 'function'
&& MorseMode.isActive()
),
wifi: Boolean(
((typeof WiFiMode !== 'undefined' && typeof WiFiMode.isScanning === 'function' && WiFiMode.isScanning()) || isWifiRunning)
),
@@ -3879,6 +3937,12 @@
if (isSensorRunning && typeof stopSensorDecoding === 'function') {
Promise.resolve(stopSensorDecoding()).catch(() => { });
}
const morseActive = typeof MorseMode !== 'undefined'
&& typeof MorseMode.isActive === 'function'
&& MorseMode.isActive();
if (morseActive && typeof MorseMode.stop === 'function') {
Promise.resolve(MorseMode.stop()).catch(() => { });
}
const wifiScanActive = (
typeof WiFiMode !== 'undefined'
@@ -4004,6 +4068,12 @@
if (isSensorRunning) {
stopTasks.push(awaitStopAction('sensor', () => stopSensorDecoding(), LOCAL_STOP_TIMEOUT_MS));
}
const morseActive = typeof MorseMode !== 'undefined'
&& typeof MorseMode.isActive === 'function'
&& MorseMode.isActive();
if (morseActive && typeof MorseMode.stop === 'function') {
stopTasks.push(awaitStopAction('morse', () => MorseMode.stop(), LOCAL_STOP_TIMEOUT_MS));
}
const wifiScanActive = (
typeof WiFiMode !== 'undefined'
&& typeof WiFiMode.isScanning === 'function'
@@ -4040,6 +4110,9 @@
if (typeof SubGhz !== 'undefined' && currentMode === 'subghz' && mode !== 'subghz') {
SubGhz.destroy();
}
if (typeof MorseMode !== 'undefined' && currentMode === 'morse' && mode !== 'morse' && typeof MorseMode.destroy === 'function') {
MorseMode.destroy();
}
currentMode = mode;
document.body.setAttribute('data-mode', mode);
@@ -4089,6 +4162,7 @@
document.getElementById('spaceWeatherMode')?.classList.toggle('active', mode === 'spaceweather');
document.getElementById('waterfallMode')?.classList.toggle('active', mode === 'waterfall');
document.getElementById('morseMode')?.classList.toggle('active', mode === 'morse');
document.getElementById('systemMode')?.classList.toggle('active', mode === 'system');
const pagerStats = document.getElementById('pagerStats');
@@ -4130,6 +4204,7 @@
const wefaxVisuals = document.getElementById('wefaxVisuals');
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
const waterfallVisuals = document.getElementById('waterfallVisuals');
const systemVisuals = document.getElementById('systemVisuals');
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
@@ -4147,6 +4222,7 @@
if (wefaxVisuals) wefaxVisuals.style.display = mode === 'wefax' ? 'flex' : 'none';
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
if (waterfallVisuals) waterfallVisuals.style.display = mode === 'waterfall' ? 'flex' : 'none';
if (systemVisuals) systemVisuals.style.display = mode === 'system' ? 'flex' : 'none';
// Prevent Leaflet heatmap redraws on hidden BT Locate map containers.
if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) {
@@ -4172,6 +4248,8 @@
const morseOutputPanel = document.getElementById('morseOutputPanel');
if (morseScopePanel && mode !== 'morse') morseScopePanel.style.display = 'none';
if (morseOutputPanel && mode !== 'morse') morseOutputPanel.style.display = 'none';
const morseDiagLog = document.getElementById('morseDiagLog');
if (morseDiagLog && mode !== 'morse') morseDiagLog.style.display = 'none';
// Update output panel title based on mode
const outputTitle = document.getElementById('outputTitle');
@@ -4201,11 +4279,16 @@
if (typeof WeFax !== 'undefined' && WeFax.destroy) WeFax.destroy();
}
// Disconnect System Health SSE when leaving the mode
if (mode !== 'system') {
if (typeof SystemHealth !== 'undefined' && SystemHealth.destroy) SystemHealth.destroy();
}
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm)
const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
const reconPanel = document.getElementById('reconPanel');
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'gps' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall') {
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'gps' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall' || mode === 'system') {
if (reconPanel) reconPanel.style.display = 'none';
if (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none';
@@ -4256,8 +4339,8 @@
// Hide output console for modes with their own visualizations
const outputEl = document.getElementById('output');
const statusBar = document.querySelector('.status-bar');
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall') ? 'none' : 'block';
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall') ? 'none' : 'flex';
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall' || mode === 'morse' || mode === 'system') ? 'none' : 'block';
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall' || mode === 'morse' || mode === 'system') ? 'none' : 'flex';
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
if (mode !== 'meshtastic') {
@@ -4331,6 +4414,8 @@
if (typeof Waterfall !== 'undefined') Waterfall.init();
} else if (mode === 'morse') {
MorseMode.init();
} else if (mode === 'system') {
SystemHealth.init();
}
// Destroy Waterfall WebSocket when leaving SDR receiver modes

View File

@@ -2,9 +2,9 @@
<div id="morseMode" class="mode-content">
<div class="section">
<h3>CW/Morse Decoder</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Decode CW (continuous wave) Morse code from amateur radio HF bands using USB demodulation
and Goertzel tone detection.
<p class="info-text morse-mode-help">
Decode CW (continuous wave) Morse with USB demod + Goertzel tone detection.
Start with 700 Hz tone and 200 Hz bandwidth.
</p>
</div>
@@ -12,11 +12,12 @@
<h3>Frequency</h3>
<div class="form-group">
<label>Frequency (MHz)</label>
<input type="number" id="morseFrequency" value="14.060" step="0.001" min="1" max="30">
<input type="number" id="morseFrequency" value="14.060" step="0.001" min="0.5" max="30" placeholder="e.g., 14.060">
<span class="help-text morse-help-text">Enter CW center frequency in MHz (e.g., 7.030 for 40m).</span>
</div>
<div class="form-group">
<label>Band Presets</label>
<div class="morse-presets" style="display: flex; flex-wrap: wrap; gap: 4px;">
<div class="morse-presets">
<button class="preset-btn" onclick="MorseMode.setFreq(3.560)">80m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(7.030)">40m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(10.116)">30m</button>
@@ -30,37 +31,106 @@
</div>
<div class="section">
<h3>Settings</h3>
<h3>Device</h3>
<div class="form-group">
<label>Gain (dB)</label>
<input type="number" id="morseGain" value="40" step="1" min="0" max="50">
<input type="number" id="morseGain" value="40" step="1" min="0" max="60">
</div>
<div class="form-group">
<label>PPM Correction</label>
<input type="number" id="morsePPM" value="0" step="1" min="-100" max="100">
<input type="number" id="morsePPM" value="0" step="1" min="-200" max="200">
</div>
</div>
<div class="section">
<h3>CW Settings</h3>
<h3>CW Detector</h3>
<div class="form-group">
<label>Tone Frequency: <span id="morseToneFreqLabel">700</span> Hz</label>
<input type="range" id="morseToneFreq" value="700" min="300" max="1200" step="10"
oninput="document.getElementById('morseToneFreqLabel').textContent = this.value">
oninput="MorseMode.updateToneLabel(this.value)">
</div>
<div class="form-group">
<label>Speed: <span id="morseWpmLabel">15</span> WPM</label>
<input type="range" id="morseWpm" value="15" min="5" max="50" step="1"
oninput="document.getElementById('morseWpmLabel').textContent = this.value">
<label>Bandwidth</label>
<select id="morseBandwidth">
<option value="50">50 Hz</option>
<option value="100">100 Hz</option>
<option value="200" selected>200 Hz</option>
<option value="400">400 Hz</option>
</select>
</div>
<div class="form-group checkbox-group">
<label><input type="checkbox" id="morseAutoToneTrack" checked> Auto Tone Track</label>
<label><input type="checkbox" id="morseToneLock"> Hold Tone Lock</label>
</div>
</div>
<!-- Morse Reference -->
<div class="section">
<h3>Threshold + WPM</h3>
<div class="form-group">
<label>Threshold Mode</label>
<select id="morseThresholdMode" onchange="MorseMode.onThresholdModeChange()">
<option value="auto" selected>Auto</option>
<option value="manual">Manual</option>
</select>
</div>
<div class="form-group" id="morseThresholdAutoRow">
<label>Threshold Multiplier</label>
<input type="number" id="morseThresholdMultiplier" value="2.8" min="1.1" max="8" step="0.1">
</div>
<div class="form-group" id="morseThresholdOffsetRow">
<label>Threshold Offset</label>
<input type="number" id="morseThresholdOffset" value="0" min="0" step="0.1">
</div>
<div class="form-group" id="morseManualThresholdRow" style="display: none;">
<label>Manual Threshold</label>
<input type="number" id="morseManualThreshold" value="0" min="0" step="0.1">
</div>
<div class="form-group">
<label>Minimum Signal Gate</label>
<input type="number" id="morseSignalGate" value="0.05" min="0" max="1" step="0.01">
</div>
<div class="form-group">
<label>WPM Mode</label>
<select id="morseWpmMode" onchange="MorseMode.onWpmModeChange()">
<option value="auto" selected>Auto</option>
<option value="manual">Manual</option>
</select>
</div>
<div class="form-group" id="morseWpmManualRow" style="display: none;">
<label>Manual Speed: <span id="morseWpmLabel">15</span> WPM</label>
<input type="range" id="morseWpm" value="15" min="5" max="50" step="1"
oninput="MorseMode.updateWpmLabel(this.value)">
</div>
<div class="form-group checkbox-group">
<label><input type="checkbox" id="morseWpmLock"> Lock WPM Estimator</label>
</div>
</div>
<div class="section">
<h3>Output</h3>
<div class="form-group checkbox-group">
<label><input type="checkbox" id="morseShowRaw" checked> Show Raw Morse</label>
<label><input type="checkbox" id="morseShowDiag"> Show Decoder Logs</label>
</div>
<div class="morse-actions-row">
<button class="btn btn-sm btn-ghost" id="morseCalibrateBtn" onclick="MorseMode.calibrate()">Reset / Calibrate</button>
</div>
</div>
<div class="section">
<h3>Decode WAV File</h3>
<div class="morse-file-row">
<input type="file" id="morseFileInput" accept="audio/wav,.wav">
<button class="btn btn-sm btn-ghost" id="morseDecodeFileBtn" onclick="MorseMode.decodeFile()">Decode File</button>
</div>
<span class="help-text morse-help-text">Runs the same CW decoder pipeline against uploaded WAV audio.</span>
</div>
<div class="section">
<h3 style="cursor: pointer;" onclick="this.parentElement.querySelector('.morse-ref-grid').classList.toggle('collapsed')">
Morse Reference <span style="font-size: 10px; color: var(--text-dim);">(click to toggle)</span>
Morse Reference <span class="morse-ref-toggle">(click to toggle)</span>
</h3>
<div class="morse-ref-grid collapsed" style="font-family: var(--font-mono); font-size: 10px; line-height: 1.8; columns: 2; column-gap: 12px; color: var(--text-dim);">
<div class="morse-ref-grid collapsed">
<div>A .-</div><div>B -...</div><div>C -.-.</div><div>D -..</div>
<div>E .</div><div>F ..-.</div><div>G --.</div><div>H ....</div>
<div>I ..</div><div>J .---</div><div>K -.-</div><div>L .-..</div>
@@ -68,28 +138,26 @@
<div>Q --.-</div><div>R .-.</div><div>S ...</div><div>T -</div>
<div>U ..-</div><div>V ...-</div><div>W .--</div><div>X -..-</div>
<div>Y -.--</div><div>Z --..</div>
<div style="margin-top: 4px; border-top: 1px solid var(--border-color); padding-top: 4px;">0 -----</div>
<div style="margin-top: 4px; border-top: 1px solid var(--border-color); padding-top: 4px;">1 .----</div>
<div class="morse-ref-divider">0 -----</div>
<div class="morse-ref-divider">1 .----</div>
<div>2 ..---</div><div>3 ...--</div><div>4 ....-</div>
<div>5 .....</div><div>6 -....</div><div>7 --...</div>
<div>8 ---..</div><div>9 ----.</div>
</div>
</div>
<!-- Status -->
<div class="section">
<div class="morse-status" style="display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim);">
<span id="morseStatusIndicator" class="status-dot" style="width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim);"></span>
<div class="morse-status">
<span id="morseStatusIndicator" class="status-dot"></span>
<span id="morseStatusText">Standby</span>
<span style="margin-left: auto;" id="morseCharCount">0 chars</span>
<span id="morseCharCount">0 chars</span>
</div>
</div>
<!-- HF Antenna Note -->
<div class="section">
<p class="info-text" style="font-size: 11px; color: #ffaa00; line-height: 1.5;">
CW operates on HF bands (1-30 MHz). Requires an HF-capable SDR with direct sampling
or an upconverter, plus an appropriate HF antenna (dipole, end-fed, or random wire).
<p class="info-text morse-hf-note">
CW on HF (1-30 MHz) requires an HF-capable SDR path (direct sampling or upconverter)
and an appropriate antenna.
</p>
</div>

View 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">--&deg;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&hellip;
</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&hellip;
</div>
</div>
</div>

View File

@@ -140,6 +140,19 @@
</div>
</div>
{# System Group #}
<div class="mode-nav-dropdown" data-group="system">
<button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('system')"{% endif %}>
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></span>
<span class="nav-label">System</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
<div class="mode-nav-dropdown-menu">
{{ mode_item('system', 'Health', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
</div>
</div>
{# Dynamic dashboard button (shown when in satellite mode) #}
<div class="mode-nav-actions">
<a href="/satellite/dashboard" target="_blank" class="nav-action-btn" id="satelliteDashboardBtn" style="display: none;">
@@ -230,6 +243,8 @@
{{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{# New modes #}
{{ mobile_item('waterfall', 'Waterfall', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h4l3-8 3 16 3-8h4"/></svg>') }}
{# System #}
{{ mobile_item('system', 'System', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>') }}
</nav>
{# JavaScript stub for pages that don't have switchMode defined #}

View File

@@ -515,18 +515,16 @@ class TestDSCDecoder:
assert result == '002320001'
def test_decode_mmsi_short_symbols(self, decoder):
"""Test MMSI decoding handles short symbol list."""
"""Test MMSI decoding returns None for short symbol list."""
result = decoder._decode_mmsi([1, 2, 3])
assert result == '000000000'
assert result is None
def test_decode_mmsi_invalid_symbols(self, decoder):
"""Test MMSI decoding handles invalid symbol values."""
# Symbols > 99 should be treated as 0
"""Test MMSI decoding returns None for out-of-range symbols."""
# Symbols > 99 should cause decode to fail
symbols = [100, 32, 12, 34, 56]
result = decoder._decode_mmsi(symbols)
# First symbol (100) becomes 00, padded result "0032123456",
# trim leading pad digit -> "032123456"
assert result == '032123456'
assert result is None
def test_decode_position_northeast(self, decoder):
"""Test position decoding for NE quadrant."""
@@ -577,8 +575,9 @@ class TestDSCDecoder:
def test_bits_to_symbol(self, decoder):
"""Test bit to symbol conversion."""
# Symbol value is first 7 bits (LSB first)
# Value 100 = 0b1100100 -> bits [0,0,1,0,0,1,1, x,x,x]
bits = [0, 0, 1, 0, 0, 1, 1, 0, 0, 0]
# Value 100 = 0b1100100 -> bits [0,0,1,0,0,1,1] -> 3 ones
# Check bits must make total even -> need 1 more one -> [1,0,0]
bits = [0, 0, 1, 0, 0, 1, 1, 1, 0, 0]
result = decoder._bits_to_symbol(bits)
assert result == 100
@@ -588,14 +587,14 @@ class TestDSCDecoder:
assert result == -1
def test_detect_dot_pattern(self, decoder):
"""Test dot pattern detection."""
# Dot pattern is alternating 1010101...
decoder.bit_buffer = [1, 0] * 25 # 50 alternating bits
"""Test dot pattern detection with 200+ alternating bits."""
# Dot pattern requires at least 200 bits / 100 alternations
decoder.bit_buffer = [1, 0] * 110 # 220 alternating bits
assert decoder._detect_dot_pattern() is True
def test_detect_dot_pattern_insufficient(self, decoder):
"""Test dot pattern not detected with insufficient alternations."""
decoder.bit_buffer = [1, 0] * 5 # Only 10 bits
decoder.bit_buffer = [1, 0] * 40 # Only 80 bits, below 200 threshold
assert decoder._detect_dot_pattern() is False
def test_detect_dot_pattern_not_alternating(self, decoder):
@@ -603,6 +602,84 @@ class TestDSCDecoder:
decoder.bit_buffer = [1, 1, 1, 1, 0, 0, 0, 0] * 5
assert decoder._detect_dot_pattern() is False
def test_bounded_phasing_strip(self, decoder):
"""Test that >7 phasing symbols causes decode to return None."""
# Build message bits: 10 phasing symbols (120) + format + data
# Each symbol is 10 bits. Phasing symbol 120 = 0b1111000 LSB first
# 120 in 7 bits LSB-first: 0,0,0,1,1,1,1 + 3 check bits
# 120 = 0b1111000 -> LSB first: 0,0,0,1,1,1,1 -> ones=4 (even) -> check [0,0,0]
phasing_bits = [0, 0, 0, 1, 1, 1, 1, 0, 0, 0] # symbol 120
# 10 phasing symbols (>7 max)
decoder.message_bits = phasing_bits * 10
# Add some non-phasing symbols after (enough for a message)
# Symbol 112 (INDIVIDUAL) = 0b1110000 LSB-first: 0,0,0,0,1,1,1 -> ones=3 (odd) -> need odd check
# For simplicity, just add enough bits for the decoder to attempt
for _ in range(20):
decoder.message_bits.extend([0, 0, 0, 0, 1, 1, 1, 1, 0, 0])
result = decoder._try_decode_message()
assert result is None
def test_eos_minimum_length(self, decoder):
"""Test that EOS found too early in the symbol stream is skipped."""
# Build a message where EOS appears at position 5 (< MIN_SYMBOLS_FOR_FORMAT=12)
# This should not be accepted as a valid message end
# Symbol 127 (EOS) = 0b1111111 LSB-first: 1,1,1,1,1,1,1 -> ones=7 (odd) -> check needs 1 one
# Use a simple approach: create symbols directly via _try_decode_message
# Create 5 normal symbols + EOS at position 5 — should be skipped
# Followed by more symbols and a real EOS at position 15
from utils.dsc.decoder import DSCDecoder
d = DSCDecoder()
# Build symbols manually: we need _try_decode_message to find EOS too early
# Symbol 112 = format code. We'll build 10 bits per symbol.
# Since check bit validation is now active, we need valid check bits.
# Symbol value 10 = 0b0001010 LSB-first: 0,1,0,1,0,0,0, ones=2 (even) -> check [0,0,0]
sym_10 = [0, 1, 0, 1, 0, 0, 0, 0, 0, 0]
# Symbol 127 (EOS) = 0b1111111, ones=7 (odd) -> check needs odd total -> [1,0,0]
sym_eos = [1, 1, 1, 1, 1, 1, 1, 1, 0, 0]
# 5 normal symbols + early EOS (should be skipped) + 8 more normal + real EOS
d.message_bits = sym_10 * 5 + sym_eos + sym_10 * 8 + sym_eos
result = d._try_decode_message()
# The early EOS at index 5 should be skipped; the one at index 14
# is past MIN_SYMBOLS_FOR_FORMAT so it can be accepted.
# But the message content is garbage, so _decode_symbols will likely
# return None for other reasons. The key test: it doesn't return a
# message truncated at position 5.
# Just verify no crash and either None or a valid longer message
# (not truncated at the early EOS)
assert result is None or len(result.get('raw', '')) > 18
def test_bits_to_symbol_check_bit_validation(self, decoder):
"""Test that _bits_to_symbol rejects symbols with invalid check bits."""
# Symbol 100 = 0b1100100 LSB-first: 0,0,1,0,0,1,1
# ones in data = 3, need total even -> check bits need 1 one
# Valid: [0,0,1,0,0,1,1, 1,0,0] -> total ones = 4 (even) -> valid
valid_bits = [0, 0, 1, 0, 0, 1, 1, 1, 0, 0]
assert decoder._bits_to_symbol(valid_bits) == 100
# Invalid: flip one check bit -> total ones = 5 (odd) -> invalid
invalid_bits = [0, 0, 1, 0, 0, 1, 1, 0, 0, 0]
assert decoder._bits_to_symbol(invalid_bits) == -1
def test_safety_is_critical(self):
"""Test that SAFETY category is marked as critical."""
import json
from utils.dsc.parser import parse_dsc_message
raw = json.dumps({
'type': 'dsc',
'format': 123,
'source_mmsi': '232123456',
'category': 'SAFETY',
'timestamp': '2025-01-15T12:00:00Z',
'raw': '123232123456100122',
})
msg = parse_dsc_message(raw)
assert msg is not None
assert msg['is_critical'] is True
class TestDSCConstants:
"""Tests for DSC constants."""
@@ -670,3 +747,27 @@ class TestDSCConstants:
assert DSC_BAUD_RATE == 1200
assert DSC_MARK_FREQ == 2100
assert DSC_SPACE_FREQ == 1300
def test_telecommand_codes_full(self):
"""Test TELECOMMAND_CODES_FULL covers 0-127 range."""
from utils.dsc.constants import TELECOMMAND_CODES_FULL
assert len(TELECOMMAND_CODES_FULL) == 128
# Known codes map correctly
assert TELECOMMAND_CODES_FULL[100] == 'F3E_G3E_ALL'
assert TELECOMMAND_CODES_FULL[107] == 'DISTRESS_ACK'
# Unknown codes map to "UNKNOWN"
assert TELECOMMAND_CODES_FULL[0] == 'UNKNOWN'
assert TELECOMMAND_CODES_FULL[99] == 'UNKNOWN'
def test_telecommand_formats(self):
"""Test TELECOMMAND_FORMATS contains correct format codes."""
from utils.dsc.constants import TELECOMMAND_FORMATS
assert {112, 114, 116, 120, 123} == TELECOMMAND_FORMATS
def test_min_symbols_for_format(self):
"""Test MIN_SYMBOLS_FOR_FORMAT constant."""
from utils.dsc.constants import MIN_SYMBOLS_FOR_FORMAT
assert MIN_SYMBOLS_FOR_FORMAT == 12

View File

@@ -1,22 +1,31 @@
"""Tests for Morse code decoder (utils/morse.py) and routes."""
"""Tests for Morse code decoder pipeline and lifecycle routes."""
from __future__ import annotations
import io
import math
import os
import queue
import struct
import threading
import time
import wave
from collections import Counter
import pytest
import app as app_module
import routes.morse as morse_routes
from utils.morse import (
CHAR_TO_MORSE,
MORSE_TABLE,
GoertzelFilter,
MorseDecoder,
decode_morse_wav_file,
morse_decoder_thread,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -47,7 +56,7 @@ def generate_silence(duration: float, sample_rate: int = 8000) -> bytes:
def generate_morse_audio(text: str, wpm: int = 15, tone_freq: float = 700.0, sample_rate: int = 8000) -> bytes:
"""Generate PCM audio for a Morse-encoded string."""
"""Generate synthetic CW PCM for the given text."""
dit_dur = 1.2 / wpm
dah_dur = 3 * dit_dur
element_gap = dit_dur
@@ -61,333 +70,477 @@ def generate_morse_audio(text: str, wpm: int = 15, tone_freq: float = 700.0, sam
morse = CHAR_TO_MORSE.get(char)
if morse is None:
continue
for ei, element in enumerate(morse):
if element == '.':
audio += generate_tone(tone_freq, dit_dur, sample_rate)
elif element == '-':
audio += generate_tone(tone_freq, dah_dur, sample_rate)
if ei < len(morse) - 1:
audio += generate_silence(element_gap, sample_rate)
if ci < len(word) - 1:
audio += generate_silence(char_gap, sample_rate)
if wi < len(words) - 1:
audio += generate_silence(word_gap, sample_rate)
# Add some leading/trailing silence for threshold settling
silence = generate_silence(0.3, sample_rate)
return silence + audio + silence
# Leading/trailing silence for threshold settling.
return generate_silence(0.3, sample_rate) + audio + generate_silence(0.3, sample_rate)
def write_wav(path, pcm_bytes: bytes, sample_rate: int = 8000) -> None:
"""Write mono 16-bit PCM bytes to a WAV file."""
with wave.open(str(path), 'wb') as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
wf.writeframes(pcm_bytes)
def decode_text_from_events(events) -> str:
out = []
for ev in events:
if ev.get('type') == 'morse_char':
out.append(str(ev.get('char', '')))
elif ev.get('type') == 'morse_space':
out.append(' ')
return ''.join(out)
# ---------------------------------------------------------------------------
# MORSE_TABLE tests
# Unit tests
# ---------------------------------------------------------------------------
class TestMorseTable:
def test_all_26_letters_present(self):
def test_morse_table_contains_letters_and_digits(self):
chars = set(MORSE_TABLE.values())
for letter in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ':
assert letter in chars, f"Missing letter: {letter}"
for ch in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789':
assert ch in chars
def test_all_10_digits_present(self):
chars = set(MORSE_TABLE.values())
for digit in '0123456789':
assert digit in chars, f"Missing digit: {digit}"
def test_reverse_lookup_consistent(self):
def test_round_trip_morse_lookup(self):
for morse, char in MORSE_TABLE.items():
if char in CHAR_TO_MORSE:
assert CHAR_TO_MORSE[char] == morse
def test_no_duplicate_morse_codes(self):
"""Each morse pattern should map to exactly one character."""
assert len(MORSE_TABLE) == len(set(MORSE_TABLE.keys()))
# ---------------------------------------------------------------------------
# GoertzelFilter tests
# ---------------------------------------------------------------------------
class TestGoertzelFilter:
def test_detects_target_frequency(self):
class TestToneDetector:
def test_goertzel_prefers_target_frequency(self):
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160)
# Generate 700 Hz tone
samples = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(160)]
mag = gf.magnitude(samples)
assert mag > 10.0, f"Expected high magnitude for target freq, got {mag}"
def test_rejects_off_frequency(self):
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160)
# Generate 1500 Hz tone (well off target)
samples = [0.8 * math.sin(2 * math.pi * 1500 * i / 8000) for i in range(160)]
mag_off = gf.magnitude(samples)
# Compare with on-target
samples_on = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(160)]
mag_on = gf.magnitude(samples_on)
assert mag_on > mag_off * 3, "Target freq should be significantly stronger than off-freq"
def test_silence_returns_near_zero(self):
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=160)
samples = [0.0] * 160
mag = gf.magnitude(samples)
assert mag < 0.01, f"Expected near-zero for silence, got {mag}"
def test_different_block_sizes(self):
for block_size in [80, 160, 320]:
gf = GoertzelFilter(target_freq=700.0, sample_rate=8000, block_size=block_size)
samples = [0.8 * math.sin(2 * math.pi * 700 * i / 8000) for i in range(block_size)]
mag = gf.magnitude(samples)
assert mag > 5.0, f"Should detect tone with block_size={block_size}"
on_tone = [0.8 * math.sin(2 * math.pi * 700.0 * i / 8000.0) for i in range(160)]
off_tone = [0.8 * math.sin(2 * math.pi * 1500.0 * i / 8000.0) for i in range(160)]
assert gf.magnitude(on_tone) > gf.magnitude(off_tone) * 3.0
# ---------------------------------------------------------------------------
# MorseDecoder tests
# ---------------------------------------------------------------------------
class TestTimingAndWpmEstimator:
def test_timing_classifier_distinguishes_dit_and_dah(self):
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
dit = 1.2 / 15.0
dah = dit * 3.0
class TestMorseDecoder:
def _make_decoder(self, wpm=15):
"""Create decoder with pre-warmed threshold for testing."""
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=wpm)
# Warm up noise floor with silence
silence = generate_silence(0.5)
decoder.process_block(silence)
# Warm up signal peak with tone
tone = generate_tone(700.0, 0.3)
decoder.process_block(tone)
# More silence to settle
silence2 = generate_silence(0.5)
decoder.process_block(silence2)
# Reset state after warm-up
decoder._tone_on = False
decoder._current_symbol = ''
decoder._tone_blocks = 0
decoder._silence_blocks = 0
return decoder
audio = (
generate_silence(0.35)
+ generate_tone(700.0, dit)
+ generate_silence(dit * 1.5)
+ generate_tone(700.0, dah)
+ generate_silence(0.35)
)
def test_dit_detection(self):
"""A single dit should produce a '.' in the symbol buffer."""
decoder = self._make_decoder()
dit_dur = 1.2 / 15
events = decoder.process_block(audio)
events.extend(decoder.flush())
elements = [e['element'] for e in events if e.get('type') == 'morse_element']
# Send a tone burst (dit)
tone = generate_tone(700.0, dit_dur)
decoder.process_block(tone)
assert '.' in elements
assert '-' in elements
# Send silence to trigger end of tone
silence = generate_silence(dit_dur * 2)
decoder.process_block(silence)
def test_wpm_estimator_sanity(self):
target_wpm = 18
audio = generate_morse_audio('PARIS PARIS PARIS', wpm=target_wpm)
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=12, wpm_mode='auto')
# Symbol buffer should have a dot
assert '.' in decoder._current_symbol, f"Expected '.' in symbol, got '{decoder._current_symbol}'"
def test_dah_detection(self):
"""A longer tone should produce a '-' in the symbol buffer."""
decoder = self._make_decoder()
dah_dur = 3 * 1.2 / 15
tone = generate_tone(700.0, dah_dur)
decoder.process_block(tone)
silence = generate_silence(dah_dur)
decoder.process_block(silence)
assert '-' in decoder._current_symbol, f"Expected '-' in symbol, got '{decoder._current_symbol}'"
def test_decode_letter_e(self):
"""E is a single dit - the simplest character."""
decoder = self._make_decoder()
audio = generate_morse_audio('E', wpm=15)
events = decoder.process_block(audio)
events.extend(decoder.flush())
chars = [e for e in events if e['type'] == 'morse_char']
decoded = ''.join(e['char'] for e in chars)
assert 'E' in decoded, f"Expected 'E' in decoded text, got '{decoded}'"
def test_decode_letter_t(self):
"""T is a single dah."""
decoder = self._make_decoder()
audio = generate_morse_audio('T', wpm=15)
events = decoder.process_block(audio)
events.extend(decoder.flush())
chars = [e for e in events if e['type'] == 'morse_char']
decoded = ''.join(e['char'] for e in chars)
assert 'T' in decoded, f"Expected 'T' in decoded text, got '{decoded}'"
def test_word_space_detection(self):
"""A long silence between words should produce decoded chars with a space."""
decoder = self._make_decoder()
dit_dur = 1.2 / 15
# E = dit
audio = generate_tone(700.0, dit_dur) + generate_silence(7 * dit_dur * 1.5)
# T = dah
audio += generate_tone(700.0, 3 * dit_dur) + generate_silence(3 * dit_dur)
events = decoder.process_block(audio)
events.extend(decoder.flush())
spaces = [e for e in events if e['type'] == 'morse_space']
assert len(spaces) >= 1, "Expected at least one word space"
def test_scope_events_generated(self):
"""Decoder should produce scope events for visualization."""
audio = generate_morse_audio('SOS', wpm=15)
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
events = decoder.process_block(audio)
scope_events = [e for e in events if e['type'] == 'scope']
assert len(scope_events) > 0, "Expected scope events"
# Check scope event structure
se = scope_events[0]
assert 'amplitudes' in se
assert 'threshold' in se
assert 'tone_on' in se
def test_adaptive_threshold_adjusts(self):
"""After processing audio, threshold should be non-zero."""
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
# Process some tone + silence
audio = generate_tone(700.0, 0.3) + generate_silence(0.3)
decoder.process_block(audio)
assert decoder._threshold > 0, "Threshold should adapt above zero"
def test_flush_emits_pending_char(self):
"""flush() should emit any accumulated but not-yet-decoded symbol."""
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
decoder._current_symbol = '.' # Manually set pending dit
events = decoder.flush()
assert len(events) == 1
assert events[0]['type'] == 'morse_char'
assert events[0]['char'] == 'E'
def test_flush_empty_returns_nothing(self):
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
events = decoder.flush()
assert events == []
metrics = decoder.get_metrics()
assert metrics['wpm'] >= 10.0
assert metrics['wpm'] <= 35.0
# ---------------------------------------------------------------------------
# morse_decoder_thread tests
# Decoder thread tests
# ---------------------------------------------------------------------------
class TestMorseDecoderThread:
def test_thread_stops_on_event(self):
"""Thread should exit when stop_event is set."""
import io
# Create a fake stdout that blocks until stop
stop = threading.Event()
q = queue.Queue(maxsize=100)
def test_thread_emits_waiting_heartbeat_on_no_data(self):
stop_event = threading.Event()
output_queue = queue.Queue(maxsize=64)
# Feed some audio then close
audio = generate_morse_audio('E', wpm=15)
fake_stdout = io.BytesIO(audio)
read_fd, write_fd = os.pipe()
read_file = os.fdopen(read_fd, 'rb', 0)
t = threading.Thread(
worker = threading.Thread(
target=morse_decoder_thread,
args=(fake_stdout, q, stop),
args=(read_file, output_queue, stop_event),
daemon=True,
)
t.daemon = True
t.start()
t.join(timeout=5)
assert not t.is_alive(), "Thread should finish after reading all data"
worker.start()
def test_thread_produces_events(self):
"""Thread should push character events to the queue."""
import io
from unittest.mock import patch
stop = threading.Event()
q = queue.Queue(maxsize=1000)
got_waiting = False
deadline = time.monotonic() + 3.5
while time.monotonic() < deadline:
try:
msg = output_queue.get(timeout=0.3)
except queue.Empty:
continue
if msg.get('type') == 'scope' and msg.get('waiting'):
got_waiting = True
break
# Generate audio with pre-warmed decoder in mind
# The thread creates a fresh decoder, so generate lots of audio
audio = generate_silence(0.5) + generate_morse_audio('SOS', wpm=10) + generate_silence(1.0)
fake_stdout = io.BytesIO(audio)
stop_event.set()
os.close(write_fd)
read_file.close()
worker.join(timeout=2.0)
# Patch SCOPE_INTERVAL to 0 so scope events aren't throttled in fast reads
with patch('utils.morse.time') as mock_time:
# Make monotonic() always return increasing values
counter = [0.0]
def fake_monotonic():
counter[0] += 0.15 # each call advances 150ms
return counter[0]
mock_time.monotonic = fake_monotonic
assert got_waiting is True
assert not worker.is_alive()
t = threading.Thread(
def test_thread_produces_character_events(self):
stop_event = threading.Event()
output_queue = queue.Queue(maxsize=512)
audio = generate_morse_audio('SOS', wpm=15)
worker = threading.Thread(
target=morse_decoder_thread,
args=(fake_stdout, q, stop),
args=(io.BytesIO(audio), output_queue, stop_event),
daemon=True,
)
t.daemon = True
t.start()
t.join(timeout=10)
worker.start()
worker.join(timeout=4.0)
events = []
while not q.empty():
events.append(q.get_nowait())
while not output_queue.empty():
events.append(output_queue.get_nowait())
# Should have at least some events (scope or char)
assert len(events) > 0, "Expected events from thread"
chars = [e for e in events if e.get('type') == 'morse_char']
assert len(chars) >= 1
# ---------------------------------------------------------------------------
# Route tests
# Route lifecycle regression
# ---------------------------------------------------------------------------
class TestMorseRoutes:
def test_start_missing_required_fields(self, client):
"""Start should succeed with defaults."""
_login_session(client)
with pytest.MonkeyPatch.context() as m:
m.setattr('app.morse_process', None)
# Should fail because rtl_fm won't be found in test env
resp = client.post('/morse/start', json={'frequency': '14.060'})
assert resp.status_code in (200, 400, 409, 500)
class TestMorseLifecycleRoutes:
def _reset_route_state(self):
with app_module.morse_lock:
app_module.morse_process = None
while not app_module.morse_queue.empty():
try:
app_module.morse_queue.get_nowait()
except queue.Empty:
break
def test_stop_when_not_running(self, client):
"""Stop when nothing is running should return not_running."""
_login_session(client)
with pytest.MonkeyPatch.context() as m:
m.setattr('app.morse_process', None)
resp = client.post('/morse/stop')
data = resp.get_json()
assert data['status'] == 'not_running'
morse_routes.morse_active_device = None
morse_routes.morse_decoder_worker = None
morse_routes.morse_stderr_worker = None
morse_routes.morse_relay_worker = None
morse_routes.morse_stop_event = None
morse_routes.morse_control_queue = None
morse_routes.morse_runtime_config = {}
morse_routes.morse_last_error = ''
morse_routes.morse_state = morse_routes.MORSE_IDLE
morse_routes.morse_state_message = 'Idle'
def test_status_when_not_running(self, client):
"""Status should report not running."""
def test_start_stop_reaches_idle_and_releases_resources(self, client, monkeypatch):
_login_session(client)
with pytest.MonkeyPatch.context() as m:
m.setattr('app.morse_process', None)
resp = client.get('/morse/status')
data = resp.get_json()
assert data['running'] is False
self._reset_route_state()
def test_invalid_tone_freq(self, client):
"""Tone frequency outside range should be rejected."""
_login_session(client)
with pytest.MonkeyPatch.context() as m:
m.setattr('app.morse_process', None)
resp = client.post('/morse/start', json={
released_devices = []
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None)
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx))
class DummyDevice:
sdr_type = morse_routes.SDRType.RTL_SDR
class DummyBuilder:
def build_fm_demod_command(self, **kwargs):
return ['rtl_fm', '-f', '14060000', '-']
monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice()))
monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder()))
monkeypatch.setattr(morse_routes.SDRFactory, 'detect_devices', staticmethod(lambda: []))
pcm = generate_morse_audio('E', wpm=15, sample_rate=22050)
class FakeRtlProc:
def __init__(self, payload: bytes):
self.stdout = io.BytesIO(payload)
self.stderr = io.BytesIO(b'')
self.returncode = None
def poll(self):
return self.returncode
def terminate(self):
self.returncode = 0
def wait(self, timeout=None):
self.returncode = 0
return 0
def kill(self):
self.returncode = -9
def fake_popen(cmd, *args, **kwargs):
return FakeRtlProc(pcm)
monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen)
monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None)
monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None)
monkeypatch.setattr(
morse_routes,
'safe_terminate',
lambda proc, timeout=0.0: setattr(proc, 'returncode', 0),
)
start_resp = client.post('/morse/start', json={
'frequency': '14.060',
'tone_freq': '50', # too low
'gain': '20',
'ppm': '0',
'device': '0',
'tone_freq': '700',
'wpm': '15',
})
assert resp.status_code == 400
assert start_resp.status_code == 200
assert start_resp.get_json()['status'] == 'started'
def test_invalid_wpm(self, client):
"""WPM outside range should be rejected."""
status_resp = client.get('/morse/status')
assert status_resp.status_code == 200
assert status_resp.get_json()['state'] in {'running', 'starting', 'stopping', 'idle'}
stop_resp = client.post('/morse/stop')
assert stop_resp.status_code == 200
stop_data = stop_resp.get_json()
assert stop_data['status'] == 'stopped'
assert stop_data['state'] == 'idle'
assert stop_data['alive'] == []
final_status = client.get('/morse/status').get_json()
assert final_status['running'] is False
assert final_status['state'] == 'idle'
assert 0 in released_devices
def test_start_retries_after_early_process_exit(self, client, monkeypatch):
_login_session(client)
with pytest.MonkeyPatch.context() as m:
m.setattr('app.morse_process', None)
resp = client.post('/morse/start', json={
self._reset_route_state()
released_devices = []
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None)
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx))
class DummyDevice:
sdr_type = morse_routes.SDRType.RTL_SDR
class DummyBuilder:
def build_fm_demod_command(self, **kwargs):
cmd = ['rtl_fm', '-f', '14.060M', '-M', 'usb', '-s', '22050']
if kwargs.get('direct_sampling') is not None:
cmd.extend(['--direct', str(kwargs['direct_sampling'])])
cmd.append('-')
return cmd
monkeypatch.setattr(morse_routes.SDRFactory, 'create_default_device', staticmethod(lambda sdr_type, index: DummyDevice()))
monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder()))
monkeypatch.setattr(morse_routes.SDRFactory, 'detect_devices', staticmethod(lambda: []))
pcm = generate_morse_audio('E', wpm=15, sample_rate=22050)
rtl_cmds = []
class FakeRtlProc:
def __init__(self, stdout_bytes: bytes, returncode: int | None):
self.stdout = io.BytesIO(stdout_bytes)
self.stderr = io.BytesIO(b'')
self.returncode = returncode
def poll(self):
return self.returncode
def terminate(self):
self.returncode = 0
def wait(self, timeout=None):
self.returncode = 0
return 0
def kill(self):
self.returncode = -9
def fake_popen(cmd, *args, **kwargs):
rtl_cmds.append(cmd)
if len(rtl_cmds) == 1:
return FakeRtlProc(b'', 1)
return FakeRtlProc(pcm, None)
monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen)
monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None)
monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None)
monkeypatch.setattr(
morse_routes,
'safe_terminate',
lambda proc, timeout=0.0: setattr(proc, 'returncode', 0),
)
start_resp = client.post('/morse/start', json={
'frequency': '14.060',
'wpm': '100', # too high
'gain': '20',
'ppm': '0',
'device': '0',
'tone_freq': '700',
'wpm': '15',
})
assert resp.status_code == 400
assert start_resp.status_code == 200
assert start_resp.get_json()['status'] == 'started'
assert len(rtl_cmds) >= 2
assert rtl_cmds[0][0] == 'rtl_fm'
assert '--direct' in rtl_cmds[0]
assert '2' in rtl_cmds[0]
assert rtl_cmds[1][0] == 'rtl_fm'
assert '--direct' in rtl_cmds[1]
assert '1' in rtl_cmds[1]
def test_stream_endpoint_exists(self, client):
"""Stream endpoint should return SSE content type."""
stop_resp = client.post('/morse/stop')
assert stop_resp.status_code == 200
assert stop_resp.get_json()['status'] == 'stopped'
assert 0 in released_devices
def test_start_falls_back_to_next_device_when_selected_device_has_no_pcm(self, client, monkeypatch):
_login_session(client)
resp = client.get('/morse/stream')
assert resp.content_type.startswith('text/event-stream')
self._reset_route_state()
released_devices = []
monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode: None)
monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx: released_devices.append(idx))
class DummyDevice:
def __init__(self, index: int):
self.sdr_type = morse_routes.SDRType.RTL_SDR
self.index = index
class DummyDetected:
def __init__(self, index: int, serial: str):
self.sdr_type = morse_routes.SDRType.RTL_SDR
self.index = index
self.name = f'RTL {index}'
self.serial = serial
class DummyBuilder:
def build_fm_demod_command(self, **kwargs):
cmd = ['rtl_fm', '-d', str(kwargs['device'].index), '-f', '14.060M', '-M', 'usb', '-s', '22050']
if kwargs.get('direct_sampling') is not None:
cmd.extend(['--direct', str(kwargs['direct_sampling'])])
cmd.append('-')
return cmd
monkeypatch.setattr(
morse_routes.SDRFactory,
'create_default_device',
staticmethod(lambda sdr_type, index: DummyDevice(int(index))),
)
monkeypatch.setattr(morse_routes.SDRFactory, 'get_builder', staticmethod(lambda sdr_type: DummyBuilder()))
monkeypatch.setattr(
morse_routes.SDRFactory,
'detect_devices',
staticmethod(lambda: [DummyDetected(0, 'AAA00000'), DummyDetected(1, 'BBB11111')]),
)
pcm = generate_morse_audio('E', wpm=15, sample_rate=22050)
class FakeRtlProc:
def __init__(self, stdout_bytes: bytes, returncode: int | None):
self.stdout = io.BytesIO(stdout_bytes)
self.stderr = io.BytesIO(b'')
self.returncode = returncode
def poll(self):
return self.returncode
def terminate(self):
self.returncode = 0
def wait(self, timeout=None):
self.returncode = 0
return 0
def kill(self):
self.returncode = -9
def fake_popen(cmd, *args, **kwargs):
try:
dev = int(cmd[cmd.index('-d') + 1])
except Exception:
dev = 0
if dev == 0:
return FakeRtlProc(b'', 1)
return FakeRtlProc(pcm, None)
monkeypatch.setattr(morse_routes.subprocess, 'Popen', fake_popen)
monkeypatch.setattr(morse_routes, 'register_process', lambda _proc: None)
monkeypatch.setattr(morse_routes, 'unregister_process', lambda _proc: None)
monkeypatch.setattr(
morse_routes,
'safe_terminate',
lambda proc, timeout=0.0: setattr(proc, 'returncode', 0),
)
start_resp = client.post('/morse/start', json={
'frequency': '14.060',
'gain': '20',
'ppm': '0',
'device': '0',
'tone_freq': '700',
'wpm': '15',
})
assert start_resp.status_code == 200
start_data = start_resp.get_json()
assert start_data['status'] == 'started'
assert start_data['config']['active_device'] == 1
assert start_data['config']['device_serial'] == 'BBB11111'
assert 0 in released_devices
stop_resp = client.post('/morse/stop')
assert stop_resp.status_code == 200
assert stop_resp.get_json()['status'] == 'stopped'
assert 1 in released_devices
# ---------------------------------------------------------------------------
# Integration: synthetic CW -> WAV decode
# ---------------------------------------------------------------------------
class TestMorseIntegration:
def test_decode_morse_wav_contains_expected_phrase(self, tmp_path):
wav_path = tmp_path / 'cq_test_123.wav'
pcm = generate_morse_audio('CQ TEST 123', wpm=15, tone_freq=700.0)
write_wav(wav_path, pcm, sample_rate=8000)
result = decode_morse_wav_file(
wav_path,
sample_rate=8000,
tone_freq=700.0,
wpm=15,
bandwidth_hz=200,
auto_tone_track=True,
threshold_mode='auto',
wpm_mode='auto',
min_signal_gate=0.0,
)
decoded = ' '.join(str(result.get('text', '')).split())
assert 'CQ TEST 123' in decoded
events = result.get('events', [])
event_counts = Counter(e.get('type') for e in events)
assert event_counts['morse_char'] >= len('CQTEST123')

89
tests/test_system.py Normal file
View 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

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import logging
import os
import platform
import shutil
import subprocess
from typing import Any
@@ -19,6 +20,26 @@ def check_tool(name: str) -> bool:
def get_tool_path(name: str) -> str | None:
"""Get the full path to a tool, checking standard PATH and extra locations."""
# Optional explicit override, e.g. INTERCEPT_RTL_FM_PATH=/opt/homebrew/bin/rtl_fm
env_key = f"INTERCEPT_{name.upper().replace('-', '_')}_PATH"
env_path = os.environ.get(env_key)
if env_path and os.path.isfile(env_path) and os.access(env_path, os.X_OK):
return env_path
# Prefer native Homebrew binaries on Apple Silicon to avoid mixing Rosetta
# /usr/local tools with arm64 Python/runtime.
if platform.system() == 'Darwin':
machine = platform.machine().lower()
preferred_paths: list[str] = []
if machine in {'arm64', 'aarch64'}:
preferred_paths.append('/opt/homebrew/bin')
preferred_paths.append('/usr/local/bin')
for base in preferred_paths:
full_path = os.path.join(base, name)
if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
return full_path
# First check standard PATH
path = shutil.which(name)
if path:

View File

@@ -89,6 +89,15 @@ TELECOMMAND_CODES = {
201: 'POLL_RESPONSE', # Poll response
}
# Full 0-127 telecommand lookup (maps unknown codes to "UNKNOWN")
TELECOMMAND_CODES_FULL = {i: TELECOMMAND_CODES.get(i, "UNKNOWN") for i in range(128)}
# Format codes that carry telecommand fields
TELECOMMAND_FORMATS = {112, 114, 116, 120, 123}
# Minimum symbols (after phasing strip) before an EOS can be accepted
MIN_SYMBOLS_FOR_FORMAT = 12
# =============================================================================
# DSC Symbol Definitions

View File

@@ -43,6 +43,8 @@ from .constants import (
FORMAT_CODES,
DISTRESS_NATURE_CODES,
VALID_EOS,
TELECOMMAND_FORMATS,
MIN_SYMBOLS_FOR_FORMAT,
)
# Configure logging
@@ -222,13 +224,14 @@ class DSCDecoder:
Detect DSC dot pattern for synchronization.
The dot pattern is at least 200 alternating bits (1010101...).
We look for at least 20 consecutive alternations.
We require at least 100 consecutive alternations to avoid
false sync triggers from noise.
"""
if len(self.bit_buffer) < 40:
if len(self.bit_buffer) < 200:
return False
# Check last 40 bits for alternating pattern
last_bits = self.bit_buffer[-40:]
# Check last 200 bits for alternating pattern
last_bits = self.bit_buffer[-200:]
alternations = 0
for i in range(1, len(last_bits)):
@@ -237,7 +240,7 @@ class DSCDecoder:
else:
alternations = 0
if alternations >= 20:
if alternations >= 100:
return True
return False
@@ -263,27 +266,37 @@ class DSCDecoder:
if end <= len(self.message_bits):
symbol_bits = self.message_bits[start:end]
symbol_value = self._bits_to_symbol(symbol_bits)
if symbol_value == -1:
logger.debug("DSC symbol check bit failure, aborting decode")
return None
symbols.append(symbol_value)
# Strip phasing sequence (RX/DX symbols 120-126) from the
# start of the message. Per ITU-R M.493, after the dot pattern
# there are 7 phasing symbols before the format specifier.
# Bound to max 7 — if more are present, this is a bad sync.
msg_start = 0
for i, sym in enumerate(symbols):
if 120 <= sym <= 126:
msg_start = i + 1
else:
break
if msg_start > 7:
logger.debug("DSC bad sync: >7 phasing symbols stripped")
return None
symbols = symbols[msg_start:]
if len(symbols) < 5:
return None
# Look for EOS (End of Sequence) - symbols 117, 122, or 127
# EOS must appear after at least MIN_SYMBOLS_FOR_FORMAT symbols
eos_found = False
eos_index = -1
for i, sym in enumerate(symbols):
if sym in VALID_EOS:
if i < MIN_SYMBOLS_FOR_FORMAT:
continue # Too early — not a real EOS
eos_found = True
eos_index = i
break
@@ -300,7 +313,9 @@ class DSCDecoder:
Convert 10 bits to symbol value.
DSC uses 10-bit symbols: 7 information bits + 3 error bits.
We extract the 7-bit value.
The 3 check bits provide parity such that the total number of
'1' bits across all 10 bits should be even (even parity).
Returns -1 if the check bits are invalid.
"""
if len(bits) != 10:
return -1
@@ -311,6 +326,11 @@ class DSCDecoder:
if bits[i]:
value |= (1 << i)
# Validate check bits: total number of 1s should be even
ones = sum(bits)
if ones % 2 != 0:
return -1
return value
def _decode_symbols(self, symbols: list[int]) -> dict | None:
@@ -356,9 +376,13 @@ class DSCDecoder:
# Decode MMSI from symbols 1-5 (destination/address)
dest_mmsi = self._decode_mmsi(symbols[1:6])
if dest_mmsi is None:
return None
# Decode self-ID from symbols 6-10 (source)
source_mmsi = self._decode_mmsi(symbols[6:11])
if source_mmsi is None:
return None
message = {
'type': 'dsc',
@@ -387,8 +411,9 @@ class DSCDecoder:
if position:
message['position'] = position
# Telecommand fields (usually last two before EOS)
if len(remaining) >= 2:
# Telecommand fields (last two before EOS) — only for formats
# that carry telecommand fields per ITU-R M.493
if format_code in TELECOMMAND_FORMATS and len(remaining) >= 2:
message['telecommand1'] = remaining[-2]
message['telecommand2'] = remaining[-1]
@@ -402,20 +427,21 @@ class DSCDecoder:
logger.warning(f"DSC decode error: {e}")
return None
def _decode_mmsi(self, symbols: list[int]) -> str:
def _decode_mmsi(self, symbols: list[int]) -> str | None:
"""
Decode MMSI from 5 DSC symbols.
Each symbol represents 2 BCD digits (00-99).
5 symbols = 10 digits, but MMSI is 9 digits (first symbol has leading 0).
Returns None if any symbol is out of valid BCD range.
"""
if len(symbols) < 5:
return '000000000'
return None
digits = []
for sym in symbols:
if sym < 0 or sym > 99:
sym = 0
return None
# Each symbol is 2 BCD digits
digits.append(f'{sym:02d}')

View File

@@ -248,7 +248,10 @@ def parse_dsc_message(raw_line: str) -> dict[str, Any] | None:
msg['priority'] = get_category_priority(msg['category'])
# Mark if this is a critical alert
msg['is_critical'] = msg['category'] in ('DISTRESS', 'ALL_SHIPS_URGENCY_SAFETY')
msg['is_critical'] = msg['category'] in (
'DISTRESS', 'DISTRESS_ACK', 'DISTRESS_RELAY',
'URGENCY', 'SAFETY', 'ALL_SHIPS_URGENCY_SAFETY',
)
return msg

File diff suppressed because it is too large Load Diff

View File

@@ -86,12 +86,17 @@ class RTLSDRCommandBuilder(CommandBuilder):
ppm: Optional[int] = None,
modulation: str = "fm",
squelch: Optional[int] = None,
bias_t: bool = False
bias_t: bool = False,
direct_sampling: Optional[int] = None,
) -> list[str]:
"""
Build rtl_fm command for FM demodulation.
Used for pager decoding. Supports local devices and rtl_tcp connections.
Args:
direct_sampling: Enable direct sampling mode (0=off, 1=I-branch,
2=Q-branch). Use 2 for HF reception below 24 MHz.
"""
rtl_fm_path = get_tool_path('rtl_fm') or 'rtl_fm'
demod_mode = _rtl_fm_demod_mode(modulation)
@@ -112,6 +117,14 @@ class RTLSDRCommandBuilder(CommandBuilder):
if squelch is not None and squelch > 0:
cmd.extend(['-l', str(squelch)])
if direct_sampling is not None:
# Older rtl_fm builds (common in Docker/distro packages) don't
# support -D; they use -E direct / -E direct2 instead.
if direct_sampling == 1:
cmd.extend(['-E', 'direct'])
elif direct_sampling == 2:
cmd.extend(['-E', 'direct2'])
if bias_t:
cmd.extend(['-T'])