From 0a90010c1f903ff0e120df8017bd55317a8738a7 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 23:42:45 +0000 Subject: [PATCH 01/22] feat: enhance System Health dashboard with rich telemetry and visualizations Add SVG arc gauge, per-core CPU bars, temperature sparkline, network interface monitoring with bandwidth deltas, disk I/O rates, 3D globe with observer location, weather overlay, battery/fan/throttle support, and process grid layout. New /system/location and /system/weather endpoints. Co-Authored-By: Claude Opus 4.6 --- routes/system.py | 255 +++++++++- static/css/modes/system.css | 335 ++++++++++++- static/js/modes/system.js | 692 ++++++++++++++++++++++++--- templates/index.html | 28 +- templates/partials/modes/system.html | 18 + tests/test_system.py | 103 ++++ 6 files changed, 1344 insertions(+), 87 deletions(-) diff --git a/routes/system.py b/routes/system.py index 839899d..b66efc2 100644 --- a/routes/system.py +++ b/routes/system.py @@ -1,7 +1,8 @@ """System Health monitoring blueprint. -Provides real-time system metrics (CPU, memory, disk, temperatures), -active process status, and SDR device enumeration via SSE streaming. +Provides real-time system metrics (CPU, memory, disk, temperatures, +network, battery, fans), active process status, SDR device enumeration, +location, and weather data via SSE streaming and REST endpoints. """ from __future__ import annotations @@ -11,11 +12,13 @@ import os import platform import queue import socket +import subprocess import threading import time +from pathlib import Path from typing import Any -from flask import Blueprint, Response, jsonify +from flask import Blueprint, Response, jsonify, request from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT from utils.logging import sensor_logger as logger @@ -29,6 +32,11 @@ except ImportError: psutil = None # type: ignore[assignment] _HAS_PSUTIL = False +try: + import requests as _requests +except ImportError: + _requests = None # type: ignore[assignment] + system_bp = Blueprint('system', __name__, url_prefix='/system') # --------------------------------------------------------------------------- @@ -40,6 +48,11 @@ _collector_started = False _collector_lock = threading.Lock() _app_start_time: float | None = None +# Weather cache +_weather_cache: dict[str, Any] = {} +_weather_cache_time: float = 0.0 +_WEATHER_CACHE_TTL = 600 # 10 minutes + def _get_app_start_time() -> float: """Return the application start timestamp from the main app module.""" @@ -138,6 +151,38 @@ def _collect_process_status() -> dict[str, bool]: return {} +def _collect_throttle_flags() -> str | None: + """Read Raspberry Pi throttle flags via vcgencmd (Linux/Pi only).""" + try: + result = subprocess.run( + ['vcgencmd', 'get_throttled'], + capture_output=True, + text=True, + timeout=2, + ) + if result.returncode == 0 and 'throttled=' in result.stdout: + return result.stdout.strip().split('=', 1)[1] + except Exception: + pass + return None + + +def _collect_power_draw() -> float | None: + """Read power draw in watts from sysfs (Linux only).""" + try: + power_supply = Path('/sys/class/power_supply') + if not power_supply.exists(): + return None + for supply_dir in power_supply.iterdir(): + power_file = supply_dir / 'power_now' + if power_file.exists(): + val = int(power_file.read_text().strip()) + return round(val / 1_000_000, 2) # microwatts to watts + except Exception: + pass + return None + + def _collect_metrics() -> dict[str, Any]: """Gather a snapshot of system metrics.""" now = time.time() @@ -159,7 +204,7 @@ def _collect_metrics() -> dict[str, Any]: } if _HAS_PSUTIL: - # CPU + # CPU — overall + per-core + frequency cpu_percent = psutil.cpu_percent(interval=None) cpu_count = psutil.cpu_count() or 1 try: @@ -167,12 +212,28 @@ def _collect_metrics() -> dict[str, Any]: except (OSError, AttributeError): load_1 = load_5 = load_15 = 0.0 + per_core = [] + with contextlib.suppress(Exception): + per_core = psutil.cpu_percent(interval=None, percpu=True) + + freq_data = None + with contextlib.suppress(Exception): + freq = psutil.cpu_freq() + if freq: + freq_data = { + 'current': round(freq.current, 0), + 'min': round(freq.min, 0), + 'max': round(freq.max, 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), + 'per_core': per_core, + 'freq': freq_data, } # Memory @@ -191,7 +252,7 @@ def _collect_metrics() -> dict[str, Any]: 'percent': swap.percent, } - # Disk + # Disk — usage + I/O counters try: disk = psutil.disk_usage('/') metrics['disk'] = { @@ -204,6 +265,18 @@ def _collect_metrics() -> dict[str, Any]: except Exception: metrics['disk'] = None + disk_io = None + with contextlib.suppress(Exception): + dio = psutil.disk_io_counters() + if dio: + disk_io = { + 'read_bytes': dio.read_bytes, + 'write_bytes': dio.write_bytes, + 'read_count': dio.read_count, + 'write_count': dio.write_count, + } + metrics['disk_io'] = disk_io + # Temperatures try: temps = psutil.sensors_temperatures() @@ -224,12 +297,102 @@ def _collect_metrics() -> dict[str, Any]: metrics['temperatures'] = None except (AttributeError, Exception): metrics['temperatures'] = None + + # Fans + fans_data = None + with contextlib.suppress(Exception): + fans = psutil.sensors_fans() + if fans: + fans_data = {} + for chip, entries in fans.items(): + fans_data[chip] = [ + {'label': e.label or chip, 'current': e.current} + for e in entries + ] + metrics['fans'] = fans_data + + # Battery + battery_data = None + with contextlib.suppress(Exception): + bat = psutil.sensors_battery() + if bat: + battery_data = { + 'percent': bat.percent, + 'plugged': bat.power_plugged, + 'secs_left': bat.secsleft if bat.secsleft != psutil.POWER_TIME_UNLIMITED else None, + } + metrics['battery'] = battery_data + + # Network interfaces + net_ifaces: list[dict[str, Any]] = [] + with contextlib.suppress(Exception): + addrs = psutil.net_if_addrs() + stats = psutil.net_if_stats() + for iface_name in sorted(addrs.keys()): + if iface_name == 'lo': + continue + iface_info: dict[str, Any] = {'name': iface_name} + # Get addresses + for addr in addrs[iface_name]: + if addr.family == socket.AF_INET: + iface_info['ipv4'] = addr.address + elif addr.family == socket.AF_INET6: + iface_info.setdefault('ipv6', addr.address) + elif addr.family == psutil.AF_LINK: + iface_info['mac'] = addr.address + # Get stats + if iface_name in stats: + st = stats[iface_name] + iface_info['is_up'] = st.isup + iface_info['speed'] = st.speed # Mbps + iface_info['mtu'] = st.mtu + net_ifaces.append(iface_info) + metrics['network'] = {'interfaces': net_ifaces} + + # Network I/O counters (raw — JS computes deltas) + net_io = None + with contextlib.suppress(Exception): + counters = psutil.net_io_counters(pernic=True) + if counters: + net_io = {} + for nic, c in counters.items(): + if nic == 'lo': + continue + net_io[nic] = { + 'bytes_sent': c.bytes_sent, + 'bytes_recv': c.bytes_recv, + } + metrics['network']['io'] = net_io + + # Connection count + conn_count = 0 + with contextlib.suppress(Exception): + conn_count = len(psutil.net_connections()) + metrics['network']['connections'] = conn_count + + # Boot time + boot_ts = None + with contextlib.suppress(Exception): + boot_ts = psutil.boot_time() + metrics['boot_time'] = boot_ts + + # Power / throttle (Pi-specific) + metrics['power'] = { + 'throttled': _collect_throttle_flags(), + 'draw_watts': _collect_power_draw(), + } else: metrics['cpu'] = None metrics['memory'] = None metrics['swap'] = None metrics['disk'] = None + metrics['disk_io'] = None metrics['temperatures'] = None + metrics['fans'] = None + metrics['battery'] = None + metrics['network'] = None + metrics['boot_time'] = None + metrics['power'] = None return metrics @@ -270,6 +433,32 @@ def _ensure_collector() -> None: logger.info('System metrics collector started') +def _get_observer_location() -> dict[str, Any]: + """Get observer location from GPS state or config defaults.""" + lat, lon, source = None, None, 'none' + + # Try GPS state from app module + with contextlib.suppress(Exception): + import app as app_module + + gps_state = getattr(app_module, 'gps_state', None) + if gps_state and isinstance(gps_state, dict): + g_lat = gps_state.get('lat') or gps_state.get('latitude') + g_lon = gps_state.get('lon') or gps_state.get('longitude') + if g_lat is not None and g_lon is not None: + lat, lon, source = float(g_lat), float(g_lon), 'gps' + + # Fall back to config defaults + if lat is None: + with contextlib.suppress(Exception): + from config import DEFAULT_LATITUDE, DEFAULT_LONGITUDE + + if DEFAULT_LATITUDE != 0.0 or DEFAULT_LONGITUDE != 0.0: + lat, lon, source = DEFAULT_LATITUDE, DEFAULT_LONGITUDE, 'config' + + return {'lat': lat, 'lon': lon, 'source': source} + + # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @@ -321,3 +510,59 @@ def get_sdr_devices() -> Response: except Exception as exc: logger.warning('SDR device detection failed: %s', exc) return jsonify({'devices': [], 'error': str(exc)}) + + +@system_bp.route('/location') +def get_location() -> Response: + """Return observer location from GPS or config.""" + return jsonify(_get_observer_location()) + + +@system_bp.route('/weather') +def get_weather() -> Response: + """Proxy weather from wttr.in, cached for 10 minutes.""" + global _weather_cache, _weather_cache_time + + now = time.time() + if _weather_cache and (now - _weather_cache_time) < _WEATHER_CACHE_TTL: + return jsonify(_weather_cache) + + lat = request.args.get('lat', type=float) + lon = request.args.get('lon', type=float) + if lat is None or lon is None: + loc = _get_observer_location() + lat, lon = loc.get('lat'), loc.get('lon') + + if lat is None or lon is None: + return jsonify({'error': 'No location available'}) + + if _requests is None: + return jsonify({'error': 'requests library not available'}) + + try: + resp = _requests.get( + f'https://wttr.in/{lat},{lon}?format=j1', + timeout=5, + headers={'User-Agent': 'INTERCEPT-SystemHealth/1.0'}, + ) + resp.raise_for_status() + data = resp.json() + + current = data.get('current_condition', [{}])[0] + weather = { + 'temp_c': current.get('temp_C'), + 'temp_f': current.get('temp_F'), + 'condition': current.get('weatherDesc', [{}])[0].get('value', ''), + 'humidity': current.get('humidity'), + 'wind_mph': current.get('windspeedMiles'), + 'wind_dir': current.get('winddir16Point'), + 'feels_like_c': current.get('FeelsLikeC'), + 'visibility': current.get('visibility'), + 'pressure': current.get('pressure'), + } + _weather_cache = weather + _weather_cache_time = now + return jsonify(weather) + except Exception as exc: + logger.debug('Weather fetch failed: %s', exc) + return jsonify({'error': str(exc)}) diff --git a/static/css/modes/system.css b/static/css/modes/system.css index 3efd245..7beee04 100644 --- a/static/css/modes/system.css +++ b/static/css/modes/system.css @@ -1,14 +1,32 @@ -/* System Health Mode Styles */ +/* System Health Mode Styles — Enhanced Dashboard */ .sys-dashboard { display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + grid-template-columns: repeat(3, 1fr); gap: 16px; padding: 16px; width: 100%; box-sizing: border-box; } +/* Group headers span full width */ +.sys-group-header { + grid-column: 1 / -1; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--accent-cyan, #00d4ff); + border-bottom: 1px solid rgba(0, 212, 255, 0.2); + padding-bottom: 6px; + margin-top: 8px; +} + +.sys-group-header:first-child { + margin-top: 0; +} + +/* Cards */ .sys-card { background: var(--bg-card, #1a1a2e); border: 1px solid var(--border-color, #2a2a4a); @@ -17,6 +35,14 @@ min-height: 120px; } +.sys-card-wide { + grid-column: span 2; +} + +.sys-card-full { + grid-column: 1 / -1; +} + .sys-card-header { font-size: 11px; font-weight: 700; @@ -99,7 +125,252 @@ font-size: 11px; } -/* Process items */ +/* SVG Arc Gauge */ +.sys-gauge-wrap { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 12px; +} + +.sys-gauge-arc { + position: relative; + width: 90px; + height: 90px; + flex-shrink: 0; +} + +.sys-gauge-arc svg { + width: 100%; + height: 100%; +} + +.sys-gauge-arc .arc-bg { + fill: none; + stroke: var(--bg-primary, #0d0d1a); + stroke-width: 8; + stroke-linecap: round; +} + +.sys-gauge-arc .arc-fill { + fill: none; + stroke-width: 8; + stroke-linecap: round; + transition: stroke-dashoffset 0.6s ease, stroke 0.3s ease; + filter: drop-shadow(0 0 4px currentColor); +} + +.sys-gauge-arc .arc-fill.ok { stroke: var(--accent-green, #00ff88); } +.sys-gauge-arc .arc-fill.warn { stroke: var(--accent-yellow, #ffcc00); } +.sys-gauge-arc .arc-fill.crit { stroke: var(--accent-red, #ff3366); } + +.sys-gauge-label { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 18px; + font-weight: 700; + font-family: var(--font-mono, 'JetBrains Mono', monospace); + color: var(--text-primary, #e0e0ff); +} + +.sys-gauge-details { + flex: 1; + font-size: 11px; +} + +/* Per-core bars */ +.sys-core-bars { + display: flex; + gap: 3px; + align-items: flex-end; + height: 24px; + margin-top: 8px; +} + +.sys-core-bar { + flex: 1; + background: var(--bg-primary, #0d0d1a); + border-radius: 2px; + position: relative; + min-width: 4px; + max-width: 16px; + height: 100%; +} + +.sys-core-bar-fill { + position: absolute; + bottom: 0; + left: 0; + right: 0; + border-radius: 2px; + transition: height 0.4s ease, background 0.3s ease; +} + +/* Temperature sparkline */ +.sys-sparkline-wrap { + margin: 8px 0; +} + +.sys-sparkline-wrap svg { + width: 100%; + height: 40px; +} + +.sys-sparkline-line { + fill: none; + stroke: var(--accent-cyan, #00d4ff); + stroke-width: 1.5; + filter: drop-shadow(0 0 2px rgba(0, 212, 255, 0.4)); +} + +.sys-sparkline-area { + fill: url(#sparkGradient); + opacity: 0.3; +} + +.sys-temp-big { + font-size: 28px; + font-weight: 700; + font-family: var(--font-mono, 'JetBrains Mono', monospace); + color: var(--text-primary, #e0e0ff); + margin-bottom: 4px; +} + +/* Network interface rows */ +.sys-net-iface { + padding: 6px 0; + border-bottom: 1px solid var(--border-color, #2a2a4a); +} + +.sys-net-iface:last-child { + border-bottom: none; +} + +.sys-net-iface-name { + font-weight: 700; + color: var(--accent-cyan, #00d4ff); + font-size: 11px; +} + +.sys-net-iface-ip { + font-family: var(--font-mono, 'JetBrains Mono', monospace); + font-size: 12px; + color: var(--text-primary, #e0e0ff); +} + +.sys-net-iface-detail { + font-size: 10px; + color: var(--text-dim, #8888aa); +} + +/* Bandwidth arrows */ +.sys-bandwidth { + display: flex; + gap: 12px; + margin-top: 4px; + font-family: var(--font-mono, 'JetBrains Mono', monospace); + font-size: 11px; +} + +.sys-bw-up { + color: var(--accent-green, #00ff88); +} + +.sys-bw-down { + color: var(--accent-cyan, #00d4ff); +} + +/* Globe container */ +.sys-location-inner { + display: flex; + gap: 16px; + align-items: stretch; +} + +.sys-globe-wrap { + width: 300px; + height: 300px; + flex-shrink: 0; + background: #000; + border-radius: 8px; + overflow: hidden; + position: relative; +} + +.sys-location-details { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; +} + +.sys-location-coords { + font-family: var(--font-mono, 'JetBrains Mono', monospace); + font-size: 13px; + color: var(--text-primary, #e0e0ff); +} + +.sys-location-source { + font-size: 10px; + color: var(--text-dim, #8888aa); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Weather overlay */ +.sys-weather { + margin-top: auto; + padding: 10px; + background: rgba(0, 0, 0, 0.3); + border-radius: 6px; + border: 1px solid var(--border-color, #2a2a4a); +} + +.sys-weather-temp { + font-size: 24px; + font-weight: 700; + font-family: var(--font-mono, 'JetBrains Mono', monospace); + color: var(--text-primary, #e0e0ff); +} + +.sys-weather-condition { + font-size: 12px; + color: var(--text-dim, #8888aa); + margin-top: 2px; +} + +.sys-weather-detail { + font-size: 10px; + color: var(--text-dim, #8888aa); + margin-top: 2px; +} + +/* Disk I/O indicators */ +.sys-disk-io { + display: flex; + gap: 16px; + margin-top: 8px; + font-family: var(--font-mono, 'JetBrains Mono', monospace); + font-size: 11px; +} + +.sys-disk-io-read { + color: var(--accent-cyan, #00d4ff); +} + +.sys-disk-io-write { + color: var(--accent-green, #00ff88); +} + +/* Process grid — dot-matrix style */ +.sys-process-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 4px 12px; +} + .sys-process-item { display: flex; align-items: center; @@ -128,6 +399,12 @@ background: var(--text-dim, #555); } +.sys-process-summary { + margin-top: 8px; + font-size: 11px; + color: var(--text-dim, #8888aa); +} + /* SDR Devices */ .sys-sdr-device { padding: 6px 0; @@ -154,6 +431,32 @@ background: var(--bg-primary, #0d0d1a); } +/* System info — horizontal layout */ +.sys-info-grid { + display: flex; + flex-wrap: wrap; + gap: 4px 20px; + font-size: 11px; + color: var(--text-dim, #8888aa); +} + +.sys-info-item { + white-space: nowrap; +} + +.sys-info-item strong { + color: var(--text-primary, #e0e0ff); + font-weight: 600; +} + +/* Battery indicator */ +.sys-battery-inline { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; +} + /* Sidebar Quick Grid */ .sys-quick-grid { display: grid; @@ -206,10 +509,36 @@ padding: 8px; gap: 10px; } + + .sys-card-wide, + .sys-card-full { + grid-column: 1; + } + + .sys-location-inner { + flex-direction: column; + } + + .sys-globe-wrap { + width: 100%; + height: 250px; + } + + .sys-process-grid { + grid-template-columns: 1fr; + } } @media (max-width: 1024px) and (min-width: 769px) { .sys-dashboard { grid-template-columns: repeat(2, 1fr); } + + .sys-card-wide { + grid-column: span 2; + } + + .sys-card-full { + grid-column: 1 / -1; + } } diff --git a/static/js/modes/system.js b/static/js/modes/system.js index 1aab9aa..74fb93d 100644 --- a/static/js/modes/system.js +++ b/static/js/modes/system.js @@ -1,8 +1,9 @@ /** - * System Health – IIFE module + * System Health – Enhanced Dashboard 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. + * Streams real-time system metrics via SSE with rich visualizations: + * SVG arc gauge, per-core bars, temperature sparkline, network bandwidth, + * disk I/O, 3D globe, weather, and process grid. */ const SystemHealth = (function () { 'use strict'; @@ -11,19 +12,46 @@ const SystemHealth = (function () { let connected = false; let lastMetrics = null; + // Temperature sparkline ring buffer (last 20 readings) + const SPARKLINE_SIZE = 20; + let tempHistory = []; + + // Network I/O delta tracking + let prevNetIo = null; + let prevNetTimestamp = null; + + // Disk I/O delta tracking + let prevDiskIo = null; + let prevDiskTimestamp = null; + + // Location & weather state + let locationData = null; + let weatherData = null; + let weatherTimer = null; + let globeInstance = null; + let globeDestroyed = false; + + const GLOBE_SCRIPT_URL = 'https://cdn.jsdelivr.net/npm/globe.gl@2.33.1/dist/globe.gl.min.js'; + const GLOBE_TEXTURE_URL = '/static/images/globe/earth-dark.jpg'; + // ----------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------- function formatBytes(bytes) { if (bytes == null) return '--'; - const units = ['B', 'KB', 'MB', 'GB', 'TB']; - let i = 0; - let val = bytes; + var units = ['B', 'KB', 'MB', 'GB', 'TB']; + var i = 0; + var val = bytes; while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; } return val.toFixed(1) + ' ' + units[i]; } + function formatRate(bytesPerSec) { + if (bytesPerSec == null) return '--'; + return formatBytes(bytesPerSec) + '/s'; + } + function barClass(pct) { if (pct >= 85) return 'crit'; if (pct >= 60) return 'warn'; @@ -32,8 +60,8 @@ const SystemHealth = (function () { function barHtml(pct, label) { if (pct == null) return 'N/A'; - const cls = barClass(pct); - const rounded = Math.round(pct); + var cls = barClass(pct); + var rounded = Math.round(pct); return '
' + (label ? '' + label + '' : '') + '
' + @@ -41,71 +69,477 @@ const SystemHealth = (function () { '
'; } + function escHtml(s) { + var d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; + } + // ----------------------------------------------------------------------- - // Rendering + // SVG Arc Gauge + // ----------------------------------------------------------------------- + + function arcGaugeSvg(pct) { + var radius = 36; + var cx = 45, cy = 45; + var startAngle = -225; + var endAngle = 45; + var totalAngle = endAngle - startAngle; // 270 degrees + var fillAngle = startAngle + (totalAngle * Math.min(pct, 100) / 100); + + function polarToCart(angle) { + var r = angle * Math.PI / 180; + return { x: cx + radius * Math.cos(r), y: cy + radius * Math.sin(r) }; + } + + var bgStart = polarToCart(startAngle); + var bgEnd = polarToCart(endAngle); + var fillEnd = polarToCart(fillAngle); + var largeArcBg = totalAngle > 180 ? 1 : 0; + var fillArc = (fillAngle - startAngle) > 180 ? 1 : 0; + var cls = barClass(pct); + + return '' + + '' + + '' + + ''; + } + + // ----------------------------------------------------------------------- + // Temperature Sparkline + // ----------------------------------------------------------------------- + + function sparklineSvg(values) { + if (!values || values.length < 2) return ''; + var w = 200, h = 40; + var min = Math.min.apply(null, values); + var max = Math.max.apply(null, values); + var range = max - min || 1; + var step = w / (values.length - 1); + + var points = values.map(function (v, i) { + var x = Math.round(i * step); + var y = Math.round(h - ((v - min) / range) * (h - 4) - 2); + return x + ',' + y; + }); + + var areaPoints = points.join(' ') + ' ' + w + ',' + h + ' 0,' + h; + + return '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + } + + // ----------------------------------------------------------------------- + // Rendering — CPU Card // ----------------------------------------------------------------------- function renderCpuCard(m) { - const el = document.getElementById('sysCardCpu'); + var el = document.getElementById('sysCardCpu'); if (!el) return; - const cpu = m.cpu; + var cpu = m.cpu; if (!cpu) { el.innerHTML = '
psutil not available
'; return; } + + var pct = Math.round(cpu.percent); + var coreHtml = ''; + if (cpu.per_core && cpu.per_core.length) { + coreHtml = '
'; + cpu.per_core.forEach(function (c) { + var cls = barClass(c); + var h = Math.max(2, Math.round(c / 100 * 24)); + coreHtml += '
'; + }); + coreHtml += '
'; + } + + var freqHtml = ''; + if (cpu.freq) { + var freqGhz = (cpu.freq.current / 1000).toFixed(2); + freqHtml = '
Freq: ' + freqGhz + ' GHz
'; + } + el.innerHTML = '
CPU
' + '
' + - barHtml(cpu.percent, '') + + '
' + + '
' + arcGaugeSvg(pct) + + '
' + pct + '%
' + + '
' + '
Load: ' + cpu.load_1 + ' / ' + cpu.load_5 + ' / ' + cpu.load_15 + '
' + '
Cores: ' + cpu.count + '
' + + freqHtml + + '
' + + coreHtml + '
'; } + // ----------------------------------------------------------------------- + // Memory Card + // ----------------------------------------------------------------------- + function renderMemoryCard(m) { - const el = document.getElementById('sysCardMemory'); + var el = document.getElementById('sysCardMemory'); if (!el) return; - const mem = m.memory; + var mem = m.memory; if (!mem) { el.innerHTML = '
N/A
'; return; } - const swap = m.swap || {}; + var swap = m.swap || {}; el.innerHTML = '
Memory
' + '
' + - barHtml(mem.percent, '') + + barHtml(mem.percent, 'RAM') + '
' + formatBytes(mem.used) + ' / ' + formatBytes(mem.total) + '
' + - '
Swap: ' + formatBytes(swap.used) + ' / ' + formatBytes(swap.total) + '
' + + (swap.total > 0 ? barHtml(swap.percent, 'Swap') + + '
' + formatBytes(swap.used) + ' / ' + formatBytes(swap.total) + '
' : '') + '
'; } - function renderDiskCard(m) { - const el = document.getElementById('sysCardDisk'); - if (!el) return; - const disk = m.disk; - if (!disk) { el.innerHTML = '
N/A
'; return; } - el.innerHTML = - '
Disk
' + - '
' + - barHtml(disk.percent, '') + - '
' + formatBytes(disk.used) + ' / ' + formatBytes(disk.total) + '
' + - '
Path: ' + (disk.path || '/') + '
' + - '
'; - } + // ----------------------------------------------------------------------- + // Temperature & Power Card + // ----------------------------------------------------------------------- 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]; + var preferred = ['cpu_thermal', 'coretemp', 'k10temp', 'acpitz', 'soc_thermal']; + for (var i = 0; i < preferred.length; i++) { + if (temps[preferred[i]] && temps[preferred[i]].length) return temps[preferred[i]][0]; } - // Fall back to first available - for (const key of Object.keys(temps)) { + for (var key in temps) { if (temps[key] && temps[key].length) return temps[key][0]; } return null; } - function renderSdrCard(devices) { - const el = document.getElementById('sysCardSdr'); + function renderTempCard(m) { + var el = document.getElementById('sysCardTemp'); if (!el) return; - let html = '
SDR Devices
'; + + var temp = _extractPrimaryTemp(m.temperatures); + var html = '
Temperature & Power
'; + + if (temp) { + // Update sparkline history + tempHistory.push(temp.current); + if (tempHistory.length > SPARKLINE_SIZE) tempHistory.shift(); + + html += '
' + Math.round(temp.current) + '°C
'; + html += '
' + sparklineSvg(tempHistory) + '
'; + + // Additional sensors + if (m.temperatures) { + for (var chip in m.temperatures) { + m.temperatures[chip].forEach(function (s) { + html += '
' + escHtml(s.label) + ': ' + Math.round(s.current) + '°C
'; + }); + } + } + } else { + html += 'No temperature sensors'; + } + + // Fans + if (m.fans) { + for (var fChip in m.fans) { + m.fans[fChip].forEach(function (f) { + html += '
Fan ' + escHtml(f.label) + ': ' + f.current + ' RPM
'; + }); + } + } + + // Battery + if (m.battery) { + html += '
' + + 'Battery: ' + Math.round(m.battery.percent) + '%' + + (m.battery.plugged ? ' (plugged)' : '') + '
'; + } + + // Throttle flags (Pi) + if (m.power && m.power.throttled) { + html += '
Throttle: 0x' + m.power.throttled + '
'; + } + + // Power draw + if (m.power && m.power.draw_watts != null) { + html += '
Power: ' + m.power.draw_watts + ' W
'; + } + + html += '
'; + el.innerHTML = html; + } + + // ----------------------------------------------------------------------- + // Disk Card + // ----------------------------------------------------------------------- + + function renderDiskCard(m) { + var el = document.getElementById('sysCardDisk'); + if (!el) return; + var disk = m.disk; + if (!disk) { el.innerHTML = '
Disk & Storage
N/A
'; return; } + + var html = '
Disk & Storage
'; + html += barHtml(disk.percent, ''); + html += '
' + formatBytes(disk.used) + ' / ' + formatBytes(disk.total) + '
'; + + // Disk I/O rates + if (m.disk_io && prevDiskIo && prevDiskTimestamp) { + var dt = (m.timestamp - prevDiskTimestamp); + if (dt > 0) { + var readRate = (m.disk_io.read_bytes - prevDiskIo.read_bytes) / dt; + var writeRate = (m.disk_io.write_bytes - prevDiskIo.write_bytes) / dt; + var readIops = Math.round((m.disk_io.read_count - prevDiskIo.read_count) / dt); + var writeIops = Math.round((m.disk_io.write_count - prevDiskIo.write_count) / dt); + html += '
' + + 'R: ' + formatRate(Math.max(0, readRate)) + '' + + 'W: ' + formatRate(Math.max(0, writeRate)) + '' + + '
'; + html += '
IOPS: ' + Math.max(0, readIops) + 'r / ' + Math.max(0, writeIops) + 'w
'; + } + } + + if (m.disk_io) { + prevDiskIo = m.disk_io; + prevDiskTimestamp = m.timestamp; + } + + html += '
'; + el.innerHTML = html; + } + + // ----------------------------------------------------------------------- + // Network Card + // ----------------------------------------------------------------------- + + function renderNetworkCard(m) { + var el = document.getElementById('sysCardNetwork'); + if (!el) return; + var net = m.network; + if (!net) { el.innerHTML = '
Network
N/A
'; return; } + + var html = '
Network
'; + + // Interfaces + var ifaces = net.interfaces || []; + if (ifaces.length === 0) { + html += 'No interfaces'; + } else { + ifaces.forEach(function (iface) { + html += '
'; + html += '
' + escHtml(iface.name) + + (iface.is_up ? '' : ' (down)') + '
'; + if (iface.ipv4) html += '
' + escHtml(iface.ipv4) + '
'; + var details = []; + if (iface.mac) details.push('MAC: ' + iface.mac); + if (iface.speed) details.push(iface.speed + ' Mbps'); + if (details.length) html += '
' + escHtml(details.join(' | ')) + '
'; + + // Bandwidth for this interface + if (net.io && net.io[iface.name] && prevNetIo && prevNetIo[iface.name] && prevNetTimestamp) { + var dt = (m.timestamp - prevNetTimestamp); + if (dt > 0) { + var prev = prevNetIo[iface.name]; + var cur = net.io[iface.name]; + var upRate = (cur.bytes_sent - prev.bytes_sent) / dt; + var downRate = (cur.bytes_recv - prev.bytes_recv) / dt; + html += '
' + + '↑ ' + formatRate(Math.max(0, upRate)) + '' + + '↓ ' + formatRate(Math.max(0, downRate)) + '' + + '
'; + } + } + + html += '
'; + }); + } + + // Connection count + if (net.connections != null) { + html += '
Connections: ' + net.connections + '
'; + } + + // Save for next delta + if (net.io) { + prevNetIo = net.io; + prevNetTimestamp = m.timestamp; + } + + html += '
'; + el.innerHTML = html; + } + + // ----------------------------------------------------------------------- + // Location & Weather Card + // ----------------------------------------------------------------------- + + function renderLocationCard() { + var el = document.getElementById('sysCardLocation'); + if (!el) return; + + var html = '
Location & Weather
'; + html += '
'; + + // Globe container + html += '
'; + + // Details column + html += '
'; + + if (locationData && locationData.lat != null) { + html += '
' + + locationData.lat.toFixed(4) + '°' + (locationData.lat >= 0 ? 'N' : 'S') + '
' + + locationData.lon.toFixed(4) + '°' + (locationData.lon >= 0 ? 'E' : 'W') + '
'; + html += '
Source: ' + escHtml(locationData.source || 'unknown') + '
'; + } else { + html += '
No location
'; + } + + // Weather + if (weatherData && !weatherData.error) { + html += '
'; + html += '
' + (weatherData.temp_c || '--') + '°C
'; + html += '
' + escHtml(weatherData.condition || '') + '
'; + var details = []; + if (weatherData.humidity) details.push('Humidity: ' + weatherData.humidity + '%'); + if (weatherData.wind_mph) details.push('Wind: ' + weatherData.wind_mph + ' mph ' + (weatherData.wind_dir || '')); + if (weatherData.feels_like_c) details.push('Feels like: ' + weatherData.feels_like_c + '°C'); + details.forEach(function (d) { + html += '
' + escHtml(d) + '
'; + }); + html += '
'; + } else if (weatherData && weatherData.error) { + html += '
Weather unavailable
'; + } + + html += '
'; // .sys-location-details + html += '
'; // .sys-location-inner + html += '
'; + el.innerHTML = html; + + // Initialize globe after DOM is ready + setTimeout(function () { initGlobe(); }, 50); + } + + // ----------------------------------------------------------------------- + // Globe (reuses globe.gl from GPS mode) + // ----------------------------------------------------------------------- + + function ensureGlobeLibrary() { + return new Promise(function (resolve, reject) { + if (typeof window.Globe === 'function') { resolve(true); return; } + + // Check if script already exists + var existing = document.querySelector( + 'script[data-intercept-globe-src="' + GLOBE_SCRIPT_URL + '"], ' + + 'script[src="' + GLOBE_SCRIPT_URL + '"]' + ); + if (existing) { + if (existing.dataset.loaded === 'true') { resolve(true); return; } + if (existing.dataset.failed === 'true') { resolve(false); return; } + existing.addEventListener('load', function () { resolve(true); }, { once: true }); + existing.addEventListener('error', function () { resolve(false); }, { once: true }); + return; + } + + var script = document.createElement('script'); + script.src = GLOBE_SCRIPT_URL; + script.async = true; + script.crossOrigin = 'anonymous'; + script.dataset.interceptGlobeSrc = GLOBE_SCRIPT_URL; + script.onload = function () { script.dataset.loaded = 'true'; resolve(true); }; + script.onerror = function () { script.dataset.failed = 'true'; resolve(false); }; + document.head.appendChild(script); + }); + } + + function initGlobe() { + var container = document.getElementById('sysGlobeContainer'); + if (!container || globeDestroyed) return; + + // Don't reinitialize if globe is already in this container + if (globeInstance && container.querySelector('canvas')) return; + + ensureGlobeLibrary().then(function (ready) { + if (!ready || typeof window.Globe !== 'function' || globeDestroyed) return; + + container = document.getElementById('sysGlobeContainer'); + if (!container || !container.clientWidth) return; + + container.innerHTML = ''; + container.style.background = 'radial-gradient(circle, rgba(10,20,40,0.9), rgba(2,4,8,0.98) 70%)'; + + globeInstance = window.Globe()(container) + .backgroundColor('rgba(0,0,0,0)') + .globeImageUrl(GLOBE_TEXTURE_URL) + .showAtmosphere(true) + .atmosphereColor('#3bb9ff') + .atmosphereAltitude(0.12) + .pointsData([]) + .pointRadius(0.8) + .pointAltitude(0.01) + .pointColor(function () { return '#00d4ff'; }); + + var controls = globeInstance.controls(); + if (controls) { + controls.autoRotate = true; + controls.autoRotateSpeed = 0.5; + controls.enablePan = false; + controls.minDistance = 180; + controls.maxDistance = 400; + } + + // Size the globe + globeInstance.width(container.clientWidth); + globeInstance.height(container.clientHeight); + + updateGlobePosition(); + }); + } + + function updateGlobePosition() { + if (!globeInstance || !locationData || locationData.lat == null) return; + + // Observer point + globeInstance.pointsData([{ + lat: locationData.lat, + lng: locationData.lon, + size: 0.8, + color: '#00d4ff', + }]); + + // Snap view + globeInstance.pointOfView({ lat: locationData.lat, lng: locationData.lon, altitude: 2.0 }, 1000); + + // Stop auto-rotate when we have a fix + var controls = globeInstance.controls(); + if (controls) controls.autoRotate = false; + } + + function destroyGlobe() { + globeDestroyed = true; + if (globeInstance) { + var container = document.getElementById('sysGlobeContainer'); + if (container) container.innerHTML = ''; + globeInstance = null; + } + } + + // ----------------------------------------------------------------------- + // SDR Card + // ----------------------------------------------------------------------- + + function renderSdrCard(devices) { + var el = document.getElementById('sysCardSdr'); + if (!el) return; + var html = '
SDR Devices
'; html += '
'; if (!devices || !devices.length) { html += 'No devices found'; @@ -113,9 +547,9 @@ const SystemHealth = (function () { devices.forEach(function (d) { html += '
' + ' ' + - '' + d.type + ' #' + d.index + '' + - '
' + (d.name || 'Unknown') + '
' + - (d.serial ? '
S/N: ' + d.serial + '
' : '') + + '' + escHtml(d.type) + ' #' + d.index + '' + + '
' + escHtml(d.name || 'Unknown') + '
' + + (d.serial ? '
S/N: ' + escHtml(d.serial) + '
' : '') + '
'; }); } @@ -123,93 +557,187 @@ const SystemHealth = (function () { el.innerHTML = html; } + // ----------------------------------------------------------------------- + // Process Card + // ----------------------------------------------------------------------- + function renderProcessCard(m) { - const el = document.getElementById('sysCardProcesses'); + var el = document.getElementById('sysCardProcesses'); if (!el) return; - const procs = m.processes || {}; - const keys = Object.keys(procs).sort(); - let html = '
Processes
'; + var procs = m.processes || {}; + var keys = Object.keys(procs).sort(); + var html = '
Active Processes
'; if (!keys.length) { html += 'No data'; } else { + var running = 0, stopped = 0; + html += '
'; keys.forEach(function (k) { - const running = procs[k]; - const dotCls = running ? 'running' : 'stopped'; - const label = k.charAt(0).toUpperCase() + k.slice(1); + var isRunning = procs[k]; + if (isRunning) running++; else stopped++; + var dotCls = isRunning ? 'running' : 'stopped'; + var label = k.charAt(0).toUpperCase() + k.slice(1); html += '
' + ' ' + - '' + label + '' + + '' + escHtml(label) + '' + '
'; }); + html += '
'; + html += '
' + running + ' running / ' + stopped + ' idle
'; } html += '
'; el.innerHTML = html; } + // ----------------------------------------------------------------------- + // System Info Card + // ----------------------------------------------------------------------- + function renderSystemInfoCard(m) { - const el = document.getElementById('sysCardInfo'); + var el = document.getElementById('sysCardInfo'); if (!el) return; - const sys = m.system || {}; - const temp = _extractPrimaryTemp(m.temperatures); - let html = '
System Info
'; - html += '
Host: ' + (sys.hostname || '--') + '
'; - html += '
OS: ' + (sys.platform || '--') + '
'; - html += '
Python: ' + (sys.python || '--') + '
'; - html += '
App: v' + (sys.version || '--') + '
'; - html += '
Uptime: ' + (sys.uptime_human || '--') + '
'; - if (temp) { - html += '
Temp: ' + Math.round(temp.current) + '°C'; - if (temp.high) html += ' / ' + Math.round(temp.high) + '°C max'; - html += '
'; + var sys = m.system || {}; + var html = '
System Info
'; + + html += '
Host: ' + escHtml(sys.hostname || '--') + '
'; + html += '
OS: ' + escHtml(sys.platform || '--') + '
'; + html += '
Python: ' + escHtml(sys.python || '--') + '
'; + html += '
App: v' + escHtml(sys.version || '--') + '
'; + html += '
Uptime: ' + escHtml(sys.uptime_human || '--') + '
'; + + if (m.boot_time) { + var bootDate = new Date(m.boot_time * 1000); + html += '
Boot: ' + escHtml(bootDate.toUTCString()) + '
'; } - html += '
'; + + if (m.network && m.network.connections != null) { + html += '
Connections: ' + m.network.connections + '
'; + } + + html += '
'; el.innerHTML = html; } + // ----------------------------------------------------------------------- + // Sidebar Updates + // ----------------------------------------------------------------------- + function updateSidebarQuickStats(m) { - const cpuEl = document.getElementById('sysQuickCpu'); - const tempEl = document.getElementById('sysQuickTemp'); - const ramEl = document.getElementById('sysQuickRam'); - const diskEl = document.getElementById('sysQuickDisk'); + var cpuEl = document.getElementById('sysQuickCpu'); + var tempEl = document.getElementById('sysQuickTemp'); + var ramEl = document.getElementById('sysQuickRam'); + var 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); + var temp = _extractPrimaryTemp(m.temperatures); if (tempEl) tempEl.innerHTML = temp ? Math.round(temp.current) + '°C' : '--'; // Color-code values [cpuEl, ramEl, diskEl].forEach(function (el) { if (!el) return; - const val = parseInt(el.textContent); + var 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'); + var el = document.getElementById('sysProcessList'); if (!el) return; - const procs = m.processes || {}; - const keys = Object.keys(procs).sort(); + var procs = m.processes || {}; + var 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]; }); + var running = keys.filter(function (k) { return procs[k]; }); + var stopped = keys.filter(function (k) { return !procs[k]; }); el.innerHTML = (running.length ? '' + running.length + ' running' : '') + (running.length && stopped.length ? ' · ' : '') + (stopped.length ? '' + stopped.length + ' stopped' : ''); } + function updateSidebarNetwork(m) { + var el = document.getElementById('sysQuickNet'); + if (!el || !m.network) return; + var ifaces = m.network.interfaces || []; + var ips = []; + ifaces.forEach(function (iface) { + if (iface.ipv4 && iface.is_up) { + ips.push(iface.name + ': ' + iface.ipv4); + } + }); + el.textContent = ips.length ? ips.join(', ') : '--'; + } + + function updateSidebarBattery(m) { + var section = document.getElementById('sysQuickBatterySection'); + var el = document.getElementById('sysQuickBattery'); + if (!section || !el) return; + if (m.battery) { + section.style.display = ''; + el.textContent = Math.round(m.battery.percent) + '%' + (m.battery.plugged ? ' (plugged)' : ''); + } else { + section.style.display = 'none'; + } + } + + function updateSidebarLocation() { + var el = document.getElementById('sysQuickLocation'); + if (!el) return; + if (locationData && locationData.lat != null) { + el.textContent = locationData.lat.toFixed(4) + ', ' + locationData.lon.toFixed(4) + ' (' + locationData.source + ')'; + } else { + el.textContent = 'No location'; + } + } + + // ----------------------------------------------------------------------- + // Render all + // ----------------------------------------------------------------------- + function renderAll(m) { renderCpuCard(m); renderMemoryCard(m); + renderTempCard(m); renderDiskCard(m); + renderNetworkCard(m); renderProcessCard(m); renderSystemInfoCard(m); updateSidebarQuickStats(m); updateSidebarProcesses(m); + updateSidebarNetwork(m); + updateSidebarBattery(m); + } + + // ----------------------------------------------------------------------- + // Location & Weather Fetching + // ----------------------------------------------------------------------- + + function fetchLocation() { + fetch('/system/location') + .then(function (r) { return r.json(); }) + .then(function (data) { + locationData = data; + updateSidebarLocation(); + renderLocationCard(); + if (data.lat != null) fetchWeather(); + }) + .catch(function () { + renderLocationCard(); + }); + } + + function fetchWeather() { + if (!locationData || locationData.lat == null) return; + fetch('/system/weather?lat=' + locationData.lat + '&lon=' + locationData.lon) + .then(function (r) { return r.json(); }) + .then(function (data) { + weatherData = data; + renderLocationCard(); + }) + .catch(function () {}); } // ----------------------------------------------------------------------- @@ -267,7 +795,7 @@ const SystemHealth = (function () { var html = ''; devices.forEach(function (d) { html += '
' + - d.type + ' #' + d.index + ' — ' + (d.name || 'Unknown') + '
'; + escHtml(d.type) + ' #' + d.index + ' — ' + escHtml(d.name || 'Unknown') + '
'; }); sidebarEl.innerHTML = html; } @@ -284,12 +812,24 @@ const SystemHealth = (function () { // ----------------------------------------------------------------------- function init() { + globeDestroyed = false; connect(); refreshSdr(); + fetchLocation(); + + // Refresh weather every 10 minutes + weatherTimer = setInterval(function () { + fetchWeather(); + }, 600000); } function destroy() { disconnect(); + destroyGlobe(); + if (weatherTimer) { + clearInterval(weatherTimer); + weatherTimer = null; + } } return { diff --git a/templates/index.html b/templates/index.html index ddc1b96..ddf7990 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3130,6 +3130,8 @@ diff --git a/tests/test_system.py b/tests/test_system.py index 3ddef06..805ea11 100644 --- a/tests/test_system.py +++ b/tests/test_system.py @@ -123,8 +123,7 @@ def test_stream_returns_sse_content_type(client): def test_location_returns_shape(client): """GET /system/location returns lat/lon/source shape.""" _login(client) - with patch('routes.system.contextlib.suppress'): - resp = client.get('/system/location') + resp = client.get('/system/location') assert resp.status_code == 200 data = resp.get_json() assert 'lat' in data @@ -132,12 +131,48 @@ def test_location_returns_shape(client): assert 'source' in data +def test_location_from_gps(client): + """Location endpoint returns GPS data when fix available.""" + _login(client) + mock_pos = MagicMock() + mock_pos.fix_quality = 3 + mock_pos.latitude = 51.5074 + mock_pos.longitude = -0.1278 + mock_pos.satellites = 12 + mock_pos.epx = 2.5 + mock_pos.epy = 3.1 + mock_pos.altitude = 45.0 + + with patch('routes.system.get_current_position', return_value=mock_pos, create=True): + # Patch the import inside the function + import routes.system as mod + original = mod._get_observer_location + + def _patched(): + with patch('utils.gps.get_current_position', return_value=mock_pos): + return original() + + mod._get_observer_location = _patched + try: + resp = client.get('/system/location') + finally: + mod._get_observer_location = original + + assert resp.status_code == 200 + data = resp.get_json() + assert data['source'] == 'gps' + assert data['lat'] == 51.5074 + assert data['lon'] == -0.1278 + assert data['gps']['fix_quality'] == 3 + assert data['gps']['satellites'] == 12 + assert data['gps']['accuracy'] == 3.1 + assert data['gps']['altitude'] == 45.0 + + def test_location_falls_back_to_config(client): """Location endpoint returns config defaults when GPS unavailable.""" _login(client) - with patch('routes.system.DEFAULT_LATITUDE', 40.7128, create=True), \ - patch('routes.system.DEFAULT_LONGITUDE', -74.006, create=True): - # Mock the import inside _get_observer_location + with patch('utils.gps.get_current_position', return_value=None): resp = client.get('/system/location') assert resp.status_code == 200 data = resp.get_json() From 4b31474080716b1f399d96ac7b2a94b77f73d367 Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 26 Feb 2026 23:55:36 +0000 Subject: [PATCH 03/22] fix: location fallback to constants, compact card sizing - Add third location fallback to utils/constants (London 51.5074/-0.1278) so location always resolves even without GPS or env vars configured - Remove min-height from sys-card to eliminate wasted space - Switch System Info to vertical key-value layout filling the card - Clean up OS string (strip glibc suffix), use locale date for boot time - Bump info grid font size from 11px to 12px for readability Co-Authored-By: Claude Opus 4.6 --- routes/system.py | 10 +++++++++- static/css/modes/system.css | 18 ++++++++++++------ static/js/modes/system.js | 14 +++++++------- tests/test_system.py | 11 +++++++---- 4 files changed, 35 insertions(+), 18 deletions(-) diff --git a/routes/system.py b/routes/system.py index 271a2ef..4a96aad 100644 --- a/routes/system.py +++ b/routes/system.py @@ -452,7 +452,7 @@ def _get_observer_location() -> dict[str, Any]: if pos.altitude is not None: gps_meta['altitude'] = round(pos.altitude, 1) - # Fall back to config defaults + # Fall back to config env vars if lat is None: with contextlib.suppress(Exception): from config import DEFAULT_LATITUDE, DEFAULT_LONGITUDE @@ -460,6 +460,14 @@ def _get_observer_location() -> dict[str, Any]: if DEFAULT_LATITUDE != 0.0 or DEFAULT_LONGITUDE != 0.0: lat, lon, source = DEFAULT_LATITUDE, DEFAULT_LONGITUDE, 'config' + # Fall back to hardcoded constants (London) + if lat is None: + with contextlib.suppress(Exception): + from utils.constants import DEFAULT_LATITUDE as CONST_LAT + from utils.constants import DEFAULT_LONGITUDE as CONST_LON + + lat, lon, source = CONST_LAT, CONST_LON, 'default' + result: dict[str, Any] = {'lat': lat, 'lon': lon, 'source': source} if gps_meta: result['gps'] = gps_meta diff --git a/static/css/modes/system.css b/static/css/modes/system.css index 5593690..8d7a484 100644 --- a/static/css/modes/system.css +++ b/static/css/modes/system.css @@ -32,7 +32,6 @@ border: 1px solid var(--border-color, #2a2a4a); border-radius: 6px; padding: 16px; - min-height: 120px; } .sys-card-wide { @@ -464,17 +463,24 @@ background: var(--bg-primary, #0d0d1a); } -/* System info — horizontal layout */ +/* System info — vertical layout to fill card */ .sys-info-grid { display: flex; - flex-wrap: wrap; - gap: 4px 20px; - font-size: 11px; + flex-direction: column; + gap: 6px; + font-size: 12px; color: var(--text-dim, #8888aa); } .sys-info-item { - white-space: nowrap; + display: flex; + justify-content: space-between; + padding: 2px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} + +.sys-info-item:last-child { + border-bottom: none; } .sys-info-item strong { diff --git a/static/js/modes/system.js b/static/js/modes/system.js index 6731806..7bd0be2 100644 --- a/static/js/modes/system.js +++ b/static/js/modes/system.js @@ -612,19 +612,19 @@ const SystemHealth = (function () { var sys = m.system || {}; var html = '
System Info
'; - html += '
Host: ' + escHtml(sys.hostname || '--') + '
'; - html += '
OS: ' + escHtml(sys.platform || '--') + '
'; - html += '
Python: ' + escHtml(sys.python || '--') + '
'; - html += '
App: v' + escHtml(sys.version || '--') + '
'; - html += '
Uptime: ' + escHtml(sys.uptime_human || '--') + '
'; + html += '
Host' + escHtml(sys.hostname || '--') + '
'; + html += '
OS' + escHtml((sys.platform || '--').replace(/-with-glibc[\d.]+/, '')) + '
'; + html += '
Python' + escHtml(sys.python || '--') + '
'; + html += '
Appv' + escHtml(sys.version || '--') + '
'; + html += '
Uptime' + escHtml(sys.uptime_human || '--') + '
'; if (m.boot_time) { var bootDate = new Date(m.boot_time * 1000); - html += '
Boot: ' + escHtml(bootDate.toUTCString()) + '
'; + html += '
Boot' + escHtml(bootDate.toLocaleString()) + '
'; } if (m.network && m.network.connections != null) { - html += '
Connections: ' + m.network.connections + '
'; + html += '
Connections' + m.network.connections + '
'; } html += '
'; diff --git a/tests/test_system.py b/tests/test_system.py index 805ea11..bd5fdac 100644 --- a/tests/test_system.py +++ b/tests/test_system.py @@ -169,14 +169,17 @@ def test_location_from_gps(client): assert data['gps']['altitude'] == 45.0 -def test_location_falls_back_to_config(client): - """Location endpoint returns config defaults when GPS unavailable.""" +def test_location_falls_back_to_defaults(client): + """Location endpoint returns constants defaults when GPS and config unavailable.""" _login(client) - with patch('utils.gps.get_current_position', return_value=None): - resp = client.get('/system/location') + resp = client.get('/system/location') assert resp.status_code == 200 data = resp.get_json() assert 'source' in data + # Should get location from config or default constants + assert data['lat'] is not None + assert data['lon'] is not None + assert data['source'] in ('config', 'default') def test_weather_requires_location(client): From f679433ac06808297b561bc2117d73c62a2aff91 Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 27 Feb 2026 00:00:51 +0000 Subject: [PATCH 04/22] fix: globe rendering, CPU sizing, manual location support - Fix globe destroyed on re-render by preserving canvas DOM node across renderLocationCard() calls instead of recreating from scratch - Reduce globe.gl camera minDistance (180->120) so globe is visible in 200px container - Clear stale globeInstance ref when canvas is gone - Enlarge CPU gauge (90->110px), percentage label (18->22px), core bars (24->48px height), and detail text (11->12px) - JS fetchLocation() now supplements server response with client-side ObserverLocation.getShared() from localStorage when server returns 'default' or 'none', picking up manual coordinates from settings modal - Location priority: GPS > config env vars > manual (localStorage) > default Co-Authored-By: Claude Opus 4.6 --- static/css/modes/system.css | 20 ++++++++-------- static/js/modes/system.js | 48 ++++++++++++++++++++++++++++++------- 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/static/css/modes/system.css b/static/css/modes/system.css index 8d7a484..60b66e4 100644 --- a/static/css/modes/system.css +++ b/static/css/modes/system.css @@ -134,8 +134,8 @@ .sys-gauge-arc { position: relative; - width: 90px; - height: 90px; + width: 110px; + height: 110px; flex-shrink: 0; } @@ -168,7 +168,7 @@ top: 50%; left: 50%; transform: translate(-50%, -50%); - font-size: 18px; + font-size: 22px; font-weight: 700; font-family: var(--font-mono, 'JetBrains Mono', monospace); color: var(--text-primary, #e0e0ff); @@ -176,25 +176,25 @@ .sys-gauge-details { flex: 1; - font-size: 11px; + font-size: 12px; } /* Per-core bars */ .sys-core-bars { display: flex; - gap: 3px; + gap: 4px; align-items: flex-end; - height: 24px; - margin-top: 8px; + height: 48px; + margin-top: 12px; } .sys-core-bar { flex: 1; background: var(--bg-primary, #0d0d1a); - border-radius: 2px; + border-radius: 3px; position: relative; - min-width: 4px; - max-width: 16px; + min-width: 6px; + max-width: 32px; height: 100%; } diff --git a/static/js/modes/system.js b/static/js/modes/system.js index 7bd0be2..af3f3a3 100644 --- a/static/js/modes/system.js +++ b/static/js/modes/system.js @@ -153,7 +153,7 @@ const SystemHealth = (function () { coreHtml = '
'; cpu.per_core.forEach(function (c) { var cls = barClass(c); - var h = Math.max(2, Math.round(c / 100 * 24)); + var h = Math.max(3, Math.round(c / 100 * 48)); coreHtml += '
Location & Weather
'; html += '
'; - // Globe container - html += '
'; + // Globe placeholder (will be replaced with saved node or initialized fresh) + if (!savedGlobe) { + html += '
'; + } else { + html += '
'; + } // Details below globe html += '
'; @@ -437,8 +449,13 @@ const SystemHealth = (function () { html += '
'; el.innerHTML = html; - // Initialize globe after DOM is ready - setTimeout(function () { initGlobe(); }, 50); + // Re-insert saved globe or initialize fresh + if (savedGlobe) { + var placeholder = document.getElementById('sysGlobePlaceholder'); + if (placeholder) placeholder.parentNode.replaceChild(savedGlobe, placeholder); + } else { + setTimeout(function () { initGlobe(); }, 100); + } } // ----------------------------------------------------------------------- @@ -477,9 +494,14 @@ const SystemHealth = (function () { var container = document.getElementById('sysGlobeContainer'); if (!container || globeDestroyed) return; - // Don't reinitialize if globe is already in this container + // Don't reinitialize if globe canvas is still alive in this container if (globeInstance && container.querySelector('canvas')) return; + // Clear stale reference if canvas was destroyed by innerHTML replacement + if (globeInstance && !container.querySelector('canvas')) { + globeInstance = null; + } + ensureGlobeLibrary().then(function (ready) { if (!ready || typeof window.Globe !== 'function' || globeDestroyed) return; @@ -505,8 +527,8 @@ const SystemHealth = (function () { controls.autoRotate = true; controls.autoRotateSpeed = 0.5; controls.enablePan = false; - controls.minDistance = 180; - controls.maxDistance = 400; + controls.minDistance = 120; + controls.maxDistance = 300; } // Size the globe @@ -732,6 +754,16 @@ const SystemHealth = (function () { fetch('/system/location') .then(function (r) { return r.json(); }) .then(function (data) { + // If server only has default/none, check client-side saved location + if ((data.source === 'default' || data.source === 'none') && + window.ObserverLocation && ObserverLocation.getShared) { + var shared = ObserverLocation.getShared(); + if (shared && shared.lat && shared.lon) { + data.lat = shared.lat; + data.lon = shared.lon; + data.source = 'manual'; + } + } locationData = data; updateSidebarLocation(); renderLocationCard(); From f9dc54cc3b0f22d54e779392f6c59dc40481c271 Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 27 Feb 2026 00:04:28 +0000 Subject: [PATCH 05/22] fix: globe init using requestAnimationFrame retry like GPS mode The globe wasn't rendering because initGlobe() used setTimeout(100) which can race with the display:none removal by switchMode(). Both GPS and WebSDR modes use requestAnimationFrame to wait for the browser to compute layout before initializing Globe.gl. - Replace setTimeout with RAF-based retry loop (up to 8 frames) - Add try-catch around Globe() init with fallback message - Match the proven pattern from GPS and WebSDR modes Co-Authored-By: Claude Opus 4.6 --- static/js/modes/system.js | 75 ++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 28 deletions(-) diff --git a/static/js/modes/system.js b/static/js/modes/system.js index af3f3a3..006485f 100644 --- a/static/js/modes/system.js +++ b/static/js/modes/system.js @@ -454,7 +454,7 @@ const SystemHealth = (function () { var placeholder = document.getElementById('sysGlobePlaceholder'); if (placeholder) placeholder.parentNode.replaceChild(savedGlobe, placeholder); } else { - setTimeout(function () { initGlobe(); }, 100); + requestAnimationFrame(function () { initGlobe(); }); } } @@ -505,37 +505,56 @@ const SystemHealth = (function () { ensureGlobeLibrary().then(function (ready) { if (!ready || typeof window.Globe !== 'function' || globeDestroyed) return; - container = document.getElementById('sysGlobeContainer'); - if (!container || !container.clientWidth) return; + // Wait for layout — container may have 0 dimensions right after + // display:none is removed by switchMode(). Use RAF retry like GPS mode. + var attempts = 0; + function tryInit() { + if (globeDestroyed) return; + container = document.getElementById('sysGlobeContainer'); + if (!container) return; - container.innerHTML = ''; - container.style.background = 'radial-gradient(circle, rgba(10,20,40,0.9), rgba(2,4,8,0.98) 70%)'; + if ((!container.clientWidth || !container.clientHeight) && attempts < 8) { + attempts++; + requestAnimationFrame(tryInit); + return; + } + if (!container.clientWidth || !container.clientHeight) return; - globeInstance = window.Globe()(container) - .backgroundColor('rgba(0,0,0,0)') - .globeImageUrl(GLOBE_TEXTURE_URL) - .showAtmosphere(true) - .atmosphereColor('#3bb9ff') - .atmosphereAltitude(0.12) - .pointsData([]) - .pointRadius(0.8) - .pointAltitude(0.01) - .pointColor(function () { return '#00d4ff'; }); + container.innerHTML = ''; + container.style.background = 'radial-gradient(circle, rgba(10,20,40,0.9), rgba(2,4,8,0.98) 70%)'; - var controls = globeInstance.controls(); - if (controls) { - controls.autoRotate = true; - controls.autoRotateSpeed = 0.5; - controls.enablePan = false; - controls.minDistance = 120; - controls.maxDistance = 300; + try { + globeInstance = window.Globe()(container) + .backgroundColor('rgba(0,0,0,0)') + .globeImageUrl(GLOBE_TEXTURE_URL) + .showAtmosphere(true) + .atmosphereColor('#3bb9ff') + .atmosphereAltitude(0.12) + .pointsData([]) + .pointRadius(0.8) + .pointAltitude(0.01) + .pointColor(function () { return '#00d4ff'; }); + + var controls = globeInstance.controls(); + if (controls) { + controls.autoRotate = true; + controls.autoRotateSpeed = 0.5; + controls.enablePan = false; + controls.minDistance = 120; + controls.maxDistance = 300; + } + + // Size the globe + globeInstance.width(container.clientWidth); + globeInstance.height(container.clientHeight); + + updateGlobePosition(); + } catch (e) { + // Globe.gl / WebGL init failed — show static fallback + container.innerHTML = '
Globe unavailable
'; + } } - - // Size the globe - globeInstance.width(container.clientWidth); - globeInstance.height(container.clientHeight); - - updateGlobePosition(); + requestAnimationFrame(tryInit); }); } From 0d13638d7068621fed29149914079574b55e2807 Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 27 Feb 2026 08:43:54 +0000 Subject: [PATCH 06/22] fix: APRS 15-minute startup delay caused by pipe buffering Switch direwolf subprocess output from PIPE to PTY (pseudo-terminal), forcing line-buffered output so packets arrive immediately instead of waiting for a 4-8KB pipe buffer to fill. Matches the proven pattern used by pager mode. Also enhances direwolf config with FIX_BITS error correction and disables unused AGWPE/KISS server ports. Co-Authored-By: Claude Opus 4.6 --- routes/aprs.py | 231 ++++++++++++++++++++++++++++++------------------- 1 file changed, 143 insertions(+), 88 deletions(-) diff --git a/routes/aprs.py b/routes/aprs.py index 606f3a4..3bc77a9 100644 --- a/routes/aprs.py +++ b/routes/aprs.py @@ -5,8 +5,10 @@ from __future__ import annotations import csv import json import os +import pty import queue import re +import select import shutil import subprocess import tempfile @@ -103,6 +105,9 @@ ADEVICE stdin null CHANNEL 0 MYCALL N0CALL MODEM 1200 +FIX_BITS 1 +AGWPORT 0 +KISSPORT 0 """ with open(DIREWOLF_CONFIG_PATH, 'w') as f: f.write(config) @@ -1437,12 +1442,12 @@ def should_send_meter_update(level: int) -> bool: return False -def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None: +def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None: """Stream decoded APRS packets and audio level meter to queue. - This function reads from the decoder's stdout (text mode, line-buffered). - The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks. - rtl_fm's stderr is captured via PIPE with a monitor thread. + Reads from a PTY master fd to get line-buffered output from the decoder, + avoiding the 15-minute pipe buffering delay. Uses select() + os.read() + to poll the PTY (same pattern as pager.py). Outputs two types of messages to the queue: - type='aprs': Decoded APRS packets @@ -1462,93 +1467,114 @@ 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 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 + # Read from PTY using select() for non-blocking reads. + # PTY forces the decoder to line-buffer, so output arrives immediately + # instead of waiting for a full 4-8KB pipe buffer to fill. + buffer = "" + while True: + try: + ready, _, _ = select.select([master_fd], [], [], 1.0) + except Exception: + break - # Check for audio level line first (for signal meter) - audio_level = parse_audio_level(line) - if audio_level is not None: - if should_send_meter_update(audio_level): - meter_msg = { - 'type': 'meter', - 'level': audio_level, - 'ts': datetime.utcnow().isoformat() + 'Z' - } - app_module.aprs_queue.put(meter_msg) - continue # Audio level lines are not packets + if ready: + try: + data = os.read(master_fd, 1024) + if not data: + break + buffer += data.decode('utf-8', errors='replace') + except OSError: + break - # Normalize decoder prefixes (multimon/direwolf) before parsing. - line = normalize_aprs_output_line(line) + while '\n' in buffer: + line, buffer = buffer.split('\n', 1) + line = line.strip() + if not line: + continue - # Skip non-packet lines (APRS format: CALL>PATH:DATA) - if '>' not in line or ':' not in line: - continue + # Check for audio level line first (for signal meter) + audio_level = parse_audio_level(line) + if audio_level is not None: + if should_send_meter_update(audio_level): + meter_msg = { + 'type': 'meter', + 'level': audio_level, + 'ts': datetime.utcnow().isoformat() + 'Z' + } + app_module.aprs_queue.put(meter_msg) + continue # Audio level lines are not packets - packet = parse_aprs_packet(line) - if packet: - aprs_packet_count += 1 - aprs_last_packet_time = time.time() + # Normalize decoder prefixes (multimon/direwolf) before parsing. + line = normalize_aprs_output_line(line) - # Track unique stations - callsign = packet.get('callsign') - if callsign and callsign not in aprs_stations: - aprs_station_count += 1 + # Skip non-packet lines (APRS format: CALL>PATH:DATA) + if '>' not in line or ':' not in line: + continue - # Update station data, preserving last known coordinates when - # packets do not contain position fields. - if callsign: - existing = aprs_stations.get(callsign, {}) - packet_lat = packet.get('lat') - packet_lon = packet.get('lon') - aprs_stations[callsign] = { - 'callsign': callsign, - 'lat': packet_lat if packet_lat is not None else existing.get('lat'), - 'lon': packet_lon if packet_lon is not None else existing.get('lon'), - 'symbol': packet.get('symbol') or existing.get('symbol'), - 'last_seen': packet.get('timestamp'), - 'packet_type': packet.get('packet_type'), - } - # Geofence check - _aprs_lat = packet_lat - _aprs_lon = packet_lon - if _aprs_lat is not None and _aprs_lon is not None: - try: - from utils.geofence import get_geofence_manager - for _gf_evt in get_geofence_manager().check_position( - callsign, 'aprs_station', _aprs_lat, _aprs_lon, - {'callsign': callsign} - ): - process_event('aprs', _gf_evt, 'geofence') - except Exception: - pass - # Evict oldest stations when limit is exceeded - if len(aprs_stations) > APRS_MAX_STATIONS: - oldest = min( - aprs_stations, - key=lambda k: aprs_stations[k].get('last_seen', ''), - ) - del aprs_stations[oldest] + packet = parse_aprs_packet(line) + if packet: + aprs_packet_count += 1 + aprs_last_packet_time = time.time() - app_module.aprs_queue.put(packet) + # Track unique stations + callsign = packet.get('callsign') + if callsign and callsign not in aprs_stations: + aprs_station_count += 1 - # Log if enabled - if app_module.logging_enabled: - try: - with open(app_module.log_file_path, 'a') as f: - ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S') - f.write(f"{ts} | APRS | {json.dumps(packet)}\n") - except Exception: - pass + # Update station data, preserving last known coordinates when + # packets do not contain position fields. + if callsign: + existing = aprs_stations.get(callsign, {}) + packet_lat = packet.get('lat') + packet_lon = packet.get('lon') + aprs_stations[callsign] = { + 'callsign': callsign, + 'lat': packet_lat if packet_lat is not None else existing.get('lat'), + 'lon': packet_lon if packet_lon is not None else existing.get('lon'), + 'symbol': packet.get('symbol') or existing.get('symbol'), + 'last_seen': packet.get('timestamp'), + 'packet_type': packet.get('packet_type'), + } + # Geofence check + _aprs_lat = packet_lat + _aprs_lon = packet_lon + if _aprs_lat is not None and _aprs_lon is not None: + try: + from utils.geofence import get_geofence_manager + for _gf_evt in get_geofence_manager().check_position( + callsign, 'aprs_station', _aprs_lat, _aprs_lon, + {'callsign': callsign} + ): + process_event('aprs', _gf_evt, 'geofence') + except Exception: + pass + # Evict oldest stations when limit is exceeded + if len(aprs_stations) > APRS_MAX_STATIONS: + oldest = min( + aprs_stations, + key=lambda k: aprs_stations[k].get('last_seen', ''), + ) + del aprs_stations[oldest] + + app_module.aprs_queue.put(packet) + + # Log if enabled + if app_module.logging_enabled: + try: + with open(app_module.log_file_path, 'a') as f: + ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + f.write(f"{ts} | APRS | {json.dumps(packet)}\n") + except Exception: + pass except Exception as e: logger.error(f"APRS stream error: {e}") app_module.aprs_queue.put({'type': 'error', 'message': str(e)}) finally: + try: + os.close(master_fd) + except OSError: + pass app_module.aprs_queue.put({'type': 'status', 'status': 'stopped'}) # Cleanup processes for proc in [rtl_process, decoder_process]: @@ -1785,19 +1811,25 @@ def start_aprs() -> Response: rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr, daemon=True) rtl_stderr_thread.start() + # Create a pseudo-terminal for decoder output. PTY forces the + # decoder to line-buffer its stdout, avoiding the 15-minute delay + # caused by full pipe buffering (~4-8KB) on small APRS packets. + master_fd, slave_fd = pty.openpty() + # Start decoder with stdin wired to rtl_fm's stdout. - # 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. + # stdout/stderr go to the PTY slave so output is line-buffered. decoder_process = subprocess.Popen( decoder_cmd, stdin=rtl_process.stdout, - stdout=PIPE, - stderr=STDOUT, + stdout=slave_fd, + stderr=slave_fd, + close_fds=True, start_new_session=True ) + # Close slave fd in parent — decoder owns it now. + os.close(slave_fd) + # Close rtl_fm's stdout in parent so decoder owns it exclusively. # This ensures proper EOF propagation when rtl_fm terminates. rtl_process.stdout.close() @@ -1818,6 +1850,10 @@ def start_aprs() -> Response: if stderr_output: error_msg += f': {stderr_output[:200]}' logger.error(error_msg) + try: + os.close(master_fd) + except OSError: + pass try: decoder_process.kill() except Exception: @@ -1828,13 +1864,23 @@ def start_aprs() -> Response: return jsonify({'status': 'error', 'message': error_msg}), 500 if decoder_process.poll() is not None: - # Decoder exited early - capture any output - 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 '' + # Decoder exited early - capture any output from PTY + error_output = '' + try: + ready, _, _ = select.select([master_fd], [], [], 0.5) + if ready: + raw = os.read(master_fd, 500) + error_output = raw.decode('utf-8', errors='replace') + except Exception: + pass error_msg = f'{decoder_name} failed to start' if error_output: error_msg += f': {error_output}' logger.error(error_msg) + try: + os.close(master_fd) + except OSError: + pass try: rtl_process.kill() except Exception: @@ -1847,11 +1893,12 @@ def start_aprs() -> Response: # Store references for status checks and cleanup app_module.aprs_process = decoder_process app_module.aprs_rtl_process = rtl_process + app_module.aprs_master_fd = master_fd # Start background thread to read decoder output and push to queue thread = threading.Thread( target=stream_aprs_output, - args=(rtl_process, decoder_process), + args=(master_fd, rtl_process, decoder_process), daemon=True ) thread.start() @@ -1902,6 +1949,14 @@ def stop_aprs() -> Response: except Exception as e: logger.error(f"Error stopping APRS process: {e}") + # Close PTY master fd + if hasattr(app_module, 'aprs_master_fd') and app_module.aprs_master_fd is not None: + try: + os.close(app_module.aprs_master_fd) + except OSError: + pass + app_module.aprs_master_fd = None + app_module.aprs_process = None if hasattr(app_module, 'aprs_rtl_process'): app_module.aprs_rtl_process = None From 5aa68a49c65558fb2805a95ab7dc9c76d4134460 Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 27 Feb 2026 09:06:41 +0000 Subject: [PATCH 07/22] fix: SDR device registry collision with multiple SDR types The registry used plain int keys (device index), so HackRF at index 0 and RTL-SDR at index 0 would collide. Changed to composite string keys ("sdr_type:index") so each SDR type+index pair is tracked independently. Updated all route callers, frontend device selectors, and session restore. Co-Authored-By: Claude Opus 4.6 --- app.py | 46 +-- routes/acars.py | 67 +++-- routes/adsb.py | 15 +- routes/ais.py | 15 +- routes/aprs.py | 28 +- routes/dsc.py | 25 +- routes/listening_post.py | 54 ++-- routes/morse.py | 28 +- routes/pager.py | 159 ++++++----- routes/sensor.py | 522 +++++++++++++++++----------------- routes/vdl2.py | 188 ++++++------ routes/waterfall_websocket.py | 21 +- routes/wefax.py | 18 +- templates/adsb_dashboard.html | 80 ++++-- templates/index.html | 31 +- tests/test_morse.py | 12 +- tests/test_wefax.py | 384 ++++++++++++------------- 17 files changed, 907 insertions(+), 786 deletions(-) diff --git a/app.py b/app.py index 1584e83..b74410b 100644 --- a/app.py +++ b/app.py @@ -257,12 +257,12 @@ cleanup_manager.register(deauth_alerts) # SDR DEVICE REGISTRY # ============================================ # Tracks which mode is using which SDR device to prevent conflicts -# Key: device_index (int), Value: mode_name (str) -sdr_device_registry: dict[int, str] = {} +# Key: "sdr_type:device_index" (str), Value: mode_name (str) +sdr_device_registry: dict[str, str] = {} sdr_device_registry_lock = threading.Lock() -def claim_sdr_device(device_index: int, mode_name: str) -> str | None: +def claim_sdr_device(device_index: int, mode_name: str, sdr_type: str = 'rtlsdr') -> str | None: """Claim an SDR device for a mode. Checks the in-app registry first, then probes the USB device to @@ -272,43 +272,48 @@ def claim_sdr_device(device_index: int, mode_name: str) -> str | None: Args: device_index: The SDR device index to claim mode_name: Name of the mode claiming the device (e.g., 'sensor', 'rtlamr') + sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr') Returns: Error message if device is in use, None if successfully claimed """ + key = f"{sdr_type}:{device_index}" with sdr_device_registry_lock: - if device_index in sdr_device_registry: - in_use_by = sdr_device_registry[device_index] - return f'SDR device {device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.' + if key in sdr_device_registry: + in_use_by = sdr_device_registry[key] + return f'SDR device {sdr_type}:{device_index} is in use by {in_use_by}. Stop {in_use_by} first or use a different device.' # Probe the USB device to catch external processes holding the handle - try: - from utils.sdr.detection import probe_rtlsdr_device - usb_error = probe_rtlsdr_device(device_index) - if usb_error: - return usb_error - except Exception: - pass # If probe fails, let the caller proceed normally + if sdr_type == 'rtlsdr': + try: + from utils.sdr.detection import probe_rtlsdr_device + usb_error = probe_rtlsdr_device(device_index) + if usb_error: + return usb_error + except Exception: + pass # If probe fails, let the caller proceed normally - sdr_device_registry[device_index] = mode_name + sdr_device_registry[key] = mode_name return None -def release_sdr_device(device_index: int) -> None: +def release_sdr_device(device_index: int, sdr_type: str = 'rtlsdr') -> None: """Release an SDR device from the registry. Args: device_index: The SDR device index to release + sdr_type: SDR type string (e.g., 'rtlsdr', 'hackrf', 'limesdr') """ + key = f"{sdr_type}:{device_index}" with sdr_device_registry_lock: - sdr_device_registry.pop(device_index, None) + sdr_device_registry.pop(key, None) -def get_sdr_device_status() -> dict[int, str]: +def get_sdr_device_status() -> dict[str, str]: """Get current SDR device allocations. Returns: - Dictionary mapping device indices to mode names + Dictionary mapping 'sdr_type:device_index' keys to mode names """ with sdr_device_registry_lock: return dict(sdr_device_registry) @@ -429,8 +434,9 @@ def get_devices_status() -> Response: result = [] for device in devices: d = device.to_dict() - d['in_use'] = device.index in registry - d['used_by'] = registry.get(device.index) + key = f"{device.sdr_type.value}:{device.index}" + d['in_use'] = key in registry + d['used_by'] = registry.get(key) result.append(d) return jsonify(result) diff --git a/routes/acars.py b/routes/acars.py index 7b9da78..5ffabef 100644 --- a/routes/acars.py +++ b/routes/acars.py @@ -21,7 +21,7 @@ import app as app_module from utils.logging import sensor_logger as logger from utils.validation import validate_device_index, validate_gain, validate_ppm from utils.sdr import SDRFactory, SDRType -from utils.sse import sse_stream_fanout +from utils.sse import sse_stream_fanout from utils.event_pipeline import process_event from utils.constants import ( PROCESS_TERMINATE_TIMEOUT, @@ -45,6 +45,7 @@ acars_last_message_time = None # Track which device is being used acars_active_device: int | None = None +acars_active_sdr_type: str | None = None def find_acarsdec(): @@ -151,7 +152,7 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) - logger.error(f"ACARS stream error: {e}") app_module.acars_queue.put({'type': 'error', 'message': str(e)}) finally: - global acars_active_device + global acars_active_device, acars_active_sdr_type # Ensure process is terminated try: process.terminate() @@ -167,8 +168,9 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) - app_module.acars_process = None # Release SDR device if acars_active_device is not None: - app_module.release_sdr_device(acars_active_device) + app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr') acars_active_device = None + acars_active_sdr_type = None @acars_bp.route('/tools') @@ -200,7 +202,7 @@ def acars_status() -> Response: @acars_bp.route('/start', methods=['POST']) def start_acars() -> Response: """Start ACARS decoder.""" - global acars_message_count, acars_last_message_time, acars_active_device + global acars_message_count, acars_last_message_time, acars_active_device, acars_active_sdr_type with app_module.acars_lock: if app_module.acars_process and app_module.acars_process.poll() is None: @@ -227,9 +229,12 @@ def start_acars() -> Response: except ValueError as e: return jsonify({'status': 'error', 'message': str(e)}), 400 + # Resolve SDR type for device selection + sdr_type_str = data.get('sdr_type', 'rtlsdr') + # Check if device is available device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'acars') + error = app_module.claim_sdr_device(device_int, 'acars', sdr_type_str) if error: return jsonify({ 'status': 'error', @@ -238,6 +243,7 @@ def start_acars() -> Response: }), 409 acars_active_device = device_int + acars_active_sdr_type = sdr_type_str # Get frequencies - use provided or defaults frequencies = data.get('frequencies', DEFAULT_ACARS_FREQUENCIES) @@ -255,8 +261,6 @@ def start_acars() -> Response: acars_message_count = 0 acars_last_message_time = None - # Resolve SDR type for device selection - sdr_type_str = data.get('sdr_type', 'rtlsdr') try: sdr_type = SDRType(sdr_type_str) except ValueError: @@ -343,8 +347,9 @@ def start_acars() -> Response: if process.poll() is not None: # Process died - release device if acars_active_device is not None: - app_module.release_sdr_device(acars_active_device) + app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr') acars_active_device = None + acars_active_sdr_type = None stderr = '' if process.stderr: stderr = process.stderr.read().decode('utf-8', errors='replace') @@ -375,8 +380,9 @@ def start_acars() -> Response: except Exception as e: # Release device on failure if acars_active_device is not None: - app_module.release_sdr_device(acars_active_device) + app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr') acars_active_device = None + acars_active_sdr_type = None logger.error(f"Failed to start ACARS decoder: {e}") return jsonify({'status': 'error', 'message': str(e)}), 500 @@ -384,7 +390,7 @@ def start_acars() -> Response: @acars_bp.route('/stop', methods=['POST']) def stop_acars() -> Response: """Stop ACARS decoder.""" - global acars_active_device + global acars_active_device, acars_active_sdr_type with app_module.acars_lock: if not app_module.acars_process: @@ -405,31 +411,32 @@ def stop_acars() -> Response: # Release device from registry if acars_active_device is not None: - app_module.release_sdr_device(acars_active_device) + app_module.release_sdr_device(acars_active_device, acars_active_sdr_type or 'rtlsdr') acars_active_device = None + acars_active_sdr_type = None return jsonify({'status': 'stopped'}) -@acars_bp.route('/stream') -def stream_acars() -> Response: - """SSE stream for ACARS messages.""" - def _on_msg(msg: dict[str, Any]) -> None: - process_event('acars', msg, msg.get('type')) - - response = Response( - sse_stream_fanout( - source_queue=app_module.acars_queue, - channel_key='acars', - timeout=SSE_QUEUE_TIMEOUT, - keepalive_interval=SSE_KEEPALIVE_INTERVAL, - on_message=_on_msg, - ), - mimetype='text/event-stream', - ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - return response +@acars_bp.route('/stream') +def stream_acars() -> Response: + """SSE stream for ACARS messages.""" + def _on_msg(msg: dict[str, Any]) -> None: + process_event('acars', msg, msg.get('type')) + + response = Response( + sse_stream_fanout( + source_queue=app_module.acars_queue, + channel_key='acars', + timeout=SSE_QUEUE_TIMEOUT, + keepalive_interval=SSE_KEEPALIVE_INTERVAL, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + return response @acars_bp.route('/frequencies') diff --git a/routes/adsb.py b/routes/adsb.py index 08c3186..60cc4a9 100644 --- a/routes/adsb.py +++ b/routes/adsb.py @@ -72,6 +72,7 @@ adsb_last_message_time = None adsb_bytes_received = 0 adsb_lines_received = 0 adsb_active_device = None # Track which device index is being used +adsb_active_sdr_type: str | None = None _sbs_error_logged = False # Suppress repeated connection error logs # Track ICAOs already looked up in aircraft database (avoid repeated lookups) @@ -674,7 +675,7 @@ def adsb_session(): @adsb_bp.route('/start', methods=['POST']) def start_adsb(): """Start ADS-B tracking.""" - global adsb_using_service, adsb_active_device + global adsb_using_service, adsb_active_device, adsb_active_sdr_type with app_module.adsb_lock: if adsb_using_service: @@ -787,7 +788,7 @@ def start_adsb(): # Check if device is available before starting local dump1090 device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'adsb') + error = app_module.claim_sdr_device(device_int, 'adsb', sdr_type_str) if error: return jsonify({ 'status': 'error', @@ -825,7 +826,7 @@ def start_adsb(): if app_module.adsb_process.poll() is not None: # Process exited - release device and get error message - app_module.release_sdr_device(device_int) + app_module.release_sdr_device(device_int, sdr_type_str) stderr_output = '' if app_module.adsb_process.stderr: try: @@ -872,6 +873,7 @@ def start_adsb(): adsb_using_service = True adsb_active_device = device # Track which device is being used + adsb_active_sdr_type = sdr_type_str thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True) thread.start() @@ -891,14 +893,14 @@ def start_adsb(): }) except Exception as e: # Release device on failure - app_module.release_sdr_device(device_int) + app_module.release_sdr_device(device_int, sdr_type_str) return jsonify({'status': 'error', 'message': str(e)}) @adsb_bp.route('/stop', methods=['POST']) def stop_adsb(): """Stop ADS-B tracking.""" - global adsb_using_service, adsb_active_device + global adsb_using_service, adsb_active_device, adsb_active_sdr_type data = request.get_json(silent=True) or {} stop_source = data.get('source') stopped_by = request.remote_addr @@ -923,10 +925,11 @@ def stop_adsb(): # Release device from registry if adsb_active_device is not None: - app_module.release_sdr_device(adsb_active_device) + app_module.release_sdr_device(adsb_active_device, adsb_active_sdr_type or 'rtlsdr') adsb_using_service = False adsb_active_device = None + adsb_active_sdr_type = None app_module.adsb_aircraft.clear() _looked_up_icaos.clear() diff --git a/routes/ais.py b/routes/ais.py index 091d1ae..021fa24 100644 --- a/routes/ais.py +++ b/routes/ais.py @@ -44,6 +44,7 @@ ais_connected = False ais_messages_received = 0 ais_last_message_time = None ais_active_device = None +ais_active_sdr_type: str | None = None _ais_error_logged = True # Common installation paths for AIS-catcher @@ -350,7 +351,7 @@ def ais_status(): @ais_bp.route('/start', methods=['POST']) def start_ais(): """Start AIS tracking.""" - global ais_running, ais_active_device + global ais_running, ais_active_device, ais_active_sdr_type with app_module.ais_lock: if ais_running: @@ -397,7 +398,7 @@ def start_ais(): # Check if device is available device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'ais') + error = app_module.claim_sdr_device(device_int, 'ais', sdr_type_str) if error: return jsonify({ 'status': 'error', @@ -436,7 +437,7 @@ def start_ais(): if app_module.ais_process.poll() is not None: # Release device on failure - app_module.release_sdr_device(device_int) + app_module.release_sdr_device(device_int, sdr_type_str) stderr_output = '' if app_module.ais_process.stderr: try: @@ -450,6 +451,7 @@ def start_ais(): ais_running = True ais_active_device = device + ais_active_sdr_type = sdr_type_str # Start TCP parser thread thread = threading.Thread(target=parse_ais_stream, args=(tcp_port,), daemon=True) @@ -463,7 +465,7 @@ def start_ais(): }) except Exception as e: # Release device on failure - app_module.release_sdr_device(device_int) + app_module.release_sdr_device(device_int, sdr_type_str) logger.error(f"Failed to start AIS-catcher: {e}") return jsonify({'status': 'error', 'message': str(e)}), 500 @@ -471,7 +473,7 @@ def start_ais(): @ais_bp.route('/stop', methods=['POST']) def stop_ais(): """Stop AIS tracking.""" - global ais_running, ais_active_device + global ais_running, ais_active_device, ais_active_sdr_type with app_module.ais_lock: if app_module.ais_process: @@ -490,10 +492,11 @@ def stop_ais(): # Release device from registry if ais_active_device is not None: - app_module.release_sdr_device(ais_active_device) + app_module.release_sdr_device(ais_active_device, ais_active_sdr_type or 'rtlsdr') ais_running = False ais_active_device = None + ais_active_sdr_type = None app_module.ais_vessels.clear() return jsonify({'status': 'stopped'}) diff --git a/routes/aprs.py b/routes/aprs.py index 3bc77a9..93ce5e9 100644 --- a/routes/aprs.py +++ b/routes/aprs.py @@ -37,6 +37,7 @@ aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs') # Track which SDR device is being used aprs_active_device: int | None = None +aprs_active_sdr_type: str | None = None # APRS frequencies by region (MHz) APRS_FREQUENCIES = { @@ -1454,7 +1455,7 @@ def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_pr - type='meter': Audio level meter readings (rate-limited) """ global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations - global _last_meter_time, _last_meter_level, aprs_active_device + global _last_meter_time, _last_meter_level, aprs_active_device, aprs_active_sdr_type # Capture the device claimed by THIS session so the finally block only # releases our own device, not one claimed by a subsequent start. @@ -1588,8 +1589,9 @@ def stream_aprs_output(master_fd: int, rtl_process: subprocess.Popen, decoder_pr pass # Release SDR device — only if it's still ours (not reclaimed by a new start) if my_device is not None and aprs_active_device == my_device: - app_module.release_sdr_device(my_device) + app_module.release_sdr_device(my_device, aprs_active_sdr_type or 'rtlsdr') aprs_active_device = None + aprs_active_sdr_type = None @aprs_bp.route('/tools') @@ -1658,7 +1660,7 @@ def aprs_data() -> Response: def start_aprs() -> Response: """Start APRS decoder.""" global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations - global aprs_active_device + global aprs_active_device, aprs_active_sdr_type with app_module.aprs_lock: if app_module.aprs_process and app_module.aprs_process.poll() is None: @@ -1707,7 +1709,7 @@ def start_aprs() -> Response: }), 400 # Reserve SDR device to prevent conflicts with other modes - error = app_module.claim_sdr_device(device, 'aprs') + error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str) if error: return jsonify({ 'status': 'error', @@ -1715,6 +1717,7 @@ def start_aprs() -> Response: 'message': error }), 409 aprs_active_device = device + aprs_active_sdr_type = sdr_type_str # Get frequency for region region = data.get('region', 'north_america') @@ -1756,8 +1759,9 @@ def start_aprs() -> Response: rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-'] except Exception as e: if aprs_active_device is not None: - app_module.release_sdr_device(aprs_active_device) + app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr') aprs_active_device = None + aprs_active_sdr_type = None return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500 # Build decoder command @@ -1859,8 +1863,9 @@ def start_aprs() -> Response: except Exception: pass if aprs_active_device is not None: - app_module.release_sdr_device(aprs_active_device) + app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr') aprs_active_device = None + aprs_active_sdr_type = None return jsonify({'status': 'error', 'message': error_msg}), 500 if decoder_process.poll() is not None: @@ -1886,8 +1891,9 @@ def start_aprs() -> Response: except Exception: pass if aprs_active_device is not None: - app_module.release_sdr_device(aprs_active_device) + app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr') aprs_active_device = None + aprs_active_sdr_type = None return jsonify({'status': 'error', 'message': error_msg}), 500 # Store references for status checks and cleanup @@ -1915,15 +1921,16 @@ def start_aprs() -> Response: except Exception as e: logger.error(f"Failed to start APRS decoder: {e}") if aprs_active_device is not None: - app_module.release_sdr_device(aprs_active_device) + app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr') aprs_active_device = None + aprs_active_sdr_type = None return jsonify({'status': 'error', 'message': str(e)}), 500 @aprs_bp.route('/stop', methods=['POST']) def stop_aprs() -> Response: """Stop APRS decoder.""" - global aprs_active_device + global aprs_active_device, aprs_active_sdr_type with app_module.aprs_lock: processes_to_stop = [] @@ -1963,8 +1970,9 @@ def stop_aprs() -> Response: # Release SDR device if aprs_active_device is not None: - app_module.release_sdr_device(aprs_active_device) + app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr') aprs_active_device = None + aprs_active_sdr_type = None return jsonify({'status': 'stopped'}) diff --git a/routes/dsc.py b/routes/dsc.py index cbdbe33..5ef13d1 100644 --- a/routes/dsc.py +++ b/routes/dsc.py @@ -51,6 +51,7 @@ dsc_running = False # Track which device is being used dsc_active_device: int | None = None +dsc_active_sdr_type: str | None = None def _get_dsc_decoder_path() -> str | None: @@ -171,7 +172,7 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non 'error': str(e) }) finally: - global dsc_active_device + global dsc_active_device, dsc_active_sdr_type try: os.close(master_fd) except OSError: @@ -197,8 +198,9 @@ def stream_dsc_decoder(master_fd: int, decoder_process: subprocess.Popen) -> Non app_module.dsc_rtl_process = None # Release SDR device if dsc_active_device is not None: - app_module.release_sdr_device(dsc_active_device) + app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr') dsc_active_device = None + dsc_active_sdr_type = None def _store_critical_alert(msg: dict) -> None: @@ -331,10 +333,13 @@ def start_decoding() -> Response: 'message': str(e) }), 400 + # Get SDR type from request + sdr_type_str = data.get('sdr_type', 'rtlsdr') + # Check if device is available using centralized registry - global dsc_active_device + global dsc_active_device, dsc_active_sdr_type device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'dsc') + error = app_module.claim_sdr_device(device_int, 'dsc', sdr_type_str) if error: return jsonify({ 'status': 'error', @@ -343,6 +348,7 @@ def start_decoding() -> Response: }), 409 dsc_active_device = device_int + dsc_active_sdr_type = sdr_type_str # Clear queue while not app_module.dsc_queue.empty(): @@ -440,8 +446,9 @@ def start_decoding() -> Response: pass # Release device on failure if dsc_active_device is not None: - app_module.release_sdr_device(dsc_active_device) + app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr') dsc_active_device = None + dsc_active_sdr_type = None return jsonify({ 'status': 'error', 'message': f'Tool not found: {e.filename}' @@ -458,8 +465,9 @@ def start_decoding() -> Response: pass # Release device on failure if dsc_active_device is not None: - app_module.release_sdr_device(dsc_active_device) + app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr') dsc_active_device = None + dsc_active_sdr_type = None logger.error(f"Failed to start DSC decoder: {e}") return jsonify({ 'status': 'error', @@ -470,7 +478,7 @@ def start_decoding() -> Response: @dsc_bp.route('/stop', methods=['POST']) def stop_decoding() -> Response: """Stop DSC decoder.""" - global dsc_running, dsc_active_device + global dsc_running, dsc_active_device, dsc_active_sdr_type with app_module.dsc_lock: if not app_module.dsc_process: @@ -509,8 +517,9 @@ def stop_decoding() -> Response: # Release device from registry if dsc_active_device is not None: - app_module.release_sdr_device(dsc_active_device) + app_module.release_sdr_device(dsc_active_device, dsc_active_sdr_type or 'rtlsdr') dsc_active_device = None + dsc_active_sdr_type = None return jsonify({'status': 'stopped'}) diff --git a/routes/listening_post.py b/routes/listening_post.py index 1ae8428..b59bb40 100644 --- a/routes/listening_post.py +++ b/routes/listening_post.py @@ -55,7 +55,9 @@ scanner_lock = threading.Lock() scanner_paused = False scanner_current_freq = 0.0 scanner_active_device: Optional[int] = None +scanner_active_sdr_type: str = 'rtlsdr' receiver_active_device: Optional[int] = None +receiver_active_sdr_type: str = 'rtlsdr' scanner_power_process: Optional[subprocess.Popen] = None scanner_config = { 'start_freq': 88.0, @@ -996,7 +998,7 @@ def check_tools() -> Response: @receiver_bp.route('/scanner/start', methods=['POST']) def start_scanner() -> Response: """Start the frequency scanner.""" - global scanner_thread, scanner_running, scanner_config, scanner_active_device, receiver_active_device + global scanner_thread, scanner_running, scanner_config, scanner_active_device, scanner_active_sdr_type, receiver_active_device, receiver_active_sdr_type with scanner_lock: if scanner_running: @@ -1063,10 +1065,11 @@ def start_scanner() -> Response: }), 503 # Release listening device if active if receiver_active_device is not None: - app_module.release_sdr_device(receiver_active_device) + app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type) receiver_active_device = None + receiver_active_sdr_type = 'rtlsdr' # Claim device for scanner - error = app_module.claim_sdr_device(scanner_config['device'], 'scanner') + error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', scanner_config['sdr_type']) if error: return jsonify({ 'status': 'error', @@ -1074,6 +1077,7 @@ def start_scanner() -> Response: 'message': error }), 409 scanner_active_device = scanner_config['device'] + scanner_active_sdr_type = scanner_config['sdr_type'] scanner_running = True scanner_thread = threading.Thread(target=scanner_loop_power, daemon=True) scanner_thread.start() @@ -1091,9 +1095,10 @@ def start_scanner() -> Response: 'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.' }), 503 if receiver_active_device is not None: - app_module.release_sdr_device(receiver_active_device) + app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type) receiver_active_device = None - error = app_module.claim_sdr_device(scanner_config['device'], 'scanner') + receiver_active_sdr_type = 'rtlsdr' + error = app_module.claim_sdr_device(scanner_config['device'], 'scanner', scanner_config['sdr_type']) if error: return jsonify({ 'status': 'error', @@ -1101,6 +1106,7 @@ def start_scanner() -> Response: 'message': error }), 409 scanner_active_device = scanner_config['device'] + scanner_active_sdr_type = scanner_config['sdr_type'] scanner_running = True scanner_thread = threading.Thread(target=scanner_loop, daemon=True) @@ -1115,7 +1121,7 @@ def start_scanner() -> Response: @receiver_bp.route('/scanner/stop', methods=['POST']) def stop_scanner() -> Response: """Stop the frequency scanner.""" - global scanner_running, scanner_active_device, scanner_power_process + global scanner_running, scanner_active_device, scanner_active_sdr_type, scanner_power_process scanner_running = False _stop_audio_stream() @@ -1130,8 +1136,9 @@ def stop_scanner() -> Response: pass scanner_power_process = None if scanner_active_device is not None: - app_module.release_sdr_device(scanner_active_device) + app_module.release_sdr_device(scanner_active_device, scanner_active_sdr_type) scanner_active_device = None + scanner_active_sdr_type = 'rtlsdr' return jsonify({'status': 'stopped'}) @@ -1296,7 +1303,7 @@ def get_presets() -> Response: @receiver_bp.route('/audio/start', methods=['POST']) def start_audio() -> Response: """Start audio at specific frequency (manual mode).""" - global scanner_running, scanner_active_device, receiver_active_device, scanner_power_process, scanner_thread + global scanner_running, scanner_active_device, scanner_active_sdr_type, receiver_active_device, receiver_active_sdr_type, scanner_power_process, scanner_thread global audio_running, audio_frequency, audio_modulation, audio_source, audio_start_token data = request.json or {} @@ -1356,8 +1363,9 @@ def start_audio() -> Response: if scanner_running: scanner_running = False if scanner_active_device is not None: - app_module.release_sdr_device(scanner_active_device) + app_module.release_sdr_device(scanner_active_device, scanner_active_sdr_type) scanner_active_device = None + scanner_active_sdr_type = 'rtlsdr' scanner_thread_ref = scanner_thread scanner_proc_ref = scanner_power_process scanner_power_process = None @@ -1419,8 +1427,9 @@ def start_audio() -> Response: audio_source = 'waterfall' # Shared monitor uses the waterfall's existing SDR claim. if receiver_active_device is not None: - app_module.release_sdr_device(receiver_active_device) + app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type) receiver_active_device = None + receiver_active_sdr_type = 'rtlsdr' return jsonify({ 'status': 'started', 'frequency': frequency, @@ -1443,13 +1452,14 @@ def start_audio() -> Response: # to give the USB device time to be fully released. if receiver_active_device is None or receiver_active_device != device: if receiver_active_device is not None: - app_module.release_sdr_device(receiver_active_device) + app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type) receiver_active_device = None + receiver_active_sdr_type = 'rtlsdr' error = None max_claim_attempts = 6 for attempt in range(max_claim_attempts): - error = app_module.claim_sdr_device(device, 'receiver') + error = app_module.claim_sdr_device(device, 'receiver', sdr_type) if not error: break if attempt < max_claim_attempts - 1: @@ -1466,6 +1476,7 @@ def start_audio() -> Response: 'message': error }), 409 receiver_active_device = device + receiver_active_sdr_type = sdr_type _start_audio_stream( frequency, @@ -1489,8 +1500,9 @@ def start_audio() -> Response: # Avoid leaving a stale device claim after startup failure. if receiver_active_device is not None: - app_module.release_sdr_device(receiver_active_device) + app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type) receiver_active_device = None + receiver_active_sdr_type = 'rtlsdr' start_error = '' for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'): @@ -1515,11 +1527,12 @@ def start_audio() -> Response: @receiver_bp.route('/audio/stop', methods=['POST']) def stop_audio() -> Response: """Stop audio.""" - global receiver_active_device + global receiver_active_device, receiver_active_sdr_type _stop_audio_stream() if receiver_active_device is not None: - app_module.release_sdr_device(receiver_active_device) + app_module.release_sdr_device(receiver_active_device, receiver_active_sdr_type) receiver_active_device = None + receiver_active_sdr_type = 'rtlsdr' return jsonify({'status': 'stopped'}) @@ -1825,6 +1838,7 @@ waterfall_running = False waterfall_lock = threading.Lock() waterfall_queue: queue.Queue = queue.Queue(maxsize=200) waterfall_active_device: Optional[int] = None +waterfall_active_sdr_type: str = 'rtlsdr' waterfall_config = { 'start_freq': 88.0, 'end_freq': 108.0, @@ -2033,7 +2047,7 @@ def _waterfall_loop(): def _stop_waterfall_internal() -> None: """Stop the waterfall display and release resources.""" - global waterfall_running, waterfall_process, waterfall_active_device + global waterfall_running, waterfall_process, waterfall_active_device, waterfall_active_sdr_type waterfall_running = False if waterfall_process and waterfall_process.poll() is None: @@ -2048,14 +2062,15 @@ def _stop_waterfall_internal() -> None: waterfall_process = None if waterfall_active_device is not None: - app_module.release_sdr_device(waterfall_active_device) + app_module.release_sdr_device(waterfall_active_device, waterfall_active_sdr_type) waterfall_active_device = None + waterfall_active_sdr_type = 'rtlsdr' @receiver_bp.route('/waterfall/start', methods=['POST']) def start_waterfall() -> Response: """Start the waterfall/spectrogram display.""" - global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device + global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device, waterfall_active_sdr_type with waterfall_lock: if waterfall_running: @@ -2101,11 +2116,12 @@ def start_waterfall() -> Response: pass # Claim SDR device - error = app_module.claim_sdr_device(waterfall_config['device'], 'waterfall') + error = app_module.claim_sdr_device(waterfall_config['device'], 'waterfall', 'rtlsdr') if error: return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409 waterfall_active_device = waterfall_config['device'] + waterfall_active_sdr_type = 'rtlsdr' waterfall_running = True waterfall_thread = threading.Thread(target=_waterfall_loop, daemon=True) waterfall_thread.start() diff --git a/routes/morse.py b/routes/morse.py index 5491979..42c1818 100644 --- a/routes/morse.py +++ b/routes/morse.py @@ -51,6 +51,7 @@ class _FilteredQueue: # Track which device is being used morse_active_device: int | None = None +morse_active_sdr_type: str | None = None # Runtime lifecycle state. MORSE_IDLE = 'idle' @@ -231,7 +232,7 @@ def _snapshot_live_resources() -> list[str]: @morse_bp.route('/morse/start', methods=['POST']) def start_morse() -> Response: - global morse_active_device, morse_decoder_worker, morse_stderr_worker + global morse_active_device, morse_active_sdr_type, morse_decoder_worker, morse_stderr_worker global morse_stop_event, morse_control_queue, morse_runtime_config global morse_last_error, morse_session_id @@ -261,6 +262,8 @@ def start_morse() -> Response: except ValueError as e: return jsonify({'status': 'error', 'message': str(e)}), 400 + sdr_type_str = data.get('sdr_type', 'rtlsdr') + with app_module.morse_lock: if morse_state in {MORSE_STARTING, MORSE_RUNNING, MORSE_STOPPING}: return jsonify({ @@ -270,7 +273,7 @@ def start_morse() -> Response: }), 409 device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'morse') + error = app_module.claim_sdr_device(device_int, 'morse', sdr_type_str) if error: return jsonify({ 'status': 'error', @@ -279,6 +282,7 @@ def start_morse() -> Response: }), 409 morse_active_device = device_int + morse_active_sdr_type = sdr_type_str morse_last_error = '' morse_session_id += 1 @@ -288,7 +292,6 @@ def start_morse() -> Response: sample_rate = 22050 bias_t = _bool_value(data.get('bias_t', False), False) - sdr_type_str = data.get('sdr_type', 'rtlsdr') try: sdr_type = SDRType(sdr_type_str) except ValueError: @@ -408,7 +411,7 @@ def start_morse() -> Response: for device_pos, candidate_device_index in enumerate(candidate_device_indices, start=1): if candidate_device_index != active_device_index: prev_device = active_device_index - claim_error = app_module.claim_sdr_device(candidate_device_index, 'morse') + claim_error = app_module.claim_sdr_device(candidate_device_index, 'morse', sdr_type_str) if claim_error: msg = f'{_device_label(candidate_device_index)} unavailable: {claim_error}' attempt_errors.append(msg) @@ -417,7 +420,7 @@ def start_morse() -> Response: continue if prev_device is not None: - app_module.release_sdr_device(prev_device) + app_module.release_sdr_device(prev_device, morse_active_sdr_type or 'rtlsdr') active_device_index = candidate_device_index with app_module.morse_lock: morse_active_device = active_device_index @@ -634,8 +637,9 @@ def start_morse() -> Response: logger.error('Morse startup failed: %s', msg) with app_module.morse_lock: if morse_active_device is not None: - app_module.release_sdr_device(morse_active_device) + app_module.release_sdr_device(morse_active_device, morse_active_sdr_type or 'rtlsdr') morse_active_device = None + morse_active_sdr_type = None morse_last_error = msg _set_state(MORSE_ERROR, msg) _set_state(MORSE_IDLE, 'Idle') @@ -675,8 +679,9 @@ def start_morse() -> Response: ) with app_module.morse_lock: if morse_active_device is not None: - app_module.release_sdr_device(morse_active_device) + app_module.release_sdr_device(morse_active_device, morse_active_sdr_type or 'rtlsdr') morse_active_device = None + morse_active_sdr_type = None morse_last_error = f'Tool not found: {e.filename}' _set_state(MORSE_ERROR, morse_last_error) _set_state(MORSE_IDLE, 'Idle') @@ -692,8 +697,9 @@ def start_morse() -> Response: ) with app_module.morse_lock: if morse_active_device is not None: - app_module.release_sdr_device(morse_active_device) + app_module.release_sdr_device(morse_active_device, morse_active_sdr_type or 'rtlsdr') morse_active_device = None + morse_active_sdr_type = None morse_last_error = str(e) _set_state(MORSE_ERROR, morse_last_error) _set_state(MORSE_IDLE, 'Idle') @@ -702,7 +708,7 @@ def start_morse() -> Response: @morse_bp.route('/morse/stop', methods=['POST']) def stop_morse() -> Response: - global morse_active_device, morse_decoder_worker, morse_stderr_worker + global morse_active_device, morse_active_sdr_type, morse_decoder_worker, morse_stderr_worker global morse_stop_event, morse_control_queue stop_started = time.perf_counter() @@ -717,6 +723,7 @@ def stop_morse() -> Response: stderr_thread = morse_stderr_worker or getattr(rtl_proc, '_stderr_thread', None) control_queue = morse_control_queue or getattr(rtl_proc, '_control_queue', None) active_device = morse_active_device + active_sdr_type = morse_active_sdr_type if ( not rtl_proc @@ -768,7 +775,7 @@ def stop_morse() -> Response: _mark(f'stderr thread joined={stderr_joined}') if active_device is not None: - app_module.release_sdr_device(active_device) + app_module.release_sdr_device(active_device, active_sdr_type or 'rtlsdr') _mark(f'SDR device {active_device} released') stop_ms = round((time.perf_counter() - stop_started) * 1000.0, 1) @@ -782,6 +789,7 @@ def stop_morse() -> Response: with app_module.morse_lock: morse_active_device = None + morse_active_sdr_type = None _set_state(MORSE_IDLE, 'Stopped', extra={ 'stop_ms': stop_ms, 'cleanup_steps': cleanup_steps, diff --git a/routes/pager.py b/routes/pager.py index 6dfcc3b..7777ced 100644 --- a/routes/pager.py +++ b/routes/pager.py @@ -24,7 +24,7 @@ from utils.validation import ( validate_frequency, validate_device_index, validate_gain, validate_ppm, validate_rtl_tcp_host, validate_rtl_tcp_port ) -from utils.sse import sse_stream_fanout +from utils.sse import sse_stream_fanout from utils.event_pipeline import process_event from utils.process import safe_terminate, register_process, unregister_process from utils.sdr import SDRFactory, SDRType, SDRValidationError @@ -34,6 +34,7 @@ pager_bp = Blueprint('pager', __name__) # Track which device is being used pager_active_device: int | None = None +pager_active_sdr_type: str | None = None def parse_multimon_output(line: str) -> dict[str, str] | None: @@ -96,7 +97,7 @@ def parse_multimon_output(line: str) -> dict[str, str] | None: return None -def log_message(msg: dict[str, Any]) -> None: +def log_message(msg: dict[str, Any]) -> None: """Log a message to file if logging is enabled.""" if not app_module.logging_enabled: return @@ -104,39 +105,39 @@ def log_message(msg: dict[str, Any]) -> None: with open(app_module.log_file_path, 'a') as f: timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n") - except Exception as e: - logger.error(f"Failed to log message: {e}") - - -def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]: - """Compress recent PCM samples into a signed 8-bit waveform for SSE.""" - if not samples: - return [] - - window = samples[-window_size:] if len(samples) > window_size else samples - waveform: list[int] = [] - for sample in window: - # Convert int16 PCM to int8 range for lightweight transport. - packed = int(round(sample / 256)) - waveform.append(max(-127, min(127, packed))) - return waveform - - -def audio_relay_thread( - rtl_stdout, - multimon_stdin, - output_queue: queue.Queue, - stop_event: threading.Event, -) -> None: - """Relay audio from rtl_fm to multimon-ng while computing signal levels. - - Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight - through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope - event plus a compact waveform sample onto *output_queue*. - """ - CHUNK = 4096 # bytes – 2048 samples at 16-bit mono - INTERVAL = 0.1 # seconds between scope updates - last_scope = time.monotonic() + except Exception as e: + logger.error(f"Failed to log message: {e}") + + +def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]: + """Compress recent PCM samples into a signed 8-bit waveform for SSE.""" + if not samples: + return [] + + window = samples[-window_size:] if len(samples) > window_size else samples + waveform: list[int] = [] + for sample in window: + # Convert int16 PCM to int8 range for lightweight transport. + packed = int(round(sample / 256)) + waveform.append(max(-127, min(127, packed))) + return waveform + + +def audio_relay_thread( + rtl_stdout, + multimon_stdin, + output_queue: queue.Queue, + stop_event: threading.Event, +) -> None: + """Relay audio from rtl_fm to multimon-ng while computing signal levels. + + Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight + through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope + event plus a compact waveform sample onto *output_queue*. + """ + CHUNK = 4096 # bytes – 2048 samples at 16-bit mono + INTERVAL = 0.1 # seconds between scope updates + last_scope = time.monotonic() try: while not stop_event.is_set(): @@ -160,16 +161,16 @@ def audio_relay_thread( if n_samples == 0: continue samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2]) - peak = max(abs(s) for s in samples) - rms = int(math.sqrt(sum(s * s for s in samples) / n_samples)) - output_queue.put_nowait({ - 'type': 'scope', - 'rms': rms, - 'peak': peak, - 'waveform': _encode_scope_waveform(samples), - }) - except (struct.error, ValueError, queue.Full): - pass + peak = max(abs(s) for s in samples) + rms = int(math.sqrt(sum(s * s for s in samples) / n_samples)) + output_queue.put_nowait({ + 'type': 'scope', + 'rms': rms, + 'peak': peak, + 'waveform': _encode_scope_waveform(samples), + }) + except (struct.error, ValueError, queue.Full): + pass except Exception as e: logger.debug(f"Audio relay error: {e}") finally: @@ -220,7 +221,7 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None: except Exception as e: app_module.output_queue.put({'type': 'error', 'text': str(e)}) finally: - global pager_active_device + global pager_active_device, pager_active_sdr_type try: os.close(master_fd) except OSError: @@ -249,13 +250,14 @@ def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None: app_module.current_process = None # Release SDR device if pager_active_device is not None: - app_module.release_sdr_device(pager_active_device) + app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') pager_active_device = None + pager_active_sdr_type = None @pager_bp.route('/start', methods=['POST']) def start_decoding() -> Response: - global pager_active_device + global pager_active_device, pager_active_sdr_type with app_module.process_lock: if app_module.current_process: @@ -284,10 +286,13 @@ def start_decoding() -> Response: rtl_tcp_host = data.get('rtl_tcp_host') rtl_tcp_port = data.get('rtl_tcp_port', 1234) + # Get SDR type early so we can pass it to claim/release + sdr_type_str = data.get('sdr_type', 'rtlsdr') + # Claim local device if not using remote rtl_tcp if not rtl_tcp_host: device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'pager') + error = app_module.claim_sdr_device(device_int, 'pager', sdr_type_str) if error: return jsonify({ 'status': 'error', @@ -295,14 +300,16 @@ def start_decoding() -> Response: 'message': error }), 409 pager_active_device = device_int + pager_active_sdr_type = sdr_type_str # Validate protocols valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX'] protocols = data.get('protocols', valid_protocols) if not isinstance(protocols, list): if pager_active_device is not None: - app_module.release_sdr_device(pager_active_device) + app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') pager_active_device = None + pager_active_sdr_type = None return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400 protocols = [p for p in protocols if p in valid_protocols] if not protocols: @@ -327,8 +334,7 @@ def start_decoding() -> Response: elif proto == 'FLEX': decoders.extend(['-a', 'FLEX']) - # Get SDR type and build command via abstraction layer - sdr_type_str = data.get('sdr_type', 'rtlsdr') + # Build command via SDR abstraction layer try: sdr_type = SDRType(sdr_type_str) except ValueError: @@ -443,8 +449,9 @@ def start_decoding() -> Response: pass # Release device on failure if pager_active_device is not None: - app_module.release_sdr_device(pager_active_device) + app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') pager_active_device = None + pager_active_sdr_type = None return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'}) except Exception as e: # Kill orphaned rtl_fm process if it was started @@ -458,14 +465,15 @@ def start_decoding() -> Response: pass # Release device on failure if pager_active_device is not None: - app_module.release_sdr_device(pager_active_device) + app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') pager_active_device = None + pager_active_sdr_type = None return jsonify({'status': 'error', 'message': str(e)}) @pager_bp.route('/stop', methods=['POST']) def stop_decoding() -> Response: - global pager_active_device + global pager_active_device, pager_active_sdr_type with app_module.process_lock: if app_module.current_process: @@ -502,8 +510,9 @@ def stop_decoding() -> Response: # Release device from registry if pager_active_device is not None: - app_module.release_sdr_device(pager_active_device) + app_module.release_sdr_device(pager_active_device, pager_active_sdr_type or 'rtlsdr') pager_active_device = None + pager_active_sdr_type = None return jsonify({'status': 'stopped'}) @@ -553,22 +562,22 @@ def toggle_logging() -> Response: return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path}) -@pager_bp.route('/stream') -def stream() -> Response: - def _on_msg(msg: dict[str, Any]) -> None: - process_event('pager', msg, msg.get('type')) - - response = Response( - sse_stream_fanout( - source_queue=app_module.output_queue, - channel_key='pager', - timeout=1.0, - keepalive_interval=30.0, - on_message=_on_msg, - ), - mimetype='text/event-stream', - ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - response.headers['Connection'] = 'keep-alive' - return response +@pager_bp.route('/stream') +def stream() -> Response: + def _on_msg(msg: dict[str, Any]) -> None: + process_event('pager', msg, msg.get('type')) + + response = Response( + sse_stream_fanout( + source_queue=app_module.output_queue, + channel_key='pager', + timeout=1.0, + keepalive_interval=30.0, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + response.headers['Connection'] = 'keep-alive' + return response diff --git a/routes/sensor.py b/routes/sensor.py index ab34c8e..29026fa 100644 --- a/routes/sensor.py +++ b/routes/sensor.py @@ -1,5 +1,5 @@ -"""RTL_433 sensor monitoring routes.""" - +"""RTL_433 sensor monitoring routes.""" + from __future__ import annotations import json @@ -10,25 +10,26 @@ import threading import time from datetime import datetime from typing import Any, Generator - -from flask import Blueprint, jsonify, request, Response - -import app as app_module -from utils.logging import sensor_logger as logger -from utils.validation import ( - validate_frequency, validate_device_index, validate_gain, validate_ppm, - validate_rtl_tcp_host, validate_rtl_tcp_port -) + +from flask import Blueprint, jsonify, request, Response + +import app as app_module +from utils.logging import sensor_logger as logger +from utils.validation import ( + validate_frequency, validate_device_index, validate_gain, validate_ppm, + validate_rtl_tcp_host, validate_rtl_tcp_port +) from utils.sse import sse_stream_fanout -from utils.event_pipeline import process_event -from utils.process import safe_terminate, register_process, unregister_process -from utils.sdr import SDRFactory, SDRType - -sensor_bp = Blueprint('sensor', __name__) - -# Track which device is being used -sensor_active_device: int | None = None - +from utils.event_pipeline import process_event +from utils.process import safe_terminate, register_process, unregister_process +from utils.sdr import SDRFactory, SDRType + +sensor_bp = Blueprint('sensor', __name__) + +# Track which device is being used +sensor_active_device: int | None = None +sensor_active_sdr_type: str | None = None + # RSSI history per device (model_id -> list of (timestamp, rssi)) sensor_rssi_history: dict[str, list[tuple[float, float]]] = {} _MAX_RSSI_HISTORY = 60 @@ -65,36 +66,36 @@ def _build_scope_waveform(rssi: float, snr: float, noise: float, points: int = 2 def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: - """Stream rtl_433 JSON output to queue.""" - try: - app_module.sensor_queue.put({'type': 'status', 'text': 'started'}) - - for line in iter(process.stdout.readline, b''): - line = line.decode('utf-8', errors='replace').strip() - if not line: - continue - - try: - # rtl_433 outputs JSON objects, one per line - data = json.loads(line) - data['type'] = 'sensor' - app_module.sensor_queue.put(data) - - # Track RSSI history per device - _model = data.get('model', '') - _dev_id = data.get('id', '') - _rssi_val = data.get('rssi') - if _rssi_val is not None and _model: - _hist_key = f"{_model}_{_dev_id}" - hist = sensor_rssi_history.setdefault(_hist_key, []) - hist.append((time.time(), float(_rssi_val))) - if len(hist) > _MAX_RSSI_HISTORY: - del hist[: len(hist) - _MAX_RSSI_HISTORY] - - # Push scope event when signal level data is present - rssi = data.get('rssi') - snr = data.get('snr') - noise = data.get('noise') + """Stream rtl_433 JSON output to queue.""" + try: + app_module.sensor_queue.put({'type': 'status', 'text': 'started'}) + + for line in iter(process.stdout.readline, b''): + line = line.decode('utf-8', errors='replace').strip() + if not line: + continue + + try: + # rtl_433 outputs JSON objects, one per line + data = json.loads(line) + data['type'] = 'sensor' + app_module.sensor_queue.put(data) + + # Track RSSI history per device + _model = data.get('model', '') + _dev_id = data.get('id', '') + _rssi_val = data.get('rssi') + if _rssi_val is not None and _model: + _hist_key = f"{_model}_{_dev_id}" + hist = sensor_rssi_history.setdefault(_hist_key, []) + hist.append((time.time(), float(_rssi_val))) + if len(hist) > _MAX_RSSI_HISTORY: + del hist[: len(hist) - _MAX_RSSI_HISTORY] + + # Push scope event when signal level data is present + rssi = data.get('rssi') + snr = data.get('snr') + noise = data.get('noise') if rssi is not None or snr is not None: try: rssi_value = float(rssi) if rssi is not None else 0.0 @@ -113,204 +114,211 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: }) except (TypeError, ValueError, queue.Full): pass - - # Log if enabled - if app_module.logging_enabled: - try: - with open(app_module.log_file_path, 'a') as f: - timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') - f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n") - except Exception: - pass - except json.JSONDecodeError: - # Not JSON, send as raw - app_module.sensor_queue.put({'type': 'raw', 'text': line}) - - except Exception as e: - app_module.sensor_queue.put({'type': 'error', 'text': str(e)}) - finally: - global sensor_active_device - # Ensure process is terminated - try: - process.terminate() - process.wait(timeout=2) - except Exception: - try: - process.kill() - except Exception: - pass - unregister_process(process) - app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'}) - with app_module.sensor_lock: - app_module.sensor_process = None - # Release SDR device - if sensor_active_device is not None: - app_module.release_sdr_device(sensor_active_device) - sensor_active_device = None - - -@sensor_bp.route('/sensor/status') -def sensor_status() -> Response: - """Check if sensor decoder is currently running.""" - with app_module.sensor_lock: - running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None - return jsonify({'running': running}) - - -@sensor_bp.route('/start_sensor', methods=['POST']) -def start_sensor() -> Response: - global sensor_active_device - - with app_module.sensor_lock: - if app_module.sensor_process: - return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409 - - data = request.json or {} - - # Validate inputs - try: - freq = validate_frequency(data.get('frequency', '433.92')) - gain = validate_gain(data.get('gain', '0')) - ppm = validate_ppm(data.get('ppm', '0')) - device = validate_device_index(data.get('device', '0')) - except ValueError as e: - return jsonify({'status': 'error', 'message': str(e)}), 400 - - # Check for rtl_tcp (remote SDR) connection - rtl_tcp_host = data.get('rtl_tcp_host') - rtl_tcp_port = data.get('rtl_tcp_port', 1234) - - # Claim local device if not using remote rtl_tcp - if not rtl_tcp_host: - device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'sensor') - if error: - return jsonify({ - 'status': 'error', - 'error_type': 'DEVICE_BUSY', - 'message': error - }), 409 - sensor_active_device = device_int - - # Clear queue - while not app_module.sensor_queue.empty(): - try: - app_module.sensor_queue.get_nowait() - except queue.Empty: - break - - # Get SDR type and build command via abstraction layer - sdr_type_str = data.get('sdr_type', 'rtlsdr') - try: - sdr_type = SDRType(sdr_type_str) - except ValueError: - sdr_type = SDRType.RTL_SDR - - if rtl_tcp_host: - # Validate and create network device - try: - rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host) - rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port) - except ValueError as e: - return jsonify({'status': 'error', 'message': str(e)}), 400 - - sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port) - logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}") - else: - # Create local device object - sdr_device = SDRFactory.create_default_device(sdr_type, index=device) - - builder = SDRFactory.get_builder(sdr_device.sdr_type) - - # Build ISM band decoder command - bias_t = data.get('bias_t', False) - cmd = builder.build_ism_command( - device=sdr_device, - frequency_mhz=freq, - gain=float(gain) if gain and gain != 0 else None, - ppm=int(ppm) if ppm and ppm != 0 else None, - bias_t=bias_t - ) - - full_cmd = ' '.join(cmd) - logger.info(f"Running: {full_cmd}") - - # Add signal level metadata so the frontend scope can display RSSI/SNR - # Disable stats reporting to suppress "row count limit 50 reached" warnings - cmd.extend(['-M', 'level', '-M', 'stats:0']) - - try: - app_module.sensor_process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) - register_process(app_module.sensor_process) - - # Start output thread - thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,)) - thread.daemon = True - thread.start() - - # Monitor stderr - # Filter noisy rtl_433 diagnostics that aren't useful to display - _stderr_noise = ( - 'bitbuffer_add_bit', - 'row count limit', - ) - - def monitor_stderr(): - for line in app_module.sensor_process.stderr: - err = line.decode('utf-8', errors='replace').strip() - if err and not any(noise in err for noise in _stderr_noise): - logger.debug(f"[rtl_433] {err}") - app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'}) - - stderr_thread = threading.Thread(target=monitor_stderr) - stderr_thread.daemon = True - stderr_thread.start() - - app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'}) - - return jsonify({'status': 'started', 'command': full_cmd}) - - except FileNotFoundError: - # Release device on failure - if sensor_active_device is not None: - app_module.release_sdr_device(sensor_active_device) - sensor_active_device = None - return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'}) - except Exception as e: - # Release device on failure - if sensor_active_device is not None: - app_module.release_sdr_device(sensor_active_device) - sensor_active_device = None - return jsonify({'status': 'error', 'message': str(e)}) - - -@sensor_bp.route('/stop_sensor', methods=['POST']) -def stop_sensor() -> Response: - global sensor_active_device - - with app_module.sensor_lock: - if app_module.sensor_process: - app_module.sensor_process.terminate() - try: - app_module.sensor_process.wait(timeout=2) - except subprocess.TimeoutExpired: - app_module.sensor_process.kill() - app_module.sensor_process = None - - # Release device from registry - if sensor_active_device is not None: - app_module.release_sdr_device(sensor_active_device) - sensor_active_device = None - - return jsonify({'status': 'stopped'}) - - return jsonify({'status': 'not_running'}) - - + + # Log if enabled + if app_module.logging_enabled: + try: + with open(app_module.log_file_path, 'a') as f: + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n") + except Exception: + pass + except json.JSONDecodeError: + # Not JSON, send as raw + app_module.sensor_queue.put({'type': 'raw', 'text': line}) + + except Exception as e: + app_module.sensor_queue.put({'type': 'error', 'text': str(e)}) + finally: + global sensor_active_device, sensor_active_sdr_type + # Ensure process is terminated + try: + process.terminate() + process.wait(timeout=2) + except Exception: + try: + process.kill() + except Exception: + pass + unregister_process(process) + app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'}) + with app_module.sensor_lock: + app_module.sensor_process = None + # Release SDR device + if sensor_active_device is not None: + app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr') + sensor_active_device = None + sensor_active_sdr_type = None + + +@sensor_bp.route('/sensor/status') +def sensor_status() -> Response: + """Check if sensor decoder is currently running.""" + with app_module.sensor_lock: + running = app_module.sensor_process is not None and app_module.sensor_process.poll() is None + return jsonify({'running': running}) + + +@sensor_bp.route('/start_sensor', methods=['POST']) +def start_sensor() -> Response: + global sensor_active_device, sensor_active_sdr_type + + with app_module.sensor_lock: + if app_module.sensor_process: + return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409 + + data = request.json or {} + + # Validate inputs + try: + freq = validate_frequency(data.get('frequency', '433.92')) + gain = validate_gain(data.get('gain', '0')) + ppm = validate_ppm(data.get('ppm', '0')) + device = validate_device_index(data.get('device', '0')) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + + # Check for rtl_tcp (remote SDR) connection + rtl_tcp_host = data.get('rtl_tcp_host') + rtl_tcp_port = data.get('rtl_tcp_port', 1234) + + # Get SDR type early so we can pass it to claim/release + sdr_type_str = data.get('sdr_type', 'rtlsdr') + + # Claim local device if not using remote rtl_tcp + if not rtl_tcp_host: + device_int = int(device) + error = app_module.claim_sdr_device(device_int, 'sensor', sdr_type_str) + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error + }), 409 + sensor_active_device = device_int + sensor_active_sdr_type = sdr_type_str + + # Clear queue + while not app_module.sensor_queue.empty(): + try: + app_module.sensor_queue.get_nowait() + except queue.Empty: + break + + # Build command via SDR abstraction layer + try: + sdr_type = SDRType(sdr_type_str) + except ValueError: + sdr_type = SDRType.RTL_SDR + + if rtl_tcp_host: + # Validate and create network device + try: + rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host) + rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + + sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port) + logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}") + else: + # Create local device object + sdr_device = SDRFactory.create_default_device(sdr_type, index=device) + + builder = SDRFactory.get_builder(sdr_device.sdr_type) + + # Build ISM band decoder command + bias_t = data.get('bias_t', False) + cmd = builder.build_ism_command( + device=sdr_device, + frequency_mhz=freq, + gain=float(gain) if gain and gain != 0 else None, + ppm=int(ppm) if ppm and ppm != 0 else None, + bias_t=bias_t + ) + + full_cmd = ' '.join(cmd) + logger.info(f"Running: {full_cmd}") + + # Add signal level metadata so the frontend scope can display RSSI/SNR + # Disable stats reporting to suppress "row count limit 50 reached" warnings + cmd.extend(['-M', 'level', '-M', 'stats:0']) + + try: + app_module.sensor_process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + register_process(app_module.sensor_process) + + # Start output thread + thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,)) + thread.daemon = True + thread.start() + + # Monitor stderr + # Filter noisy rtl_433 diagnostics that aren't useful to display + _stderr_noise = ( + 'bitbuffer_add_bit', + 'row count limit', + ) + + def monitor_stderr(): + for line in app_module.sensor_process.stderr: + err = line.decode('utf-8', errors='replace').strip() + if err and not any(noise in err for noise in _stderr_noise): + logger.debug(f"[rtl_433] {err}") + app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'}) + + stderr_thread = threading.Thread(target=monitor_stderr) + stderr_thread.daemon = True + stderr_thread.start() + + app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'}) + + return jsonify({'status': 'started', 'command': full_cmd}) + + except FileNotFoundError: + # Release device on failure + if sensor_active_device is not None: + app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr') + sensor_active_device = None + sensor_active_sdr_type = None + return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'}) + except Exception as e: + # Release device on failure + if sensor_active_device is not None: + app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr') + sensor_active_device = None + sensor_active_sdr_type = None + return jsonify({'status': 'error', 'message': str(e)}) + + +@sensor_bp.route('/stop_sensor', methods=['POST']) +def stop_sensor() -> Response: + global sensor_active_device, sensor_active_sdr_type + + with app_module.sensor_lock: + if app_module.sensor_process: + app_module.sensor_process.terminate() + try: + app_module.sensor_process.wait(timeout=2) + except subprocess.TimeoutExpired: + app_module.sensor_process.kill() + app_module.sensor_process = None + + # Release device from registry + if sensor_active_device is not None: + app_module.release_sdr_device(sensor_active_device, sensor_active_sdr_type or 'rtlsdr') + sensor_active_device = None + sensor_active_sdr_type = None + + return jsonify({'status': 'stopped'}) + + return jsonify({'status': 'not_running'}) + + @sensor_bp.route('/stream_sensor') def stream_sensor() -> Response: def _on_msg(msg: dict[str, Any]) -> None: @@ -330,12 +338,12 @@ def stream_sensor() -> Response: response.headers['X-Accel-Buffering'] = 'no' response.headers['Connection'] = 'keep-alive' return response - - -@sensor_bp.route('/sensor/rssi_history') -def get_rssi_history() -> Response: - """Return RSSI history for all tracked sensor devices.""" - result = {} - for key, entries in sensor_rssi_history.items(): - result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries] - return jsonify({'status': 'success', 'devices': result}) + + +@sensor_bp.route('/sensor/rssi_history') +def get_rssi_history() -> Response: + """Return RSSI history for all tracked sensor devices.""" + result = {} + for key, entries in sensor_rssi_history.items(): + result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries] + return jsonify({'status': 'success', 'devices': result}) diff --git a/routes/vdl2.py b/routes/vdl2.py index ca2200a..5f22878 100644 --- a/routes/vdl2.py +++ b/routes/vdl2.py @@ -1,17 +1,17 @@ """VDL2 aircraft datalink routes.""" -from __future__ import annotations - -import io -import json -import os -import platform -import pty -import queue -import shutil -import subprocess -import threading -import time +from __future__ import annotations + +import io +import json +import os +import platform +import pty +import queue +import shutil +import subprocess +import threading +import time from datetime import datetime from typing import Generator @@ -21,7 +21,7 @@ import app as app_module from utils.logging import sensor_logger as logger from utils.validation import validate_device_index, validate_gain, validate_ppm from utils.sdr import SDRFactory, SDRType -from utils.sse import sse_stream_fanout +from utils.sse import sse_stream_fanout from utils.event_pipeline import process_event from utils.constants import ( PROCESS_TERMINATE_TIMEOUT, @@ -48,6 +48,7 @@ vdl2_last_message_time = None # Track which device is being used vdl2_active_device: int | None = None +vdl2_active_sdr_type: str | None = None def find_dumpvdl2(): @@ -55,22 +56,22 @@ def find_dumpvdl2(): return shutil.which('dumpvdl2') -def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> None: - """Stream dumpvdl2 JSON output to queue.""" - global vdl2_message_count, vdl2_last_message_time - - try: - app_module.vdl2_queue.put({'type': 'status', 'status': 'started'}) - - # Use appropriate sentinel based on mode (text mode for pty on macOS) - sentinel = '' if is_text_mode else b'' - for line in iter(process.stdout.readline, sentinel): - if is_text_mode: - line = line.strip() - else: - line = line.decode('utf-8', errors='replace').strip() - if not line: - continue +def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> None: + """Stream dumpvdl2 JSON output to queue.""" + global vdl2_message_count, vdl2_last_message_time + + try: + app_module.vdl2_queue.put({'type': 'status', 'status': 'started'}) + + # Use appropriate sentinel based on mode (text mode for pty on macOS) + sentinel = '' if is_text_mode else b'' + for line in iter(process.stdout.readline, sentinel): + if is_text_mode: + line = line.strip() + else: + line = line.decode('utf-8', errors='replace').strip() + if not line: + continue try: data = json.loads(line) @@ -110,7 +111,7 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> logger.error(f"VDL2 stream error: {e}") app_module.vdl2_queue.put({'type': 'error', 'message': str(e)}) finally: - global vdl2_active_device + global vdl2_active_device, vdl2_active_sdr_type # Ensure process is terminated try: process.terminate() @@ -126,8 +127,9 @@ def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> app_module.vdl2_process = None # Release SDR device if vdl2_active_device is not None: - app_module.release_sdr_device(vdl2_active_device) + app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr') vdl2_active_device = None + vdl2_active_sdr_type = None @vdl2_bp.route('/tools') @@ -159,7 +161,7 @@ def vdl2_status() -> Response: @vdl2_bp.route('/start', methods=['POST']) def start_vdl2() -> Response: """Start VDL2 decoder.""" - global vdl2_message_count, vdl2_last_message_time, vdl2_active_device + global vdl2_message_count, vdl2_last_message_time, vdl2_active_device, vdl2_active_sdr_type with app_module.vdl2_lock: if app_module.vdl2_process and app_module.vdl2_process.poll() is None: @@ -186,9 +188,16 @@ def start_vdl2() -> Response: except ValueError as e: return jsonify({'status': 'error', 'message': str(e)}), 400 + # Resolve SDR type for device selection + sdr_type_str = data.get('sdr_type', 'rtlsdr') + try: + sdr_type = SDRType(sdr_type_str) + except ValueError: + sdr_type = SDRType.RTL_SDR + # Check if device is available device_int = int(device) - error = app_module.claim_sdr_device(device_int, 'vdl2') + error = app_module.claim_sdr_device(device_int, 'vdl2', sdr_type_str) if error: return jsonify({ 'status': 'error', @@ -197,6 +206,7 @@ def start_vdl2() -> Response: }), 409 vdl2_active_device = device_int + vdl2_active_sdr_type = sdr_type_str # Get frequencies - use provided or defaults # dumpvdl2 expects frequencies in Hz (integers) @@ -215,13 +225,6 @@ def start_vdl2() -> Response: vdl2_message_count = 0 vdl2_last_message_time = None - # Resolve SDR type for device selection - sdr_type_str = data.get('sdr_type', 'rtlsdr') - try: - sdr_type = SDRType(sdr_type_str) - except ValueError: - sdr_type = SDRType.RTL_SDR - is_soapy = sdr_type not in (SDRType.RTL_SDR,) # Build dumpvdl2 command @@ -252,28 +255,28 @@ def start_vdl2() -> Response: logger.info(f"Starting VDL2 decoder: {' '.join(cmd)}") try: - is_text_mode = False - - # On macOS, use pty to avoid stdout buffering issues - if platform.system() == 'Darwin': - master_fd, slave_fd = pty.openpty() - process = subprocess.Popen( - cmd, - stdout=slave_fd, - stderr=subprocess.PIPE, - start_new_session=True - ) - os.close(slave_fd) - # Wrap master_fd as a text file for line-buffered reading - process.stdout = io.open(master_fd, 'r', buffering=1) - is_text_mode = True - else: - process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - start_new_session=True - ) + is_text_mode = False + + # On macOS, use pty to avoid stdout buffering issues + if platform.system() == 'Darwin': + master_fd, slave_fd = pty.openpty() + process = subprocess.Popen( + cmd, + stdout=slave_fd, + stderr=subprocess.PIPE, + start_new_session=True + ) + os.close(slave_fd) + # Wrap master_fd as a text file for line-buffered reading + process.stdout = io.open(master_fd, 'r', buffering=1) + is_text_mode = True + else: + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=True + ) # Wait briefly to check if process started time.sleep(PROCESS_START_WAIT) @@ -281,8 +284,9 @@ def start_vdl2() -> Response: if process.poll() is not None: # Process died - release device if vdl2_active_device is not None: - app_module.release_sdr_device(vdl2_active_device) + app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr') vdl2_active_device = None + vdl2_active_sdr_type = None stderr = '' if process.stderr: stderr = process.stderr.read().decode('utf-8', errors='replace') @@ -295,12 +299,12 @@ def start_vdl2() -> Response: app_module.vdl2_process = process register_process(process) - # Start output streaming thread - thread = threading.Thread( - target=stream_vdl2_output, - args=(process, is_text_mode), - daemon=True - ) + # Start output streaming thread + thread = threading.Thread( + target=stream_vdl2_output, + args=(process, is_text_mode), + daemon=True + ) thread.start() return jsonify({ @@ -313,8 +317,9 @@ def start_vdl2() -> Response: except Exception as e: # Release device on failure if vdl2_active_device is not None: - app_module.release_sdr_device(vdl2_active_device) + app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr') vdl2_active_device = None + vdl2_active_sdr_type = None logger.error(f"Failed to start VDL2 decoder: {e}") return jsonify({'status': 'error', 'message': str(e)}), 500 @@ -322,7 +327,7 @@ def start_vdl2() -> Response: @vdl2_bp.route('/stop', methods=['POST']) def stop_vdl2() -> Response: """Stop VDL2 decoder.""" - global vdl2_active_device + global vdl2_active_device, vdl2_active_sdr_type with app_module.vdl2_lock: if not app_module.vdl2_process: @@ -343,31 +348,32 @@ def stop_vdl2() -> Response: # Release device from registry if vdl2_active_device is not None: - app_module.release_sdr_device(vdl2_active_device) + app_module.release_sdr_device(vdl2_active_device, vdl2_active_sdr_type or 'rtlsdr') vdl2_active_device = None + vdl2_active_sdr_type = None return jsonify({'status': 'stopped'}) -@vdl2_bp.route('/stream') -def stream_vdl2() -> Response: - """SSE stream for VDL2 messages.""" - def _on_msg(msg: dict[str, Any]) -> None: - process_event('vdl2', msg, msg.get('type')) - - response = Response( - sse_stream_fanout( - source_queue=app_module.vdl2_queue, - channel_key='vdl2', - timeout=SSE_QUEUE_TIMEOUT, - keepalive_interval=SSE_KEEPALIVE_INTERVAL, - on_message=_on_msg, - ), - mimetype='text/event-stream', - ) - response.headers['Cache-Control'] = 'no-cache' - response.headers['X-Accel-Buffering'] = 'no' - return response +@vdl2_bp.route('/stream') +def stream_vdl2() -> Response: + """SSE stream for VDL2 messages.""" + def _on_msg(msg: dict[str, Any]) -> None: + process_event('vdl2', msg, msg.get('type')) + + response = Response( + sse_stream_fanout( + source_queue=app_module.vdl2_queue, + channel_key='vdl2', + timeout=SSE_QUEUE_TIMEOUT, + keepalive_interval=SSE_KEEPALIVE_INTERVAL, + on_message=_on_msg, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + return response @vdl2_bp.route('/frequencies') diff --git a/routes/waterfall_websocket.py b/routes/waterfall_websocket.py index de31227..99ceab3 100644 --- a/routes/waterfall_websocket.py +++ b/routes/waterfall_websocket.py @@ -367,6 +367,7 @@ def init_waterfall_websocket(app: Flask): reader_thread = None stop_event = threading.Event() claimed_device = None + claimed_sdr_type = 'rtlsdr' my_generation = None # tracks which capture generation this handler owns capture_center_mhz = 0.0 capture_start_freq = 0.0 @@ -430,8 +431,9 @@ def init_waterfall_websocket(app: Flask): unregister_process(iq_process) iq_process = None if claimed_device is not None: - app_module.release_sdr_device(claimed_device) + app_module.release_sdr_device(claimed_device, claimed_sdr_type) claimed_device = None + claimed_sdr_type = 'rtlsdr' _set_shared_capture_state(running=False, generation=my_generation) my_generation = None stop_event.clear() @@ -513,7 +515,7 @@ def init_waterfall_websocket(app: Flask): max_claim_attempts = 4 if was_restarting else 1 claim_err = None for _claim_attempt in range(max_claim_attempts): - claim_err = app_module.claim_sdr_device(device_index, 'waterfall') + claim_err = app_module.claim_sdr_device(device_index, 'waterfall', sdr_type_str) if not claim_err: break if _claim_attempt < max_claim_attempts - 1: @@ -526,6 +528,7 @@ def init_waterfall_websocket(app: Flask): })) continue claimed_device = device_index + claimed_sdr_type = sdr_type_str # Build I/Q capture command try: @@ -539,8 +542,9 @@ def init_waterfall_websocket(app: Flask): bias_t=bias_t, ) except NotImplementedError as e: - app_module.release_sdr_device(device_index) + app_module.release_sdr_device(device_index, sdr_type_str) claimed_device = None + claimed_sdr_type = 'rtlsdr' ws.send(json.dumps({ 'status': 'error', 'message': str(e), @@ -549,8 +553,9 @@ def init_waterfall_websocket(app: Flask): # Pre-flight: check the capture binary exists if not shutil.which(iq_cmd[0]): - app_module.release_sdr_device(device_index) + app_module.release_sdr_device(device_index, sdr_type_str) claimed_device = None + claimed_sdr_type = 'rtlsdr' ws.send(json.dumps({ 'status': 'error', 'message': f'Required tool "{iq_cmd[0]}" not found. Install SoapySDR tools (rx_sdr).', @@ -602,8 +607,9 @@ def init_waterfall_websocket(app: Flask): safe_terminate(iq_process) unregister_process(iq_process) iq_process = None - app_module.release_sdr_device(device_index) + app_module.release_sdr_device(device_index, sdr_type_str) claimed_device = None + claimed_sdr_type = 'rtlsdr' ws.send(json.dumps({ 'status': 'error', 'message': f'Failed to start I/Q capture: {e}', @@ -806,8 +812,9 @@ def init_waterfall_websocket(app: Flask): unregister_process(iq_process) iq_process = None if claimed_device is not None: - app_module.release_sdr_device(claimed_device) + app_module.release_sdr_device(claimed_device, claimed_sdr_type) claimed_device = None + claimed_sdr_type = 'rtlsdr' _set_shared_capture_state(running=False, generation=my_generation) my_generation = None stop_event.clear() @@ -825,7 +832,7 @@ def init_waterfall_websocket(app: Flask): safe_terminate(iq_process) unregister_process(iq_process) if claimed_device is not None: - app_module.release_sdr_device(claimed_device) + app_module.release_sdr_device(claimed_device, claimed_sdr_type) _set_shared_capture_state(running=False, generation=my_generation) # Complete WebSocket close handshake, then shut down the # raw socket so Werkzeug cannot write its HTTP 200 response diff --git a/routes/wefax.py b/routes/wefax.py index 497cdf0..401672a 100644 --- a/routes/wefax.py +++ b/routes/wefax.py @@ -33,11 +33,12 @@ _wefax_queue: queue.Queue = queue.Queue(maxsize=100) # Track active SDR device wefax_active_device: int | None = None +wefax_active_sdr_type: str | None = None def _progress_callback(data: dict) -> None: """Callback to queue progress updates for SSE stream.""" - global wefax_active_device + global wefax_active_device, wefax_active_sdr_type try: _wefax_queue.put_nowait(data) @@ -56,8 +57,9 @@ def _progress_callback(data: dict) -> None: and data.get('status') in ('complete', 'error', 'stopped') and wefax_active_device is not None ): - app_module.release_sdr_device(wefax_active_device) + app_module.release_sdr_device(wefax_active_device, wefax_active_sdr_type or 'rtlsdr') wefax_active_device = None + wefax_active_sdr_type = None @wefax_bp.route('/status') @@ -169,9 +171,9 @@ def start_decoder(): }), 400 # Claim SDR device - global wefax_active_device + global wefax_active_device, wefax_active_sdr_type device_int = int(device_index) - error = app_module.claim_sdr_device(device_int, 'wefax') + error = app_module.claim_sdr_device(device_int, 'wefax', sdr_type_str) if error: return jsonify({ 'status': 'error', @@ -194,6 +196,7 @@ def start_decoder(): if success: wefax_active_device = device_int + wefax_active_sdr_type = sdr_type_str return jsonify({ 'status': 'started', 'frequency_khz': frequency_khz, @@ -209,7 +212,7 @@ def start_decoder(): 'device': device_int, }) else: - app_module.release_sdr_device(device_int) + app_module.release_sdr_device(device_int, sdr_type_str) return jsonify({ 'status': 'error', 'message': 'Failed to start decoder', @@ -219,13 +222,14 @@ def start_decoder(): @wefax_bp.route('/stop', methods=['POST']) def stop_decoder(): """Stop WeFax decoder.""" - global wefax_active_device + global wefax_active_device, wefax_active_sdr_type decoder = get_wefax_decoder() decoder.stop() if wefax_active_device is not None: - app_module.release_sdr_device(wefax_active_device) + app_module.release_sdr_device(wefax_active_device, wefax_active_sdr_type or 'rtlsdr') wefax_active_device = None + wefax_active_sdr_type = None return jsonify({'status': 'stopped'}) diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index c35636d..6ce0a96 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -1752,31 +1752,37 @@ ACARS: ${r.statistics.acarsMessages} messages`; airbandSelect.innerHTML = ''; if (devices.length === 0) { - adsbSelect.innerHTML = ''; - airbandSelect.innerHTML = ''; + adsbSelect.innerHTML = ''; + airbandSelect.innerHTML = ''; airbandSelect.disabled = true; } else { devices.forEach((dev, i) => { const idx = dev.index !== undefined ? dev.index : i; + const sdrType = dev.sdr_type || 'rtlsdr'; + const compositeVal = `${sdrType}:${idx}`; const displayName = `SDR ${idx}: ${dev.name}`; // Add to ADS-B selector const adsbOpt = document.createElement('option'); - adsbOpt.value = idx; + adsbOpt.value = compositeVal; + adsbOpt.dataset.sdrType = sdrType; + adsbOpt.dataset.index = idx; adsbOpt.textContent = displayName; adsbSelect.appendChild(adsbOpt); // Add to Airband selector const airbandOpt = document.createElement('option'); - airbandOpt.value = idx; + airbandOpt.value = compositeVal; + airbandOpt.dataset.sdrType = sdrType; + airbandOpt.dataset.index = idx; airbandOpt.textContent = displayName; airbandSelect.appendChild(airbandOpt); }); // Default: ADS-B uses first device, Airband uses second (if available) - adsbSelect.value = devices[0].index !== undefined ? devices[0].index : 0; + adsbSelect.value = adsbSelect.options[0]?.value || 'rtlsdr:0'; if (devices.length > 1) { - airbandSelect.value = devices[1].index !== undefined ? devices[1].index : 1; + airbandSelect.value = airbandSelect.options[1]?.value || airbandSelect.options[0]?.value || 'rtlsdr:0'; } // Show warning if only one device @@ -1787,8 +1793,8 @@ ACARS: ${r.statistics.acarsMessages} messages`; } }) .catch(() => { - document.getElementById('adsbDeviceSelect').innerHTML = ''; - document.getElementById('airbandDeviceSelect').innerHTML = ''; + document.getElementById('adsbDeviceSelect').innerHTML = ''; + document.getElementById('airbandDeviceSelect').innerHTML = ''; }); } @@ -2151,11 +2157,14 @@ sudo make install } } - // Get selected ADS-B device - const adsbDevice = parseInt(document.getElementById('adsbDeviceSelect').value) || 0; + // Get selected ADS-B device (composite value "sdr_type:index") + const adsbSelectVal = document.getElementById('adsbDeviceSelect').value || 'rtlsdr:0'; + const [adsbSdrType, adsbDeviceIdx] = adsbSelectVal.includes(':') ? adsbSelectVal.split(':') : ['rtlsdr', adsbSelectVal]; + const adsbDevice = parseInt(adsbDeviceIdx) || 0; const requestBody = { device: adsbDevice, + sdr_type: adsbSdrType, bias_t: getBiasTEnabled() }; if (remoteConfig) { @@ -2306,11 +2315,13 @@ sudo make install } const sessionDevice = session.device_index; + const sessionSdrType = session.sdr_type || 'rtlsdr'; if (sessionDevice !== null && sessionDevice !== undefined) { adsbActiveDevice = sessionDevice; const adsbSelect = document.getElementById('adsbDeviceSelect'); if (adsbSelect) { - adsbSelect.value = sessionDevice; + // Use composite value to select the correct device+type + adsbSelect.value = `${sessionSdrType}:${sessionDevice}`; } } @@ -3663,8 +3674,9 @@ sudo make install function startAcars() { const acarsSelect = document.getElementById('acarsDeviceSelect'); - const device = acarsSelect.value; - const sdr_type = acarsSelect.selectedOptions[0]?.dataset.sdrType || 'rtlsdr'; + const compositeVal = acarsSelect.value || 'rtlsdr:0'; + const [sdr_type, deviceIdx] = compositeVal.includes(':') ? compositeVal.split(':') : ['rtlsdr', compositeVal]; + const device = deviceIdx; const frequencies = getAcarsRegionFreqs(); // Check if using agent mode @@ -3896,18 +3908,21 @@ sudo make install const select = document.getElementById('acarsDeviceSelect'); select.innerHTML = ''; if (devices.length === 0) { - select.innerHTML = ''; + select.innerHTML = ''; } else { devices.forEach((d, i) => { const opt = document.createElement('option'); - opt.value = d.index || i; - opt.dataset.sdrType = d.sdr_type || 'rtlsdr'; - opt.textContent = `SDR ${d.index || i}: ${d.name || d.type || 'SDR'}`; + const sdrType = d.sdr_type || 'rtlsdr'; + const idx = d.index !== undefined ? d.index : i; + opt.value = `${sdrType}:${idx}`; + opt.dataset.sdrType = sdrType; + opt.dataset.index = idx; + opt.textContent = `SDR ${idx}: ${d.name || d.type || 'SDR'}`; select.appendChild(opt); }); // Default to device 1 if available (device 0 likely used for ADS-B) if (devices.length > 1) { - select.value = '1'; + select.value = select.options[1]?.value || select.options[0]?.value; } } }); @@ -3998,8 +4013,9 @@ sudo make install function startVdl2() { const vdl2Select = document.getElementById('vdl2DeviceSelect'); - const device = vdl2Select.value; - const sdr_type = vdl2Select.selectedOptions[0]?.dataset.sdrType || 'rtlsdr'; + const compositeVal = vdl2Select.value || 'rtlsdr:0'; + const [sdr_type, deviceIdx] = compositeVal.includes(':') ? compositeVal.split(':') : ['rtlsdr', compositeVal]; + const device = deviceIdx; const frequencies = getVdl2RegionFreqs(); // Check if using agent mode @@ -4419,17 +4435,20 @@ sudo make install const select = document.getElementById('vdl2DeviceSelect'); select.innerHTML = ''; if (devices.length === 0) { - select.innerHTML = ''; + select.innerHTML = ''; } else { devices.forEach((d, i) => { const opt = document.createElement('option'); - opt.value = d.index || i; - opt.dataset.sdrType = d.sdr_type || 'rtlsdr'; - opt.textContent = `SDR ${d.index || i}: ${d.name || d.type || 'SDR'}`; + const sdrType = d.sdr_type || 'rtlsdr'; + const idx = d.index !== undefined ? d.index : i; + opt.value = `${sdrType}:${idx}`; + opt.dataset.sdrType = sdrType; + opt.dataset.index = idx; + opt.textContent = `SDR ${idx}: ${d.name || d.type || 'SDR'}`; select.appendChild(opt); }); if (devices.length > 1) { - select.value = '1'; + select.value = select.options[1]?.value || select.options[0]?.value; } } }); @@ -5403,13 +5422,16 @@ sudo make install select.innerHTML = ''; if (devices.length === 0) { - select.innerHTML = ''; + select.innerHTML = ''; } else { devices.forEach(device => { const opt = document.createElement('option'); - opt.value = device.index; - opt.dataset.sdrType = device.sdr_type || 'rtlsdr'; - opt.textContent = `SDR ${device.index}: ${device.name || device.type || 'SDR'}`; + const sdrType = device.sdr_type || 'rtlsdr'; + const idx = device.index !== undefined ? device.index : 0; + opt.value = `${sdrType}:${idx}`; + opt.dataset.sdrType = sdrType; + opt.dataset.index = idx; + opt.textContent = `SDR ${idx}: ${device.name || device.type || 'SDR'}`; select.appendChild(opt); }); } diff --git a/templates/index.html b/templates/index.html index 279503f..58d1497 100644 --- a/templates/index.html +++ b/templates/index.html @@ -5646,37 +5646,41 @@ let currentDeviceList = []; // SDR Device Usage Tracking - // Tracks which mode is using which device index + // Tracks which mode is using which device (keyed by "sdr_type:index") const sdrDeviceUsage = { - // deviceIndex: 'modeName' (e.g., 0: 'pager', 1: 'scanner') + // "sdr_type:index": 'modeName' (e.g., "rtlsdr:0": 'pager', "hackrf:0": 'scanner') }; - function getDeviceInUseBy(deviceIndex) { - return sdrDeviceUsage[deviceIndex] || null; + function getDeviceInUseBy(deviceIndex, sdrType) { + const key = `${sdrType || getSelectedSDRType()}:${deviceIndex}`; + return sdrDeviceUsage[key] || null; } - function isDeviceInUse(deviceIndex) { - return sdrDeviceUsage[deviceIndex] !== undefined; + function isDeviceInUse(deviceIndex, sdrType) { + const key = `${sdrType || getSelectedSDRType()}:${deviceIndex}`; + return sdrDeviceUsage[key] !== undefined; } - function reserveDevice(deviceIndex, modeName) { - sdrDeviceUsage[deviceIndex] = modeName; + function reserveDevice(deviceIndex, modeName, sdrType) { + const key = `${sdrType || getSelectedSDRType()}:${deviceIndex}`; + sdrDeviceUsage[key] = modeName; updateDeviceSelectStatus(); } function releaseDevice(modeName) { - for (const [idx, mode] of Object.entries(sdrDeviceUsage)) { + for (const [key, mode] of Object.entries(sdrDeviceUsage)) { if (mode === modeName) { - delete sdrDeviceUsage[idx]; + delete sdrDeviceUsage[key]; } } updateDeviceSelectStatus(); } function getAvailableDevice() { - // Find first device not in use + // Find first device not in use (within selected SDR type) + const sdrType = getSelectedSDRType(); for (const device of currentDeviceList) { - if (!isDeviceInUse(device.index)) { + if ((device.sdr_type || 'rtlsdr') === sdrType && !isDeviceInUse(device.index, sdrType)) { return device.index; } } @@ -5688,10 +5692,11 @@ const select = document.getElementById('deviceSelect'); if (!select) return; + const sdrType = getSelectedSDRType(); const options = select.querySelectorAll('option'); options.forEach(opt => { const idx = parseInt(opt.value); - const usedBy = getDeviceInUseBy(idx); + const usedBy = getDeviceInUseBy(idx, sdrType); const baseName = opt.textContent.replace(/ \[.*\]$/, ''); // Remove existing status if (usedBy) { opt.textContent = `${baseName} [${usedBy.toUpperCase()}]`; diff --git a/tests/test_morse.py b/tests/test_morse.py index b17c7de..22fafc0 100644 --- a/tests/test_morse.py +++ b/tests/test_morse.py @@ -257,8 +257,8 @@ class TestMorseLifecycleRoutes: 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)) + monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode, sdr_type='rtlsdr': None) + monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx, sdr_type='rtlsdr': released_devices.append(idx)) class DummyDevice: sdr_type = morse_routes.SDRType.RTL_SDR @@ -337,8 +337,8 @@ class TestMorseLifecycleRoutes: 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)) + monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode, sdr_type='rtlsdr': None) + monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx, sdr_type='rtlsdr': released_devices.append(idx)) class DummyDevice: sdr_type = morse_routes.SDRType.RTL_SDR @@ -421,8 +421,8 @@ class TestMorseLifecycleRoutes: 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)) + monkeypatch.setattr(app_module, 'claim_sdr_device', lambda idx, mode, sdr_type='rtlsdr': None) + monkeypatch.setattr(app_module, 'release_sdr_device', lambda idx, sdr_type='rtlsdr': released_devices.append(idx)) class DummyDevice: def __init__(self, index: int): diff --git a/tests/test_wefax.py b/tests/test_wefax.py index 9a62192..54c2d14 100644 --- a/tests/test_wefax.py +++ b/tests/test_wefax.py @@ -54,72 +54,72 @@ class TestWeFaxStations: from utils.wefax_stations import get_station assert get_station('noj') is not None - def test_get_station_not_found(self): - """get_station() should return None for unknown callsign.""" - from utils.wefax_stations import get_station - assert get_station('XXXXX') is None - - def test_resolve_tuning_frequency_auto_uses_carrier_for_known_station(self): - """Known station frequencies default to carrier-list behavior in auto mode.""" - from utils.wefax_stations import resolve_tuning_frequency_khz - - tuned, reference, offset_applied = resolve_tuning_frequency_khz( - listed_frequency_khz=4298.0, - station_callsign='NOJ', - frequency_reference='auto', - ) - - assert math.isclose(tuned, 4296.1, abs_tol=1e-6) - assert reference == 'carrier' - assert offset_applied is True - - def test_resolve_tuning_frequency_auto_preserves_unknown_station_input(self): - """Ad-hoc frequencies (no station metadata) should be treated as dial.""" - from utils.wefax_stations import resolve_tuning_frequency_khz - - tuned, reference, offset_applied = resolve_tuning_frequency_khz( - listed_frequency_khz=4298.0, - station_callsign='', - frequency_reference='auto', - ) - - assert math.isclose(tuned, 4298.0, abs_tol=1e-6) - assert reference == 'dial' - assert offset_applied is False - - def test_resolve_tuning_frequency_dial_override(self): - """Explicit dial reference must bypass USB alignment.""" - from utils.wefax_stations import resolve_tuning_frequency_khz - - tuned, reference, offset_applied = resolve_tuning_frequency_khz( - listed_frequency_khz=4298.0, - station_callsign='NOJ', - frequency_reference='dial', - ) - - assert math.isclose(tuned, 4298.0, abs_tol=1e-6) - assert reference == 'dial' - assert offset_applied is False - - def test_resolve_tuning_frequency_rejects_invalid_reference(self): - """Invalid frequency reference values should raise a validation error.""" - from utils.wefax_stations import resolve_tuning_frequency_khz - - try: - resolve_tuning_frequency_khz( - listed_frequency_khz=4298.0, - station_callsign='NOJ', - frequency_reference='invalid', - ) - assert False, "Expected ValueError for invalid frequency_reference" - except ValueError as exc: - assert 'frequency_reference' in str(exc) - - def test_station_frequencies_have_khz(self): - """Each frequency entry must have 'khz' and 'description'.""" - from utils.wefax_stations import load_stations - for station in load_stations(): - for freq in station['frequencies']: + def test_get_station_not_found(self): + """get_station() should return None for unknown callsign.""" + from utils.wefax_stations import get_station + assert get_station('XXXXX') is None + + def test_resolve_tuning_frequency_auto_uses_carrier_for_known_station(self): + """Known station frequencies default to carrier-list behavior in auto mode.""" + from utils.wefax_stations import resolve_tuning_frequency_khz + + tuned, reference, offset_applied = resolve_tuning_frequency_khz( + listed_frequency_khz=4298.0, + station_callsign='NOJ', + frequency_reference='auto', + ) + + assert math.isclose(tuned, 4296.1, abs_tol=1e-6) + assert reference == 'carrier' + assert offset_applied is True + + def test_resolve_tuning_frequency_auto_preserves_unknown_station_input(self): + """Ad-hoc frequencies (no station metadata) should be treated as dial.""" + from utils.wefax_stations import resolve_tuning_frequency_khz + + tuned, reference, offset_applied = resolve_tuning_frequency_khz( + listed_frequency_khz=4298.0, + station_callsign='', + frequency_reference='auto', + ) + + assert math.isclose(tuned, 4298.0, abs_tol=1e-6) + assert reference == 'dial' + assert offset_applied is False + + def test_resolve_tuning_frequency_dial_override(self): + """Explicit dial reference must bypass USB alignment.""" + from utils.wefax_stations import resolve_tuning_frequency_khz + + tuned, reference, offset_applied = resolve_tuning_frequency_khz( + listed_frequency_khz=4298.0, + station_callsign='NOJ', + frequency_reference='dial', + ) + + assert math.isclose(tuned, 4298.0, abs_tol=1e-6) + assert reference == 'dial' + assert offset_applied is False + + def test_resolve_tuning_frequency_rejects_invalid_reference(self): + """Invalid frequency reference values should raise a validation error.""" + from utils.wefax_stations import resolve_tuning_frequency_khz + + try: + resolve_tuning_frequency_khz( + listed_frequency_khz=4298.0, + station_callsign='NOJ', + frequency_reference='invalid', + ) + assert False, "Expected ValueError for invalid frequency_reference" + except ValueError as exc: + assert 'frequency_reference' in str(exc) + + def test_station_frequencies_have_khz(self): + """Each frequency entry must have 'khz' and 'description'.""" + from utils.wefax_stations import load_stations + for station in load_stations(): + for freq in station['frequencies']: assert 'khz' in freq, f"{station['callsign']} missing khz" assert 'description' in freq, f"{station['callsign']} missing description" assert isinstance(freq['khz'], (int, float)) @@ -281,7 +281,7 @@ class TestWeFaxDecoder: # Route tests # --------------------------------------------------------------------------- -class TestWeFaxRoutes: +class TestWeFaxRoutes: """WeFax route endpoint tests.""" def test_status(self, client): @@ -390,11 +390,11 @@ class TestWeFaxRoutes: data = response.get_json() assert 'LPM' in data['message'] - def test_start_success(self, client): - """POST /wefax/start with valid params should succeed.""" - _login_session(client) - mock_decoder = MagicMock() - mock_decoder.is_running = False + def test_start_success(self, client): + """POST /wefax/start with valid params should succeed.""" + _login_session(client) + mock_decoder = MagicMock() + mock_decoder.is_running = False mock_decoder.start.return_value = True with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \ @@ -411,46 +411,46 @@ class TestWeFaxRoutes: content_type='application/json', ) - assert response.status_code == 200 - data = response.get_json() - assert data['status'] == 'started' - assert data['frequency_khz'] == 4298 - assert data['usb_offset_applied'] is True - assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6) - assert data['frequency_reference'] == 'carrier' - assert data['station'] == 'NOJ' - mock_decoder.start.assert_called_once() - start_kwargs = mock_decoder.start.call_args.kwargs - assert math.isclose(start_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6) - - def test_start_respects_dial_reference_override(self, client): - """POST /wefax/start with dial reference should not apply USB offset.""" - _login_session(client) - mock_decoder = MagicMock() - mock_decoder.is_running = False - mock_decoder.start.return_value = True - - with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \ - patch('routes.wefax.app_module.claim_sdr_device', return_value=None): - response = client.post( - '/wefax/start', - data=json.dumps({ - 'frequency_khz': 4298, - 'station': 'NOJ', - 'device': 0, - 'frequency_reference': 'dial', - }), - content_type='application/json', - ) - - assert response.status_code == 200 - data = response.get_json() - assert data['status'] == 'started' - assert data['usb_offset_applied'] is False - assert math.isclose(data['tuned_frequency_khz'], 4298.0, abs_tol=1e-6) - assert data['frequency_reference'] == 'dial' - start_kwargs = mock_decoder.start.call_args.kwargs - assert math.isclose(start_kwargs['frequency_khz'], 4298.0, abs_tol=1e-6) + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'started' + assert data['frequency_khz'] == 4298 + assert data['usb_offset_applied'] is True + assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6) + assert data['frequency_reference'] == 'carrier' + assert data['station'] == 'NOJ' + mock_decoder.start.assert_called_once() + start_kwargs = mock_decoder.start.call_args.kwargs + assert math.isclose(start_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6) + + def test_start_respects_dial_reference_override(self, client): + """POST /wefax/start with dial reference should not apply USB offset.""" + _login_session(client) + mock_decoder = MagicMock() + mock_decoder.is_running = False + mock_decoder.start.return_value = True + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder), \ + patch('routes.wefax.app_module.claim_sdr_device', return_value=None): + response = client.post( + '/wefax/start', + data=json.dumps({ + 'frequency_khz': 4298, + 'station': 'NOJ', + 'device': 0, + 'frequency_reference': 'dial', + }), + content_type='application/json', + ) + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'started' + assert data['usb_offset_applied'] is False + assert math.isclose(data['tuned_frequency_khz'], 4298.0, abs_tol=1e-6) + assert data['frequency_reference'] == 'dial' + start_kwargs = mock_decoder.start.call_args.kwargs + assert math.isclose(start_kwargs['frequency_khz'], 4298.0, abs_tol=1e-6) def test_start_device_busy(self, client): """POST /wefax/start should return 409 when device is busy.""" @@ -509,83 +509,83 @@ class TestWeFaxRoutes: assert response.status_code == 400 - def test_delete_image_wrong_extension(self, client): - """DELETE /wefax/images/ should reject non-PNG.""" - _login_session(client) - mock_decoder = MagicMock() - - with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): - response = client.delete('/wefax/images/test.jpg') - - assert response.status_code == 400 - - def test_schedule_enable_applies_usb_alignment(self, client): - """Scheduler should receive tuned USB dial frequency in auto mode.""" - _login_session(client) - mock_scheduler = MagicMock() - mock_scheduler.enable.return_value = { - 'enabled': True, - 'scheduled_count': 2, - 'total_broadcasts': 2, - } - - with patch('utils.wefax_scheduler.get_wefax_scheduler', return_value=mock_scheduler): - response = client.post( - '/wefax/schedule/enable', - data=json.dumps({ - 'station': 'NOJ', - 'frequency_khz': 4298, - 'device': 0, - }), - content_type='application/json', - ) - - assert response.status_code == 200 - data = response.get_json() - assert data['status'] == 'ok' - assert data['usb_offset_applied'] is True - assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6) - enable_kwargs = mock_scheduler.enable.call_args.kwargs - assert math.isclose(enable_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6) - - -class TestWeFaxProgressCallback: - """Regression tests for WeFax route-level progress callback behavior.""" - - def test_terminal_progress_releases_active_device(self): - """Terminal decoder events must release any manually claimed SDR.""" - import routes.wefax as wefax_routes - - original_device = wefax_routes.wefax_active_device - try: - wefax_routes.wefax_active_device = 3 - with patch('routes.wefax.app_module.release_sdr_device') as mock_release: - wefax_routes._progress_callback({ - 'type': 'wefax_progress', - 'status': 'error', - 'message': 'decode failed', - }) - - mock_release.assert_called_once_with(3) - assert wefax_routes.wefax_active_device is None - finally: - wefax_routes.wefax_active_device = original_device - - def test_non_terminal_progress_does_not_release_active_device(self): - """Non-terminal progress updates must not release SDR ownership.""" - import routes.wefax as wefax_routes - - original_device = wefax_routes.wefax_active_device - try: - wefax_routes.wefax_active_device = 4 - with patch('routes.wefax.app_module.release_sdr_device') as mock_release: - wefax_routes._progress_callback({ - 'type': 'wefax_progress', - 'status': 'receiving', - 'line_count': 120, - }) - - mock_release.assert_not_called() - assert wefax_routes.wefax_active_device == 4 - finally: - wefax_routes.wefax_active_device = original_device + def test_delete_image_wrong_extension(self, client): + """DELETE /wefax/images/ should reject non-PNG.""" + _login_session(client) + mock_decoder = MagicMock() + + with patch('routes.wefax.get_wefax_decoder', return_value=mock_decoder): + response = client.delete('/wefax/images/test.jpg') + + assert response.status_code == 400 + + def test_schedule_enable_applies_usb_alignment(self, client): + """Scheduler should receive tuned USB dial frequency in auto mode.""" + _login_session(client) + mock_scheduler = MagicMock() + mock_scheduler.enable.return_value = { + 'enabled': True, + 'scheduled_count': 2, + 'total_broadcasts': 2, + } + + with patch('utils.wefax_scheduler.get_wefax_scheduler', return_value=mock_scheduler): + response = client.post( + '/wefax/schedule/enable', + data=json.dumps({ + 'station': 'NOJ', + 'frequency_khz': 4298, + 'device': 0, + }), + content_type='application/json', + ) + + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'ok' + assert data['usb_offset_applied'] is True + assert math.isclose(data['tuned_frequency_khz'], 4296.1, abs_tol=1e-6) + enable_kwargs = mock_scheduler.enable.call_args.kwargs + assert math.isclose(enable_kwargs['frequency_khz'], 4296.1, abs_tol=1e-6) + + +class TestWeFaxProgressCallback: + """Regression tests for WeFax route-level progress callback behavior.""" + + def test_terminal_progress_releases_active_device(self): + """Terminal decoder events must release any manually claimed SDR.""" + import routes.wefax as wefax_routes + + original_device = wefax_routes.wefax_active_device + try: + wefax_routes.wefax_active_device = 3 + with patch('routes.wefax.app_module.release_sdr_device') as mock_release: + wefax_routes._progress_callback({ + 'type': 'wefax_progress', + 'status': 'error', + 'message': 'decode failed', + }) + + mock_release.assert_called_once_with(3, 'rtlsdr') + assert wefax_routes.wefax_active_device is None + finally: + wefax_routes.wefax_active_device = original_device + + def test_non_terminal_progress_does_not_release_active_device(self): + """Non-terminal progress updates must not release SDR ownership.""" + import routes.wefax as wefax_routes + + original_device = wefax_routes.wefax_active_device + try: + wefax_routes.wefax_active_device = 4 + with patch('routes.wefax.app_module.release_sdr_device') as mock_release: + wefax_routes._progress_callback({ + 'type': 'wefax_progress', + 'status': 'receiving', + 'line_count': 120, + }) + + mock_release.assert_not_called() + assert wefax_routes.wefax_active_device == 4 + finally: + wefax_routes.wefax_active_device = original_device From 5b06c57565d64e43b41d22900ed22711f31f40bf Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 27 Feb 2026 10:46:33 +0000 Subject: [PATCH 08/22] feat: add radiosonde weather balloon tracking mode Integrate radiosonde_auto_rx for automatic weather balloon detection and decoding on 400-406 MHz. Includes UDP telemetry parsing, Leaflet map with altitude-colored markers and trajectory tracks, SDR device registry integration, setup script installation, and Docker support. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 12 +- app.py | 19 +- config.py | 6 + routes/__init__.py | 2 + routes/radiosonde.py | 547 +++++++++++++++++++++++ setup.sh | 62 ++- static/css/modes/radiosonde.css | 152 +++++++ templates/index.html | 24 +- templates/partials/modes/radiosonde.html | 376 ++++++++++++++++ templates/partials/nav.html | 1 + utils/constants.py | 14 + utils/dependencies.py | 78 ++-- 12 files changed, 1254 insertions(+), 39 deletions(-) create mode 100644 routes/radiosonde.py create mode 100644 static/css/modes/radiosonde.css create mode 100644 templates/partials/modes/radiosonde.html diff --git a/Dockerfile b/Dockerfile index b5c8904..aab54a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -200,6 +200,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && make install \ && ldconfig \ && rm -rf /tmp/hackrf \ + # Install radiosonde_auto_rx (weather balloon decoder) + && cd /tmp \ + && git clone --depth 1 https://github.com/projecthorus/radiosonde_auto_rx.git \ + && cd radiosonde_auto_rx/auto_rx \ + && pip install --no-cache-dir -r requirements.txt \ + && mkdir -p /opt/radiosonde_auto_rx/auto_rx \ + && cp -r . /opt/radiosonde_auto_rx/auto_rx/ \ + && chmod +x /opt/radiosonde_auto_rx/auto_rx/auto_rx.py \ + && cd /tmp \ + && rm -rf /tmp/radiosonde_auto_rx \ # Build rtlamr (utility meter decoder - requires Go) && cd /tmp \ && curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \ @@ -246,7 +256,7 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . # Create data directory for persistence -RUN mkdir -p /app/data /app/data/weather_sat +RUN mkdir -p /app/data /app/data/weather_sat /app/data/radiosonde/logs # Expose web interface port EXPOSE 5050 diff --git a/app.py b/app.py index b74410b..9fedb75 100644 --- a/app.py +++ b/app.py @@ -198,6 +198,11 @@ tscm_lock = threading.Lock() subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) subghz_lock = threading.Lock() +# Radiosonde weather balloon tracking +radiosonde_process = None +radiosonde_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) +radiosonde_lock = threading.Lock() + # CW/Morse code decoder morse_process = None morse_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) @@ -766,6 +771,7 @@ def health_check() -> Response: 'wifi': wifi_active, 'bluetooth': bt_active, 'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False), + 'radiosonde': radiosonde_process is not None and (radiosonde_process.poll() is None if radiosonde_process else False), 'morse': morse_process is not None and (morse_process.poll() is None if morse_process else False), 'subghz': _get_subghz_active(), }, @@ -784,12 +790,13 @@ def health_check() -> Response: def kill_all() -> Response: """Kill all decoder, WiFi, and Bluetooth processes.""" global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process - global vdl2_process, morse_process + global vdl2_process, morse_process, radiosonde_process global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process - # Import adsb and ais modules to reset their state + # Import modules to reset their state from routes import adsb as adsb_module from routes import ais as ais_module + from routes import radiosonde as radiosonde_module from utils.bluetooth import reset_bluetooth_scanner killed = [] @@ -799,7 +806,8 @@ def kill_all() -> Response: 'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher', 'hcitool', 'bluetoothctl', 'satdump', 'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg', - 'hackrf_transfer', 'hackrf_sweep' + 'hackrf_transfer', 'hackrf_sweep', + 'auto_rx' ] for proc in processes_to_kill: @@ -829,6 +837,11 @@ def kill_all() -> Response: ais_process = None ais_module.ais_running = False + # Reset Radiosonde state + with radiosonde_lock: + radiosonde_process = None + radiosonde_module.radiosonde_running = False + # Reset ACARS state with acars_lock: acars_process = None diff --git a/config.py b/config.py index 76635b8..434d2b1 100644 --- a/config.py +++ b/config.py @@ -355,6 +355,12 @@ SUBGHZ_MAX_TX_DURATION = _get_env_int('SUBGHZ_MAX_TX_DURATION', 10) SUBGHZ_SWEEP_START_MHZ = _get_env_float('SUBGHZ_SWEEP_START', 300.0) SUBGHZ_SWEEP_END_MHZ = _get_env_float('SUBGHZ_SWEEP_END', 928.0) +# Radiosonde settings +RADIOSONDE_FREQ_MIN = _get_env_float('RADIOSONDE_FREQ_MIN', 400.0) +RADIOSONDE_FREQ_MAX = _get_env_float('RADIOSONDE_FREQ_MAX', 406.0) +RADIOSONDE_DEFAULT_GAIN = _get_env_float('RADIOSONDE_GAIN', 40.0) +RADIOSONDE_UDP_PORT = _get_env_int('RADIOSONDE_UDP_PORT', 55673) + # Update checking GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept') UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True) diff --git a/routes/__init__.py b/routes/__init__.py index 3a00b91..61c1ba6 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -19,6 +19,7 @@ def register_blueprints(app): from .morse import morse_bp from .offline import offline_bp from .pager import pager_bp + from .radiosonde import radiosonde_bp from .recordings import recordings_bp from .rtlamr import rtlamr_bp from .satellite import satellite_bp @@ -76,6 +77,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(radiosonde_bp) # Radiosonde weather balloon tracking app.register_blueprint(system_bp) # System health monitoring # Initialize TSCM state with queue and lock from app diff --git a/routes/radiosonde.py b/routes/radiosonde.py new file mode 100644 index 0000000..2fb1ae0 --- /dev/null +++ b/routes/radiosonde.py @@ -0,0 +1,547 @@ +"""Radiosonde weather balloon tracking routes. + +Uses radiosonde_auto_rx to automatically scan for and decode radiosonde +telemetry (position, altitude, temperature, humidity, pressure) on the +400-406 MHz band. Telemetry arrives as JSON over UDP. +""" + +from __future__ import annotations + +import json +import os +import queue +import shutil +import socket +import subprocess +import threading +import time +from typing import Any + +from flask import Blueprint, Response, jsonify, request + +import app as app_module +from utils.constants import ( + MAX_RADIOSONDE_AGE_SECONDS, + PROCESS_TERMINATE_TIMEOUT, + RADIOSONDE_TERMINATE_TIMEOUT, + RADIOSONDE_UDP_PORT, + SSE_KEEPALIVE_INTERVAL, + SSE_QUEUE_TIMEOUT, +) +from utils.logging import get_logger +from utils.sdr import SDRFactory, SDRType +from utils.sse import sse_stream_fanout +from utils.validation import validate_device_index, validate_gain + +logger = get_logger('intercept.radiosonde') + +radiosonde_bp = Blueprint('radiosonde', __name__, url_prefix='/radiosonde') + +# Track radiosonde state +radiosonde_running = False +radiosonde_active_device: int | None = None +radiosonde_active_sdr_type: str | None = None + +# Active balloon data: serial -> telemetry dict +radiosonde_balloons: dict[str, dict[str, Any]] = {} +_balloons_lock = threading.Lock() + +# UDP listener socket reference (so /stop can close it) +_udp_socket: socket.socket | None = None + +# Common installation paths for radiosonde_auto_rx +AUTO_RX_PATHS = [ + '/opt/radiosonde_auto_rx/auto_rx/auto_rx.py', + '/usr/local/bin/radiosonde_auto_rx', + '/opt/auto_rx/auto_rx.py', +] + + +def find_auto_rx() -> str | None: + """Find radiosonde_auto_rx script/binary.""" + # Check PATH first + path = shutil.which('radiosonde_auto_rx') + if path: + return path + # Check common locations + for p in AUTO_RX_PATHS: + if os.path.isfile(p) and os.access(p, os.X_OK): + return p + # Check for Python script (not executable but runnable) + for p in AUTO_RX_PATHS: + if os.path.isfile(p): + return p + return None + + +def generate_station_cfg( + freq_min: float = 400.0, + freq_max: float = 406.0, + gain: float = 40.0, + device_index: int = 0, + ppm: int = 0, + bias_t: bool = False, + udp_port: int = RADIOSONDE_UDP_PORT, +) -> str: + """Generate a station.cfg for radiosonde_auto_rx and return the file path.""" + cfg_dir = os.path.join('data', 'radiosonde') + os.makedirs(cfg_dir, exist_ok=True) + cfg_path = os.path.join(cfg_dir, 'station.cfg') + + # Minimal station.cfg that auto_rx needs + cfg = f"""# Auto-generated by INTERCEPT for radiosonde_auto_rx +[search_params] +min_freq = {freq_min} +max_freq = {freq_max} +rx_timeout = 180 +whitelist = [] +blacklist = [] +greylist = [] + +[sdr] +sdr_type = rtlsdr +rtlsdr_device_idx = {device_index} +rtlsdr_gain = {gain} +rtlsdr_ppm = {ppm} +rtlsdr_bias = {str(bias_t).lower()} + +[habitat] +upload_enabled = False + +[aprs] +upload_enabled = False + +[sondehub] +upload_enabled = False + +[positioning] +station_lat = 0.0 +station_lon = 0.0 +station_alt = 0.0 + +[logging] +per_sonde_log = True +log_directory = ./data/radiosonde/logs + +[advanced] +web_host = 127.0.0.1 +web_port = 0 +udp_broadcast_port = {udp_port} +""" + + with open(cfg_path, 'w') as f: + f.write(cfg) + + logger.info(f"Generated station.cfg at {cfg_path}") + return cfg_path + + +def parse_radiosonde_udp(udp_port: int) -> None: + """Thread function: listen for radiosonde_auto_rx UDP JSON telemetry.""" + global radiosonde_running, _udp_socket + + logger.info(f"Radiosonde UDP listener started on port {udp_port}") + + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(('0.0.0.0', udp_port)) + sock.settimeout(2.0) + _udp_socket = sock + except OSError as e: + logger.error(f"Failed to bind UDP port {udp_port}: {e}") + return + + while radiosonde_running: + try: + data, _addr = sock.recvfrom(4096) + except socket.timeout: + # Clean up stale balloons + _cleanup_stale_balloons() + continue + except OSError: + break + + try: + msg = json.loads(data.decode('utf-8', errors='ignore')) + except (json.JSONDecodeError, UnicodeDecodeError): + continue + + balloon = _process_telemetry(msg) + if balloon: + serial = balloon.get('id', '') + if serial: + with _balloons_lock: + radiosonde_balloons[serial] = balloon + try: + app_module.radiosonde_queue.put_nowait({ + 'type': 'balloon', + **balloon, + }) + except queue.Full: + pass + + try: + sock.close() + except OSError: + pass + _udp_socket = None + logger.info("Radiosonde UDP listener stopped") + + +def _process_telemetry(msg: dict) -> dict | None: + """Extract relevant fields from a radiosonde_auto_rx UDP telemetry packet.""" + # auto_rx broadcasts packets with a 'type' field + # Telemetry packets have type 'payload_summary' or individual sonde data + serial = msg.get('id') or msg.get('serial') + if not serial: + return None + + balloon: dict[str, Any] = {'id': str(serial)} + + # Sonde type (RS41, RS92, DFM, M10, etc.) + if 'type' in msg: + balloon['sonde_type'] = msg['type'] + if 'subtype' in msg: + balloon['sonde_type'] = msg['subtype'] + + # Timestamp + if 'datetime' in msg: + balloon['datetime'] = msg['datetime'] + + # Position + for key in ('lat', 'latitude'): + if key in msg: + try: + balloon['lat'] = float(msg[key]) + except (ValueError, TypeError): + pass + break + for key in ('lon', 'longitude'): + if key in msg: + try: + balloon['lon'] = float(msg[key]) + except (ValueError, TypeError): + pass + break + + # Altitude (metres) + if 'alt' in msg: + try: + balloon['alt'] = float(msg['alt']) + except (ValueError, TypeError): + pass + + # Meteorological data + for field in ('temp', 'humidity', 'pressure'): + if field in msg: + try: + balloon[field] = float(msg[field]) + except (ValueError, TypeError): + pass + + # Velocity + if 'vel_h' in msg: + try: + balloon['vel_h'] = float(msg['vel_h']) + except (ValueError, TypeError): + pass + if 'vel_v' in msg: + try: + balloon['vel_v'] = float(msg['vel_v']) + except (ValueError, TypeError): + pass + if 'heading' in msg: + try: + balloon['heading'] = float(msg['heading']) + except (ValueError, TypeError): + pass + + # GPS satellites + if 'sats' in msg: + try: + balloon['sats'] = int(msg['sats']) + except (ValueError, TypeError): + pass + + # Battery voltage + if 'batt' in msg: + try: + balloon['batt'] = float(msg['batt']) + except (ValueError, TypeError): + pass + + # Frequency + if 'freq' in msg: + try: + balloon['freq'] = float(msg['freq']) + except (ValueError, TypeError): + pass + + balloon['last_seen'] = time.time() + return balloon + + +def _cleanup_stale_balloons() -> None: + """Remove balloons not seen within the retention window.""" + now = time.time() + with _balloons_lock: + stale = [ + k for k, v in radiosonde_balloons.items() + if now - v.get('last_seen', 0) > MAX_RADIOSONDE_AGE_SECONDS + ] + for k in stale: + del radiosonde_balloons[k] + + +@radiosonde_bp.route('/tools') +def check_tools(): + """Check for radiosonde decoding tools and hardware.""" + auto_rx_path = find_auto_rx() + devices = SDRFactory.detect_devices() + has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices) + + return jsonify({ + 'auto_rx': auto_rx_path is not None, + 'auto_rx_path': auto_rx_path, + 'has_rtlsdr': has_rtlsdr, + 'device_count': len(devices), + }) + + +@radiosonde_bp.route('/status') +def radiosonde_status(): + """Get radiosonde tracking status.""" + process_running = False + if app_module.radiosonde_process: + process_running = app_module.radiosonde_process.poll() is None + + with _balloons_lock: + balloon_count = len(radiosonde_balloons) + balloons_snapshot = dict(radiosonde_balloons) + + return jsonify({ + 'tracking_active': radiosonde_running, + 'active_device': radiosonde_active_device, + 'balloon_count': balloon_count, + 'balloons': balloons_snapshot, + 'queue_size': app_module.radiosonde_queue.qsize(), + 'auto_rx_path': find_auto_rx(), + 'process_running': process_running, + }) + + +@radiosonde_bp.route('/start', methods=['POST']) +def start_radiosonde(): + """Start radiosonde tracking.""" + global radiosonde_running, radiosonde_active_device, radiosonde_active_sdr_type + + with app_module.radiosonde_lock: + if radiosonde_running: + return jsonify({ + 'status': 'already_running', + 'message': 'Radiosonde tracking already active', + }), 409 + + data = request.json or {} + + # Validate inputs + try: + gain = float(validate_gain(data.get('gain', '40'))) + device = validate_device_index(data.get('device', '0')) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + + freq_min = data.get('freq_min', 400.0) + freq_max = data.get('freq_max', 406.0) + try: + freq_min = float(freq_min) + freq_max = float(freq_max) + if not (380.0 <= freq_min <= 410.0) or not (380.0 <= freq_max <= 410.0): + raise ValueError("Frequency out of range") + if freq_min >= freq_max: + raise ValueError("Min frequency must be less than max") + except (ValueError, TypeError) as e: + return jsonify({'status': 'error', 'message': f'Invalid frequency range: {e}'}), 400 + + bias_t = data.get('bias_t', False) + ppm = int(data.get('ppm', 0)) + + # Find auto_rx + auto_rx_path = find_auto_rx() + if not auto_rx_path: + return jsonify({ + 'status': 'error', + 'message': 'radiosonde_auto_rx not found. Install from https://github.com/projecthorus/radiosonde_auto_rx', + }), 400 + + # Get SDR type + sdr_type_str = data.get('sdr_type', 'rtlsdr') + + # Kill any existing process + if app_module.radiosonde_process: + try: + pgid = os.getpgid(app_module.radiosonde_process.pid) + os.killpg(pgid, 15) + app_module.radiosonde_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT) + except (subprocess.TimeoutExpired, ProcessLookupError, OSError): + try: + pgid = os.getpgid(app_module.radiosonde_process.pid) + os.killpg(pgid, 9) + except (ProcessLookupError, OSError): + pass + app_module.radiosonde_process = None + logger.info("Killed existing radiosonde process") + + # Claim SDR device + device_int = int(device) + error = app_module.claim_sdr_device(device_int, 'radiosonde', sdr_type_str) + if error: + return jsonify({ + 'status': 'error', + 'error_type': 'DEVICE_BUSY', + 'message': error, + }), 409 + + # Generate config + cfg_path = generate_station_cfg( + freq_min=freq_min, + freq_max=freq_max, + gain=gain, + device_index=device_int, + ppm=ppm, + bias_t=bias_t, + ) + + # Build command + cfg_dir = os.path.dirname(os.path.abspath(cfg_path)) + if auto_rx_path.endswith('.py'): + cmd = ['python', auto_rx_path, '-c', cfg_dir] + else: + cmd = [auto_rx_path, '-c', cfg_dir] + + try: + logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}") + app_module.radiosonde_process = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + start_new_session=True, + ) + + # Wait briefly for process to start + time.sleep(2.0) + + if app_module.radiosonde_process.poll() is not None: + app_module.release_sdr_device(device_int, sdr_type_str) + stderr_output = '' + if app_module.radiosonde_process.stderr: + try: + stderr_output = app_module.radiosonde_process.stderr.read().decode( + 'utf-8', errors='ignore' + ).strip() + except Exception: + pass + error_msg = 'radiosonde_auto_rx failed to start. Check SDR device connection.' + if stderr_output: + error_msg += f' Error: {stderr_output[:200]}' + return jsonify({'status': 'error', 'message': error_msg}), 500 + + radiosonde_running = True + radiosonde_active_device = device_int + radiosonde_active_sdr_type = sdr_type_str + + # Clear stale data + with _balloons_lock: + radiosonde_balloons.clear() + + # Start UDP listener thread + udp_thread = threading.Thread( + target=parse_radiosonde_udp, + args=(RADIOSONDE_UDP_PORT,), + daemon=True, + ) + udp_thread.start() + + return jsonify({ + 'status': 'started', + 'message': 'Radiosonde tracking started', + 'device': device, + }) + except Exception as e: + app_module.release_sdr_device(device_int, sdr_type_str) + logger.error(f"Failed to start radiosonde_auto_rx: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + +@radiosonde_bp.route('/stop', methods=['POST']) +def stop_radiosonde(): + """Stop radiosonde tracking.""" + global radiosonde_running, radiosonde_active_device, radiosonde_active_sdr_type, _udp_socket + + with app_module.radiosonde_lock: + if app_module.radiosonde_process: + try: + pgid = os.getpgid(app_module.radiosonde_process.pid) + os.killpg(pgid, 15) + app_module.radiosonde_process.wait(timeout=RADIOSONDE_TERMINATE_TIMEOUT) + except (subprocess.TimeoutExpired, ProcessLookupError, OSError): + try: + pgid = os.getpgid(app_module.radiosonde_process.pid) + os.killpg(pgid, 9) + except (ProcessLookupError, OSError): + pass + app_module.radiosonde_process = None + logger.info("Radiosonde process stopped") + + # Close UDP socket to unblock listener thread + if _udp_socket: + try: + _udp_socket.close() + except OSError: + pass + _udp_socket = None + + # Release SDR device + if radiosonde_active_device is not None: + app_module.release_sdr_device( + radiosonde_active_device, + radiosonde_active_sdr_type or 'rtlsdr', + ) + + radiosonde_running = False + radiosonde_active_device = None + radiosonde_active_sdr_type = None + + with _balloons_lock: + radiosonde_balloons.clear() + + return jsonify({'status': 'stopped'}) + + +@radiosonde_bp.route('/stream') +def stream_radiosonde(): + """SSE stream for radiosonde telemetry.""" + response = Response( + sse_stream_fanout( + source_queue=app_module.radiosonde_queue, + channel_key='radiosonde', + 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 + + +@radiosonde_bp.route('/balloons') +def get_balloons(): + """Get current balloon data.""" + with _balloons_lock: + return jsonify({ + 'status': 'success', + 'count': len(radiosonde_balloons), + 'balloons': dict(radiosonde_balloons), + }) diff --git a/setup.sh b/setup.sh index 148fd6f..e60fd77 100755 --- a/setup.sh +++ b/setup.sh @@ -229,6 +229,7 @@ check_tools() { check_optional "dumpvdl2" "VDL2 decoder" dumpvdl2 check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump + check_optional "auto_rx.py" "Radiosonde weather balloon decoder" auto_rx.py echo info "GPS:" check_required "gpsd" "GPS daemon" gpsd @@ -816,6 +817,37 @@ WRAPPER ) } +install_radiosonde_auto_rx() { + info "Installing radiosonde_auto_rx (weather balloon decoder)..." + local install_dir="/opt/radiosonde_auto_rx" + + ( + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + + info "Cloning radiosonde_auto_rx..." + if ! git clone --depth 1 https://github.com/projecthorus/radiosonde_auto_rx.git "$tmp_dir/radiosonde_auto_rx"; then + warn "Failed to clone radiosonde_auto_rx" + exit 1 + fi + + info "Installing Python dependencies..." + cd "$tmp_dir/radiosonde_auto_rx/auto_rx" + pip3 install --quiet -r requirements.txt || { + warn "Failed to install radiosonde_auto_rx Python dependencies" + exit 1 + } + + info "Installing to ${install_dir}..." + refresh_sudo + $SUDO mkdir -p "$install_dir/auto_rx" + $SUDO cp -r . "$install_dir/auto_rx/" + $SUDO chmod +x "$install_dir/auto_rx/auto_rx.py" + + ok "radiosonde_auto_rx installed to ${install_dir}" + ) +} + install_macos_packages() { need_sudo @@ -825,7 +857,7 @@ install_macos_packages() { sudo -v || { fail "sudo authentication failed"; exit 1; } fi - TOTAL_STEPS=21 + TOTAL_STEPS=22 CURRENT_STEP=0 progress "Checking Homebrew" @@ -912,6 +944,19 @@ install_macos_packages() { ok "SatDump already installed" fi + progress "Installing radiosonde_auto_rx (optional)" + if ! cmd_exists auto_rx.py && [ ! -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ]; then + echo + info "radiosonde_auto_rx is used for weather balloon (radiosonde) tracking." + if ask_yes_no "Do you want to install radiosonde_auto_rx?"; then + install_radiosonde_auto_rx || warn "radiosonde_auto_rx installation failed. Radiosonde tracking will not be available." + else + warn "Skipping radiosonde_auto_rx. You can install it later if needed." + fi + else + ok "radiosonde_auto_rx already installed" + fi + progress "Installing aircrack-ng" brew_install aircrack-ng @@ -1303,7 +1348,7 @@ install_debian_packages() { export NEEDRESTART_MODE=a fi - TOTAL_STEPS=27 + TOTAL_STEPS=28 CURRENT_STEP=0 progress "Updating APT package lists" @@ -1485,6 +1530,19 @@ install_debian_packages() { ok "SatDump already installed" fi + progress "Installing radiosonde_auto_rx (optional)" + if ! cmd_exists auto_rx.py && [ ! -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ]; then + echo + info "radiosonde_auto_rx is used for weather balloon (radiosonde) tracking." + if ask_yes_no "Do you want to install radiosonde_auto_rx?"; then + install_radiosonde_auto_rx || warn "radiosonde_auto_rx installation failed. Radiosonde tracking will not be available." + else + warn "Skipping radiosonde_auto_rx. You can install it later if needed." + fi + else + ok "radiosonde_auto_rx already installed" + fi + progress "Configuring udev rules" setup_udev_rules_debian diff --git a/static/css/modes/radiosonde.css b/static/css/modes/radiosonde.css new file mode 100644 index 0000000..9cb503a --- /dev/null +++ b/static/css/modes/radiosonde.css @@ -0,0 +1,152 @@ +/* ============================================ + RADIOSONDE MODE — Scoped Styles + ============================================ */ + +/* Visuals container */ +.radiosonde-visuals-container { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + min-height: 0; + overflow: hidden; + padding: 8px; +} + +/* Map container */ +#radiosondeMapContainer { + flex: 1; + min-height: 300px; + border-radius: 6px; + border: 1px solid var(--border-color); + background: var(--bg-primary); +} + +/* Card container below map */ +.radiosonde-card-container { + display: flex; + flex-wrap: wrap; + gap: 8px; + max-height: 200px; + overflow-y: auto; + padding: 4px 0; +} + +/* Individual balloon card */ +.radiosonde-card { + background: var(--bg-card, #1a1e2e); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 10px 12px; + cursor: pointer; + flex: 1 1 280px; + min-width: 260px; + max-width: 400px; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.radiosonde-card:hover { + border-color: var(--accent-cyan); + background: rgba(0, 204, 255, 0.04); +} + +.radiosonde-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + padding-bottom: 6px; + border-bottom: 1px solid var(--border-color); +} + +.radiosonde-serial { + font-family: var(--font-mono, 'JetBrains Mono', monospace); + font-size: 13px; + font-weight: 600; + color: var(--accent-cyan); + letter-spacing: 0.5px; +} + +.radiosonde-type { + font-family: var(--font-mono, 'JetBrains Mono', monospace); + font-size: 10px; + font-weight: 600; + color: var(--text-dim); + background: rgba(255, 255, 255, 0.06); + padding: 2px 6px; + border-radius: 3px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Telemetry stat grid */ +.radiosonde-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; +} + +.radiosonde-stat { + display: flex; + flex-direction: column; + align-items: center; + padding: 4px; +} + +.radiosonde-stat-value { + font-family: var(--font-mono, 'JetBrains Mono', monospace); + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; +} + +.radiosonde-stat-label { + font-size: 9px; + font-weight: 600; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.8px; + margin-top: 2px; +} + +/* Leaflet popup overrides for radiosonde */ +#radiosondeMapContainer .leaflet-popup-content-wrapper { + background: var(--bg-card, #1a1e2e); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + font-family: var(--font-mono, 'JetBrains Mono', monospace); + font-size: 11px; +} + +#radiosondeMapContainer .leaflet-popup-tip { + background: var(--bg-card, #1a1e2e); + border: 1px solid var(--border-color); +} + +/* Scrollbar for card container */ +.radiosonde-card-container::-webkit-scrollbar { + width: 4px; +} + +.radiosonde-card-container::-webkit-scrollbar-track { + background: transparent; +} + +.radiosonde-card-container::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 2px; +} + +/* Responsive: stack cards on narrow screens */ +@media (max-width: 600px) { + .radiosonde-card { + flex: 1 1 100%; + max-width: 100%; + } + + .radiosonde-stats { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/templates/index.html b/templates/index.html index 58d1497..2844a23 100644 --- a/templates/index.html +++ b/templates/index.html @@ -83,6 +83,7 @@ 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') }}", + radiosonde: "{{ url_for('static', filename='css/modes/radiosonde.css') }}", system: "{{ url_for('static', filename='css/modes/system.css') }}" }; window.INTERCEPT_MODE_STYLE_LOADED = {}; @@ -307,6 +308,10 @@ GPS +
@@ -696,6 +701,8 @@ {% include 'partials/modes/ais.html' %} + {% include 'partials/modes/radiosonde.html' %} + {% include 'partials/modes/spy-stations.html' %} {% include 'partials/modes/meshtastic.html' %} @@ -3127,6 +3134,12 @@
+ + + diff --git a/utils/constants.py b/utils/constants.py index a0be48b..85552bd 100644 --- a/utils/constants.py +++ b/utils/constants.py @@ -300,6 +300,20 @@ SUBGHZ_PRESETS = { } +# ============================================================================= +# RADIOSONDE (Weather Balloon Tracking) +# ============================================================================= + +# UDP port for radiosonde_auto_rx telemetry broadcast +RADIOSONDE_UDP_PORT = 55673 + +# Radiosonde process termination timeout +RADIOSONDE_TERMINATE_TIMEOUT = 5 + +# Maximum age for balloon data before cleanup (30 min — balloons move slowly) +MAX_RADIOSONDE_AGE_SECONDS = 1800 + + # ============================================================================= # DEAUTH ATTACK DETECTION # ============================================================================= diff --git a/utils/dependencies.py b/utils/dependencies.py index 933be4b..72c2bf2 100644 --- a/utils/dependencies.py +++ b/utils/dependencies.py @@ -1,11 +1,11 @@ from __future__ import annotations -import logging -import os -import platform -import shutil -import subprocess -from typing import Any +import logging +import os +import platform +import shutil +import subprocess +from typing import Any logger = logging.getLogger('intercept.dependencies') @@ -18,32 +18,32 @@ def check_tool(name: str) -> bool: return get_tool_path(name) is not None -def get_tool_path(name: str) -> str | None: - """Get the full path to a tool, checking standard PATH and extra locations.""" - # Optional explicit override, e.g. INTERCEPT_RTL_FM_PATH=/opt/homebrew/bin/rtl_fm - env_key = f"INTERCEPT_{name.upper().replace('-', '_')}_PATH" - env_path = os.environ.get(env_key) - if env_path and os.path.isfile(env_path) and os.access(env_path, os.X_OK): - return env_path - - # Prefer native Homebrew binaries on Apple Silicon to avoid mixing Rosetta - # /usr/local tools with arm64 Python/runtime. - if platform.system() == 'Darwin': - machine = platform.machine().lower() - preferred_paths: list[str] = [] - if machine in {'arm64', 'aarch64'}: - preferred_paths.append('/opt/homebrew/bin') - preferred_paths.append('/usr/local/bin') - - for base in preferred_paths: - full_path = os.path.join(base, name) - if os.path.isfile(full_path) and os.access(full_path, os.X_OK): - return full_path - - # First check standard PATH - path = shutil.which(name) - if path: - return path +def get_tool_path(name: str) -> str | None: + """Get the full path to a tool, checking standard PATH and extra locations.""" + # Optional explicit override, e.g. INTERCEPT_RTL_FM_PATH=/opt/homebrew/bin/rtl_fm + env_key = f"INTERCEPT_{name.upper().replace('-', '_')}_PATH" + env_path = os.environ.get(env_key) + if env_path and os.path.isfile(env_path) and os.access(env_path, os.X_OK): + return env_path + + # Prefer native Homebrew binaries on Apple Silicon to avoid mixing Rosetta + # /usr/local tools with arm64 Python/runtime. + if platform.system() == 'Darwin': + machine = platform.machine().lower() + preferred_paths: list[str] = [] + if machine in {'arm64', 'aarch64'}: + preferred_paths.append('/opt/homebrew/bin') + preferred_paths.append('/usr/local/bin') + + for base in preferred_paths: + full_path = os.path.join(base, name) + if os.path.isfile(full_path) and os.access(full_path, os.X_OK): + return full_path + + # First check standard PATH + path = shutil.which(name) + if path: + return path # Check additional paths (e.g., /usr/sbin for aircrack-ng on Debian) for extra_path in EXTRA_TOOL_PATHS: @@ -447,6 +447,20 @@ TOOL_DEPENDENCIES = { } } }, + 'radiosonde': { + 'name': 'Radiosonde Tracking', + 'tools': { + 'auto_rx.py': { + 'required': True, + 'description': 'Radiosonde weather balloon decoder', + 'install': { + 'apt': 'Run ./setup.sh (clones from GitHub)', + 'brew': 'Run ./setup.sh (clones from GitHub)', + 'manual': 'https://github.com/projecthorus/radiosonde_auto_rx' + } + } + } + }, 'tscm': { 'name': 'TSCM Counter-Surveillance', 'tools': { From 3f6fa5ba282f567689679c1a0a9f43187acc9f75 Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 27 Feb 2026 10:50:16 +0000 Subject: [PATCH 09/22] fix: use project venv pip for radiosonde_auto_rx install Avoids PEP 668 externally-managed-environment error on Debian Bookworm by using the project's venv/bin/pip instead of system pip3. Co-Authored-By: Claude Opus 4.6 --- setup.sh | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/setup.sh b/setup.sh index e60fd77..d864f19 100755 --- a/setup.sh +++ b/setup.sh @@ -820,6 +820,7 @@ WRAPPER install_radiosonde_auto_rx() { info "Installing radiosonde_auto_rx (weather balloon decoder)..." local install_dir="/opt/radiosonde_auto_rx" + local project_dir="$(pwd)" ( tmp_dir="$(mktemp -d)" @@ -833,10 +834,19 @@ install_radiosonde_auto_rx() { info "Installing Python dependencies..." cd "$tmp_dir/radiosonde_auto_rx/auto_rx" - pip3 install --quiet -r requirements.txt || { - warn "Failed to install radiosonde_auto_rx Python dependencies" - exit 1 - } + # Use project venv pip to avoid PEP 668 externally-managed-environment errors + if [ -x "$project_dir/venv/bin/pip" ]; then + "$project_dir/venv/bin/pip" install --quiet -r requirements.txt || { + warn "Failed to install radiosonde_auto_rx Python dependencies" + exit 1 + } + else + pip3 install --quiet --break-system-packages -r requirements.txt 2>/dev/null \ + || pip3 install --quiet -r requirements.txt || { + warn "Failed to install radiosonde_auto_rx Python dependencies" + exit 1 + } + fi info "Installing to ${install_dir}..." refresh_sudo From 997dac3b9f63cc2f5aad9975fdae739d0c338435 Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 27 Feb 2026 11:13:44 +0000 Subject: [PATCH 10/22] fix: ADS-B device release leak and startup performance Move adsb_active_device/sdr_type assignment to immediately after claim_sdr_device so stop_adsb() can always release the device, even during startup. Sync sdr_type_str after SDRType fallback to prevent claim/release key mismatch. Clear active device on all error paths. Replace blind 3s sleep for dump1090 readiness with port-polling loop (100ms intervals, 3s max). Replace subprocess.run() in rtl_test probe with Popen + select-based early termination on success/error detection. Co-Authored-By: Claude Opus 4.6 --- routes/adsb.py | 24 ++++- utils/sdr/detection.py | 196 ++++++++++++++++++++++++----------------- 2 files changed, 134 insertions(+), 86 deletions(-) diff --git a/routes/adsb.py b/routes/adsb.py index 60cc4a9..1c97020 100644 --- a/routes/adsb.py +++ b/routes/adsb.py @@ -758,6 +758,7 @@ def start_adsb(): sdr_type = SDRType(sdr_type_str) except ValueError: sdr_type = SDRType.RTL_SDR + sdr_type_str = sdr_type.value # For RTL-SDR, use dump1090. For other hardware, need readsb with SoapySDR if sdr_type == SDRType.RTL_SDR: @@ -796,6 +797,10 @@ def start_adsb(): 'message': error }), 409 + # Track claimed device immediately so stop_adsb() can always release it + adsb_active_device = device + adsb_active_sdr_type = sdr_type_str + # Create device object and build command via abstraction layer sdr_device = SDRFactory.create_default_device(sdr_type, index=device) builder = SDRFactory.get_builder(sdr_type) @@ -822,11 +827,24 @@ def start_adsb(): ) write_dump1090_pid(app_module.adsb_process.pid) - time.sleep(DUMP1090_START_WAIT) + # Poll for dump1090 readiness instead of blind sleep + dump1090_ready = False + poll_interval = 0.1 + elapsed = 0.0 + while elapsed < DUMP1090_START_WAIT: + if app_module.adsb_process.poll() is not None: + break # Process exited early — handle below + if check_dump1090_service(): + dump1090_ready = True + break + time.sleep(poll_interval) + elapsed += poll_interval if app_module.adsb_process.poll() is not None: # Process exited - release device and get error message app_module.release_sdr_device(device_int, sdr_type_str) + adsb_active_device = None + adsb_active_sdr_type = None stderr_output = '' if app_module.adsb_process.stderr: try: @@ -872,8 +890,6 @@ def start_adsb(): }) adsb_using_service = True - adsb_active_device = device # Track which device is being used - adsb_active_sdr_type = sdr_type_str thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True) thread.start() @@ -894,6 +910,8 @@ def start_adsb(): except Exception as e: # Release device on failure app_module.release_sdr_device(device_int, sdr_type_str) + adsb_active_device = None + adsb_active_sdr_type = None return jsonify({'status': 'error', 'message': str(e)}) diff --git a/utils/sdr/detection.py b/utils/sdr/detection.py index cec3fc1..7d388aa 100644 --- a/utils/sdr/detection.py +++ b/utils/sdr/detection.py @@ -6,31 +6,31 @@ Detects RTL-SDR devices via rtl_test and other SDR hardware via SoapySDR. from __future__ import annotations -import logging -import re -import shutil -import subprocess -import time -from typing import Optional +import logging +import re +import shutil +import subprocess +import time +from typing import Optional from .base import SDRCapabilities, SDRDevice, SDRType -logger = logging.getLogger(__name__) - -# Cache HackRF detection results so polling endpoints don't repeatedly run -# hackrf_info while the device is actively streaming in SubGHz mode. -_hackrf_cache: list[SDRDevice] = [] -_hackrf_cache_ts: float = 0.0 -_HACKRF_CACHE_TTL_SECONDS = 3.0 - - -def _hackrf_probe_blocked() -> bool: - """Return True when probing HackRF would interfere with an active stream.""" - try: - from utils.subghz import get_subghz_manager - return get_subghz_manager().active_mode in {'rx', 'decode', 'tx', 'sweep'} - except Exception: - return False +logger = logging.getLogger(__name__) + +# Cache HackRF detection results so polling endpoints don't repeatedly run +# hackrf_info while the device is actively streaming in SubGHz mode. +_hackrf_cache: list[SDRDevice] = [] +_hackrf_cache_ts: float = 0.0 +_HACKRF_CACHE_TTL_SECONDS = 3.0 + + +def _hackrf_probe_blocked() -> bool: + """Return True when probing HackRF would interfere with an active stream.""" + try: + from utils.subghz import get_subghz_manager + return get_subghz_manager().active_mode in {'rx', 'decode', 'tx', 'sweep'} + except Exception: + return False def _check_tool(name: str) -> bool: @@ -112,21 +112,21 @@ def detect_rtlsdr_devices() -> list[SDRDevice]: lib_paths = ['/usr/local/lib', '/opt/homebrew/lib'] current_ld = env.get('DYLD_LIBRARY_PATH', '') env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + [current_ld] if current_ld else lib_paths) - result = subprocess.run( - ['rtl_test', '-t'], - capture_output=True, - text=True, - encoding='utf-8', - errors='replace', - timeout=5, - env=env - ) - output = result.stderr + result.stdout - - # Parse device info from rtl_test output - # Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001" - # Require a non-empty serial to avoid matching malformed lines like "SN:". - device_pattern = r'(\d+):\s+(.+?),\s*SN:\s*(\S+)\s*$' + result = subprocess.run( + ['rtl_test', '-t'], + capture_output=True, + text=True, + encoding='utf-8', + errors='replace', + timeout=5, + env=env + ) + output = result.stderr + result.stdout + + # Parse device info from rtl_test output + # Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001" + # Require a non-empty serial to avoid matching malformed lines like "SN:". + device_pattern = r'(\d+):\s+(.+?),\s*SN:\s*(\S+)\s*$' from .rtlsdr import RTLSDRCommandBuilder @@ -134,14 +134,14 @@ def detect_rtlsdr_devices() -> list[SDRDevice]: line = line.strip() match = re.match(device_pattern, line) if match: - devices.append(SDRDevice( - sdr_type=SDRType.RTL_SDR, - index=int(match.group(1)), - name=match.group(2).strip().rstrip(','), - serial=match.group(3), - driver='rtlsdr', - capabilities=RTLSDRCommandBuilder.CAPABILITIES - )) + devices.append(SDRDevice( + sdr_type=SDRType.RTL_SDR, + index=int(match.group(1)), + name=match.group(2).strip().rstrip(','), + serial=match.group(3), + driver='rtlsdr', + capabilities=RTLSDRCommandBuilder.CAPABILITIES + )) # Fallback: if we found devices but couldn't parse details if not devices: @@ -314,29 +314,29 @@ def _add_soapy_device( )) -def detect_hackrf_devices() -> list[SDRDevice]: - """ - Detect HackRF devices using native hackrf_info tool. - - Fallback for when SoapySDR is not available. - """ - global _hackrf_cache, _hackrf_cache_ts - now = time.time() - - # While HackRF is actively streaming in SubGHz mode, skip probe calls. - # Re-running hackrf_info during active RX/TX can disrupt the USB stream. - if _hackrf_probe_blocked(): - return list(_hackrf_cache) - - if _hackrf_cache and (now - _hackrf_cache_ts) < _HACKRF_CACHE_TTL_SECONDS: - return list(_hackrf_cache) - - devices: list[SDRDevice] = [] - - if not _check_tool('hackrf_info'): - _hackrf_cache = devices - _hackrf_cache_ts = now - return devices +def detect_hackrf_devices() -> list[SDRDevice]: + """ + Detect HackRF devices using native hackrf_info tool. + + Fallback for when SoapySDR is not available. + """ + global _hackrf_cache, _hackrf_cache_ts + now = time.time() + + # While HackRF is actively streaming in SubGHz mode, skip probe calls. + # Re-running hackrf_info during active RX/TX can disrupt the USB stream. + if _hackrf_probe_blocked(): + return list(_hackrf_cache) + + if _hackrf_cache and (now - _hackrf_cache_ts) < _HACKRF_CACHE_TTL_SECONDS: + return list(_hackrf_cache) + + devices: list[SDRDevice] = [] + + if not _check_tool('hackrf_info'): + _hackrf_cache = devices + _hackrf_cache_ts = now + return devices try: result = subprocess.run( @@ -374,12 +374,12 @@ def detect_hackrf_devices() -> list[SDRDevice]: capabilities=HackRFCommandBuilder.CAPABILITIES )) - except Exception as e: - logger.debug(f"HackRF detection error: {e}") - - _hackrf_cache = list(devices) - _hackrf_cache_ts = now - return devices + except Exception as e: + logger.debug(f"HackRF detection error: {e}") + + _hackrf_cache = list(devices) + _hackrf_cache_ts = now + return devices def probe_rtlsdr_device(device_index: int) -> str | None: @@ -413,16 +413,50 @@ def probe_rtlsdr_device(device_index: int) -> str | None: lib_paths + [current_ld] if current_ld else lib_paths ) - result = subprocess.run( + # Use Popen with early termination instead of run() with full timeout. + # rtl_test prints device info to stderr quickly, then keeps running + # its test loop. We kill it as soon as we see success or failure. + proc = subprocess.Popen( ['rtl_test', '-d', str(device_index), '-t'], - capture_output=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, - timeout=3, env=env, ) - output = result.stderr + result.stdout - if 'usb_claim_interface' in output or 'Failed to open' in output: + import select + error_found = False + deadline = time.monotonic() + 3.0 + + try: + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + # Wait for stderr output with timeout + ready, _, _ = select.select( + [proc.stderr], [], [], min(remaining, 0.1) + ) + if ready: + line = proc.stderr.readline() + if not line: + break # EOF — process closed stderr + if 'usb_claim_interface' in line or 'Failed to open' in line: + error_found = True + break + if 'Found' in line and 'device' in line.lower(): + # Device opened successfully — no need to wait longer + break + if proc.poll() is not None: + break # Process exited + finally: + try: + proc.kill() + except OSError: + pass + proc.wait() + + if error_found: logger.warning( f"RTL-SDR device {device_index} USB probe failed: " f"device busy or unavailable" @@ -434,10 +468,6 @@ def probe_rtlsdr_device(device_index: int) -> str | None: f'or try a different device.' ) - except subprocess.TimeoutExpired: - # rtl_test opened the device successfully and is running the - # test — that means the device *is* available. - pass except Exception as e: logger.debug(f"RTL-SDR probe error for device {device_index}: {e}") From 952736c1270f3c538343f6fe57271d7d659f003b Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 27 Feb 2026 11:15:50 +0000 Subject: [PATCH 11/22] fix: set cwd for radiosonde subprocess so autorx package imports resolve Co-Authored-By: Claude Opus 4.6 --- routes/radiosonde.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/routes/radiosonde.py b/routes/radiosonde.py index 2fb1ae0..f95698e 100644 --- a/routes/radiosonde.py +++ b/routes/radiosonde.py @@ -420,6 +420,9 @@ def start_radiosonde(): else: cmd = [auto_rx_path, '-c', cfg_dir] + # Set cwd to the auto_rx directory so 'from autorx.scan import ...' works + auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path)) + try: logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}") app_module.radiosonde_process = subprocess.Popen( @@ -427,6 +430,7 @@ def start_radiosonde(): stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, start_new_session=True, + cwd=auto_rx_dir, ) # Wait briefly for process to start From db2f3fc8e5f0d65e5a771200c8be9104343f1161 Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 27 Feb 2026 11:17:19 +0000 Subject: [PATCH 12/22] fix: use sys.executable for radiosonde subprocess to find venv packages The subprocess was launched with bare 'python' which on Debian doesn't exist (python3 only) and wouldn't have access to the venv-installed radiosonde dependencies anyway. Using sys.executable ensures the same interpreter (with all installed packages) is used. Co-Authored-By: Claude Opus 4.6 --- routes/radiosonde.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/routes/radiosonde.py b/routes/radiosonde.py index f95698e..66838a8 100644 --- a/routes/radiosonde.py +++ b/routes/radiosonde.py @@ -12,6 +12,7 @@ import os import queue import shutil import socket +import sys import subprocess import threading import time @@ -416,7 +417,7 @@ def start_radiosonde(): # Build command cfg_dir = os.path.dirname(os.path.abspath(cfg_path)) if auto_rx_path.endswith('.py'): - cmd = ['python', auto_rx_path, '-c', cfg_dir] + cmd = [sys.executable, auto_rx_path, '-c', cfg_dir] else: cmd = [auto_rx_path, '-c', cfg_dir] From 24d50c921eda960159f363b464de79aad93f2dbc Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 27 Feb 2026 11:19:23 +0000 Subject: [PATCH 13/22] fix: build radiosonde_auto_rx C decoders (dft_detect, fsk_demod, etc.) setup.sh and Dockerfile were installing the Python package and copying files but skipping the build.sh step that compiles the C decoders. This caused "Binary dft_detect does not exist" at runtime. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 1 + setup.sh | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/Dockerfile b/Dockerfile index aab54a8..1d0b789 100644 --- a/Dockerfile +++ b/Dockerfile @@ -205,6 +205,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && git clone --depth 1 https://github.com/projecthorus/radiosonde_auto_rx.git \ && cd radiosonde_auto_rx/auto_rx \ && pip install --no-cache-dir -r requirements.txt \ + && bash build.sh \ && mkdir -p /opt/radiosonde_auto_rx/auto_rx \ && cp -r . /opt/radiosonde_auto_rx/auto_rx/ \ && chmod +x /opt/radiosonde_auto_rx/auto_rx/auto_rx.py \ diff --git a/setup.sh b/setup.sh index d864f19..f7454f5 100755 --- a/setup.sh +++ b/setup.sh @@ -848,6 +848,12 @@ install_radiosonde_auto_rx() { } fi + info "Building radiosonde_auto_rx C decoders..." + if ! bash build.sh; then + warn "Failed to build radiosonde_auto_rx decoders" + exit 1 + fi + info "Installing to ${install_dir}..." refresh_sudo $SUDO mkdir -p "$install_dir/auto_rx" From 3254d82d1112d409a846fdb5e433b0c220884a59 Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 27 Feb 2026 11:21:50 +0000 Subject: [PATCH 14/22] fix: re-run radiosonde install when C decoders are missing The setup.sh skip check only looked for auto_rx.py, so a previous incomplete install (Python files but no compiled binaries) would be treated as fully installed. Now also checks for dft_detect binary. Co-Authored-By: Claude Opus 4.6 --- setup.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/setup.sh b/setup.sh index f7454f5..a31cb04 100755 --- a/setup.sh +++ b/setup.sh @@ -961,7 +961,8 @@ install_macos_packages() { fi progress "Installing radiosonde_auto_rx (optional)" - if ! cmd_exists auto_rx.py && [ ! -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ]; then + if ! cmd_exists auto_rx.py && [ ! -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ] \ + || { [ -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ] && [ ! -f /opt/radiosonde_auto_rx/auto_rx/dft_detect ]; }; then echo info "radiosonde_auto_rx is used for weather balloon (radiosonde) tracking." if ask_yes_no "Do you want to install radiosonde_auto_rx?"; then @@ -1547,7 +1548,8 @@ install_debian_packages() { fi progress "Installing radiosonde_auto_rx (optional)" - if ! cmd_exists auto_rx.py && [ ! -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ]; then + if ! cmd_exists auto_rx.py && [ ! -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ] \ + || { [ -f /opt/radiosonde_auto_rx/auto_rx/auto_rx.py ] && [ ! -f /opt/radiosonde_auto_rx/auto_rx/dft_detect ]; }; then echo info "radiosonde_auto_rx is used for weather balloon (radiosonde) tracking." if ask_yes_no "Do you want to install radiosonde_auto_rx?"; then From e176438934555f1e24728174095b57b3b5ab7703 Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 27 Feb 2026 11:27:16 +0000 Subject: [PATCH 15/22] fix: radiosonde config path and dependency detection - Pass config file path (not directory) to auto_rx -c flag - Use absolute paths in generated station.cfg since auto_rx runs with cwd set to its install directory - Teach dependency checker about auto_rx.py at /opt install path so the "missing dependency" banner no longer appears Co-Authored-By: Claude Opus 4.6 --- routes/radiosonde.py | 16 +++++++++------- utils/dependencies.py | 13 +++++++++++++ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/routes/radiosonde.py b/routes/radiosonde.py index 66838a8..611f298 100644 --- a/routes/radiosonde.py +++ b/routes/radiosonde.py @@ -85,11 +85,13 @@ def generate_station_cfg( udp_port: int = RADIOSONDE_UDP_PORT, ) -> str: """Generate a station.cfg for radiosonde_auto_rx and return the file path.""" - cfg_dir = os.path.join('data', 'radiosonde') - os.makedirs(cfg_dir, exist_ok=True) + cfg_dir = os.path.abspath(os.path.join('data', 'radiosonde')) + log_dir = os.path.join(cfg_dir, 'logs') + os.makedirs(log_dir, exist_ok=True) cfg_path = os.path.join(cfg_dir, 'station.cfg') # Minimal station.cfg that auto_rx needs + # Use absolute paths since auto_rx runs with cwd set to its install dir cfg = f"""# Auto-generated by INTERCEPT for radiosonde_auto_rx [search_params] min_freq = {freq_min} @@ -122,7 +124,7 @@ station_alt = 0.0 [logging] per_sonde_log = True -log_directory = ./data/radiosonde/logs +log_directory = {log_dir} [advanced] web_host = 127.0.0.1 @@ -414,12 +416,12 @@ def start_radiosonde(): bias_t=bias_t, ) - # Build command - cfg_dir = os.path.dirname(os.path.abspath(cfg_path)) + # Build command - auto_rx -c expects a file path, not a directory + cfg_abs = os.path.abspath(cfg_path) if auto_rx_path.endswith('.py'): - cmd = [sys.executable, auto_rx_path, '-c', cfg_dir] + cmd = [sys.executable, auto_rx_path, '-c', cfg_abs] else: - cmd = [auto_rx_path, '-c', cfg_dir] + cmd = [auto_rx_path, '-c', cfg_abs] # Set cwd to the auto_rx directory so 'from autorx.scan import ...' works auto_rx_dir = os.path.dirname(os.path.abspath(auto_rx_path)) diff --git a/utils/dependencies.py b/utils/dependencies.py index 72c2bf2..e482bb2 100644 --- a/utils/dependencies.py +++ b/utils/dependencies.py @@ -12,6 +12,14 @@ logger = logging.getLogger('intercept.dependencies') # Additional paths to search for tools (e.g., /usr/sbin on Debian) EXTRA_TOOL_PATHS = ['/usr/sbin', '/sbin'] +# Tools installed to non-standard locations (not on PATH) +KNOWN_TOOL_PATHS: dict[str, list[str]] = { + 'auto_rx.py': [ + '/opt/radiosonde_auto_rx/auto_rx/auto_rx.py', + '/opt/auto_rx/auto_rx.py', + ], +} + def check_tool(name: str) -> bool: """Check if a tool is installed.""" @@ -51,6 +59,11 @@ def get_tool_path(name: str) -> str | None: if os.path.isfile(full_path) and os.access(full_path, os.X_OK): return full_path + # Check known non-standard install locations + for known_path in KNOWN_TOOL_PATHS.get(name, []): + if os.path.isfile(known_path): + return known_path + return None From 79a0dae04b18f37fb87f812c3b1a027fcdd7e7f2 Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 27 Feb 2026 11:29:53 +0000 Subject: [PATCH 16/22] fix: rewrite radiosonde station.cfg to match auto_rx v1.8+ format The config format changed significantly: SDR settings moved to [sdr_1], [positioning] became [location], and many sections are now required. Also enable payload_summary UDP output so telemetry reaches our listener. Co-Authored-By: Claude Opus 4.6 --- routes/radiosonde.py | 75 ++++++++++++++++++++++++++++++-------------- 1 file changed, 51 insertions(+), 24 deletions(-) diff --git a/routes/radiosonde.py b/routes/radiosonde.py index 611f298..eef4169 100644 --- a/routes/radiosonde.py +++ b/routes/radiosonde.py @@ -90,46 +90,73 @@ def generate_station_cfg( os.makedirs(log_dir, exist_ok=True) cfg_path = os.path.join(cfg_dir, 'station.cfg') - # Minimal station.cfg that auto_rx needs + # station.cfg matching radiosonde_auto_rx v1.8+ expected format # Use absolute paths since auto_rx runs with cwd set to its install dir cfg = f"""# Auto-generated by INTERCEPT for radiosonde_auto_rx + +[sdr] +sdr_type = RTLSDR +sdr_quantity = 1 + +[sdr_1] +device_idx = {device_index} +ppm = {ppm} +gain = {gain} +bias = {str(bias_t)} + [search_params] min_freq = {freq_min} max_freq = {freq_max} rx_timeout = 180 -whitelist = [] -blacklist = [] -greylist = [] +only_scan = [] +never_scan = [] +always_scan = [] -[sdr] -sdr_type = rtlsdr -rtlsdr_device_idx = {device_index} -rtlsdr_gain = {gain} -rtlsdr_ppm = {ppm} -rtlsdr_bias = {str(bias_t).lower()} - -[habitat] -upload_enabled = False - -[aprs] -upload_enabled = False - -[sondehub] -upload_enabled = False - -[positioning] +[location] station_lat = 0.0 station_lon = 0.0 station_alt = 0.0 +[habitat] +uploader_callsign = INTERCEPT +upload_listener_position = False + +[sondehub] +sondehub_enabled = False + +[aprs] +aprs_enabled = False + +[oziplotter] +ozi_enabled = False +payload_summary_enabled = True +payload_summary_host = 127.0.0.1 +payload_summary_port = {udp_port} + +[email] +email_enabled = False + +[rotator] +rotator_enabled = False + [logging] per_sonde_log = True -log_directory = {log_dir} +save_system_log = False +enable_debug_logging = False -[advanced] +[web] web_host = 127.0.0.1 web_port = 0 -udp_broadcast_port = {udp_port} +web_control = False + +[debugging] +save_detection_audio = False +save_decode_audio = False +save_decode_iq = False +save_raw_hex = False + +[advanced] +synchronous_upload = True """ with open(cfg_path, 'w') as f: From 824514d9220030152cf791fb92d5d8c8105b5a0e Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 27 Feb 2026 11:31:41 +0000 Subject: [PATCH 17/22] fix: use complete station.cfg with all required fields for auto_rx v1.8+ Auto_rx reads many config keys without defaults and crashes if they're missing, even for disabled features like email. Include every section and key from the example config to prevent missing-key errors. Co-Authored-By: Claude Opus 4.6 --- routes/radiosonde.py | 78 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/routes/radiosonde.py b/routes/radiosonde.py index eef4169..25f68fb 100644 --- a/routes/radiosonde.py +++ b/routes/radiosonde.py @@ -90,13 +90,15 @@ def generate_station_cfg( os.makedirs(log_dir, exist_ok=True) cfg_path = os.path.join(cfg_dir, 'station.cfg') - # station.cfg matching radiosonde_auto_rx v1.8+ expected format - # Use absolute paths since auto_rx runs with cwd set to its install dir + # Full station.cfg based on radiosonde_auto_rx v1.8+ example config. + # All sections and keys included to avoid missing-key crashes. cfg = f"""# Auto-generated by INTERCEPT for radiosonde_auto_rx [sdr] sdr_type = RTLSDR sdr_quantity = 1 +sdr_hostname = localhost +sdr_port = 5555 [sdr_1] device_idx = {device_index} @@ -111,43 +113,92 @@ rx_timeout = 180 only_scan = [] never_scan = [] always_scan = [] +always_decode = [] [location] station_lat = 0.0 station_lon = 0.0 station_alt = 0.0 +gpsd_enabled = False +gpsd_host = localhost +gpsd_port = 2947 [habitat] uploader_callsign = INTERCEPT upload_listener_position = False +uploader_antenna = unknown [sondehub] sondehub_enabled = False +sondehub_upload_rate = 15 +sondehub_contact_email = none@none.com [aprs] aprs_enabled = False +aprs_user = N0CALL +aprs_pass = 00000 +upload_rate = 30 +aprs_server = radiosondy.info +aprs_port = 14580 +station_beacon_enabled = False +station_beacon_rate = 30 +station_beacon_comment = radiosonde_auto_rx +station_beacon_icon = /` +aprs_object_id = +aprs_use_custom_object_id = False +aprs_custom_comment = [oziplotter] ozi_enabled = False +ozi_update_rate = 5 +ozi_host = 127.0.0.1 +ozi_port = 8942 payload_summary_enabled = True payload_summary_host = 127.0.0.1 payload_summary_port = {udp_port} [email] email_enabled = False +launch_notifications = True +landing_notifications = True +encrypted_sonde_notifications = True +landing_range_threshold = 30 +landing_altitude_threshold = 1000 +error_notifications = False +smtp_server = localhost +smtp_port = 25 +smtp_authentication = None +smtp_login = None +smtp_password = None +from = sonde@localhost +to = none@none.com +subject = Sonde launch detected [rotator] rotator_enabled = False +update_rate = 30 +rotation_threshold = 5.0 +rotator_hostname = 127.0.0.1 +rotator_port = 4533 +rotator_homing_enabled = False +rotator_homing_delay = 10 +rotator_home_azimuth = 0.0 +rotator_home_elevation = 0.0 +azimuth_only = False [logging] per_sonde_log = True save_system_log = False enable_debug_logging = False +save_cal_data = False [web] web_host = 127.0.0.1 web_port = 0 +archive_age = 120 web_control = False +web_password = none +kml_refresh_rate = 10 [debugging] save_detection_audio = False @@ -156,7 +207,30 @@ save_decode_iq = False save_raw_hex = False [advanced] +search_step = 800 +snr_threshold = 10 +max_peaks = 10 +min_distance = 1000 +scan_dwell_time = 20 +detect_dwell_time = 5 +scan_delay = 10 +quantization = 10000 +decoder_spacing_limit = 15000 +temporary_block_time = 120 +max_async_scan_workers = 4 synchronous_upload = True +payload_id_valid = 3 +sdr_fm_path = rtl_fm +sdr_power_path = rtl_power +ss_iq_path = ./ss_iq +ss_power_path = ./ss_power + +[filtering] +max_altitude = 50000 +max_radius_km = 1000 +min_radius_km = 0 +radius_temporary_block = False +sonde_time_threshold = 3 """ with open(cfg_path, 'w') as f: From 7683a925df5d46a38b05bffacbb8b714660d0d44 Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 27 Feb 2026 12:18:54 +0000 Subject: [PATCH 18/22] fix: update radiosonde stop UI immediately on click The stop button appeared unresponsive because UI updates waited for the server response. If the fetch hung or errored, the user saw nothing. Now the UI updates immediately (matching the pager stop pattern) and the server request happens in the background. Co-Authored-By: Claude Opus 4.6 --- templates/partials/modes/radiosonde.html | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/templates/partials/modes/radiosonde.html b/templates/partials/modes/radiosonde.html index 2b3a9dc..0ee3d70 100644 --- a/templates/partials/modes/radiosonde.html +++ b/templates/partials/modes/radiosonde.html @@ -166,19 +166,23 @@ } function stopRadiosondeTracking() { + // Update UI immediately so the user sees feedback + document.getElementById('startRadiosondeBtn').style.display = 'block'; + document.getElementById('stopRadiosondeBtn').style.display = 'none'; + document.getElementById('radiosondeStatusText').textContent = 'Stopping...'; + document.getElementById('radiosondeStatusText').style.color = 'var(--accent-yellow)'; + + if (radiosondeEventSource) { + radiosondeEventSource.close(); + radiosondeEventSource = null; + } + fetch('/radiosonde/stop', { method: 'POST' }) .then(r => r.json()) .then(() => { - document.getElementById('startRadiosondeBtn').style.display = 'block'; - document.getElementById('stopRadiosondeBtn').style.display = 'none'; document.getElementById('radiosondeStatusText').textContent = 'Standby'; - document.getElementById('radiosondeStatusText').style.color = 'var(--accent-yellow)'; document.getElementById('radiosondeBalloonCount').textContent = '0'; document.getElementById('radiosondeLastUpdate').textContent = '\u2014'; - if (radiosondeEventSource) { - radiosondeEventSource.close(); - radiosondeEventSource = null; - } radiosondeBalloons = {}; // Clear map markers if (typeof radiosondeMap !== 'undefined' && radiosondeMap) { @@ -187,6 +191,9 @@ radiosondeTracks.forEach(t => radiosondeMap.removeLayer(t)); radiosondeTracks.clear(); } + }) + .catch(() => { + document.getElementById('radiosondeStatusText').textContent = 'Standby'; }); } From 54987e4c8d54e1b54bed61055dca095c9ce2782e Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 27 Feb 2026 14:35:28 +0000 Subject: [PATCH 19/22] fix: ADS-B probe incorrectly treats "No devices found" as success The success check ('Found' in line and 'device' in line) matched "No supported devices found" since both keywords appear. Add a pre-check for negative device messages, a return code fallback, and a clearer error message. Co-Authored-By: Claude Opus 4.6 --- utils/sdr/detection.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/utils/sdr/detection.py b/utils/sdr/detection.py index 7d388aa..1e49517 100644 --- a/utils/sdr/detection.py +++ b/utils/sdr/detection.py @@ -441,6 +441,11 @@ def probe_rtlsdr_device(device_index: int) -> str | None: line = proc.stderr.readline() if not line: break # EOF — process closed stderr + # Check for no-device messages first (before success check, + # since "No supported devices found" also contains "Found" + "device") + if 'no supported devices' in line.lower() or 'no matching devices' in line.lower(): + error_found = True + break if 'usb_claim_interface' in line or 'Failed to open' in line: error_found = True break @@ -449,6 +454,9 @@ def probe_rtlsdr_device(device_index: int) -> str | None: break if proc.poll() is not None: break # Process exited + if proc.poll() is not None and proc.returncode != 0 and not error_found: + # rtl_test exited with error but we didn't match a specific keyword + error_found = True finally: try: proc.kill() @@ -462,10 +470,8 @@ def probe_rtlsdr_device(device_index: int) -> str | None: f"device busy or unavailable" ) return ( - f'SDR device {device_index} is busy at the USB level — ' - f'another process outside INTERCEPT may be using it. ' - f'Check for stale rtl_fm/rtl_433/dump1090 processes, ' - f'or try a different device.' + f'SDR device {device_index} is not available — ' + f'check that the RTL-SDR is connected and not in use by another process.' ) except Exception as e: From 7af6d45ca1c91a982237504d169b33a0ba9067b7 Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 27 Feb 2026 14:39:39 +0000 Subject: [PATCH 20/22] fix: probe return code check incorrectly blocks valid devices rtl_test -t often exits non-zero after finding a device (e.g. "No E4000 tuner found, aborting" with R820T tuners). The return code fallback was firing even when the "Found N device(s)" success message had already been matched. Track device_found separately and only use return code as fallback when no success was seen. Co-Authored-By: Claude Opus 4.6 --- utils/sdr/detection.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/utils/sdr/detection.py b/utils/sdr/detection.py index 1e49517..f00eb7d 100644 --- a/utils/sdr/detection.py +++ b/utils/sdr/detection.py @@ -426,6 +426,7 @@ def probe_rtlsdr_device(device_index: int) -> str | None: import select error_found = False + device_found = False deadline = time.monotonic() + 3.0 try: @@ -451,11 +452,12 @@ def probe_rtlsdr_device(device_index: int) -> str | None: break if 'Found' in line and 'device' in line.lower(): # Device opened successfully — no need to wait longer + device_found = True break if proc.poll() is not None: break # Process exited - if proc.poll() is not None and proc.returncode != 0 and not error_found: - # rtl_test exited with error but we didn't match a specific keyword + if not device_found and not error_found and proc.poll() is not None and proc.returncode != 0: + # rtl_test exited with error and we never saw a success message error_found = True finally: try: From fb064a22fb33a85c434b40f14dcbca3b083c2b5e Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 27 Feb 2026 14:44:17 +0000 Subject: [PATCH 21/22] fix: add delay after probe to prevent USB claim race with dump1090 rtl_test opens the USB device during probing. After killing the process, the kernel may not release the USB interface immediately. dump1090 then fails with usb_claim_interface error -6. Add a 0.5s delay after probe cleanup to allow the kernel to fully release the device before the actual decoder opens it. Co-Authored-By: Claude Opus 4.6 --- utils/sdr/detection.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/utils/sdr/detection.py b/utils/sdr/detection.py index f00eb7d..2f90d3f 100644 --- a/utils/sdr/detection.py +++ b/utils/sdr/detection.py @@ -465,6 +465,10 @@ def probe_rtlsdr_device(device_index: int) -> str | None: except OSError: pass proc.wait() + if device_found: + # Allow the kernel to fully release the USB interface + # before the caller opens the device with dump1090/rtl_fm/etc. + time.sleep(0.5) if error_found: logger.warning( From 4f096c6c0183ec761974f43c76370fdb8344b875 Mon Sep 17 00:00:00 2001 From: Smittix Date: Fri, 27 Feb 2026 19:18:13 +0000 Subject: [PATCH 22/22] perf: add destroy() lifecycle to all mode modules to prevent resource leaks Mode modules were leaking EventSource connections, setInterval timers, and setTimeout timers on every mode switch, causing progressive browser sluggishness. Added destroy() to 8 modules missing it (meshtastic, bluetooth, wifi, bt_locate, sstv, sstv-general, websdr, spy-stations) and centralized all destroy calls in switchMode() via a moduleDestroyMap that cleanly tears down only the previous mode. Co-Authored-By: Claude Opus 4.6 --- static/js/modes/bluetooth.js | 916 ++++++++++++++++---------------- static/js/modes/bt_locate.js | 35 ++ static/js/modes/meshtastic.js | 32 +- static/js/modes/spy-stations.js | 10 +- static/js/modes/sstv-general.js | 10 +- static/js/modes/sstv.js | 185 ++++--- static/js/modes/websdr.js | 10 + static/js/modes/wifi.js | 769 ++++++++++++++------------- templates/index.html | 52 +- 9 files changed, 1063 insertions(+), 956 deletions(-) diff --git a/static/js/modes/bluetooth.js b/static/js/modes/bluetooth.js index 2a7c856..f432e98 100644 --- a/static/js/modes/bluetooth.js +++ b/static/js/modes/bluetooth.js @@ -27,22 +27,22 @@ const BluetoothMode = (function() { trackers: [] }; - // Zone counts for proximity display - let zoneCounts = { immediate: 0, near: 0, far: 0 }; + // Zone counts for proximity display + let zoneCounts = { immediate: 0, near: 0, far: 0 }; // New visualization components let radarInitialized = false; let radarPaused = false; - // Device list filter - let currentDeviceFilter = 'all'; - let currentSearchTerm = ''; - let visibleDeviceCount = 0; - let pendingDeviceFlush = false; - let selectedDeviceNeedsRefresh = false; - let filterListenersBound = false; - let listListenersBound = false; - const pendingDeviceIds = new Set(); + // Device list filter + let currentDeviceFilter = 'all'; + let currentSearchTerm = ''; + let visibleDeviceCount = 0; + let pendingDeviceFlush = false; + let selectedDeviceNeedsRefresh = false; + let filterListenersBound = false; + let listListenersBound = false; + const pendingDeviceIds = new Set(); // Agent support let showAllAgentsMode = false; @@ -116,9 +116,9 @@ const BluetoothMode = (function() { // Initialize legacy heatmap (zone counts) initHeatmap(); - // Initialize device list filters - initDeviceFilters(); - initListInteractions(); + // Initialize device list filters + initDeviceFilters(); + initListInteractions(); // Set initial panel states updateVisualizationPanels(); @@ -127,133 +127,133 @@ const BluetoothMode = (function() { /** * Initialize device list filter buttons */ - function initDeviceFilters() { - if (filterListenersBound) return; - const filterContainer = document.getElementById('btDeviceFilters'); - if (filterContainer) { - filterContainer.addEventListener('click', (e) => { - const btn = e.target.closest('.bt-filter-btn'); - if (!btn) return; - - const filter = btn.dataset.filter; - if (!filter) return; - - // Update active state - filterContainer.querySelectorAll('.bt-filter-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - - // Apply filter - currentDeviceFilter = filter; - applyDeviceFilter(); - }); - } - - const searchInput = document.getElementById('btDeviceSearch'); - if (searchInput) { - searchInput.addEventListener('input', () => { - currentSearchTerm = searchInput.value.trim().toLowerCase(); - applyDeviceFilter(); - }); - } - filterListenersBound = true; - } - - function initListInteractions() { - if (listListenersBound) return; - if (deviceContainer) { - deviceContainer.addEventListener('click', (event) => { - const locateBtn = event.target.closest('.bt-locate-btn[data-locate-id]'); - if (locateBtn) { - event.preventDefault(); - locateById(locateBtn.dataset.locateId); - return; - } - - const row = event.target.closest('.bt-device-row[data-bt-device-id]'); - if (!row) return; - selectDevice(row.dataset.btDeviceId); - }); - } - - const trackerList = document.getElementById('btTrackerList'); - if (trackerList) { - trackerList.addEventListener('click', (event) => { - const row = event.target.closest('.bt-tracker-item[data-device-id]'); - if (!row) return; - selectDevice(row.dataset.deviceId); - }); - } - listListenersBound = true; - } + function initDeviceFilters() { + if (filterListenersBound) return; + const filterContainer = document.getElementById('btDeviceFilters'); + if (filterContainer) { + filterContainer.addEventListener('click', (e) => { + const btn = e.target.closest('.bt-filter-btn'); + if (!btn) return; + + const filter = btn.dataset.filter; + if (!filter) return; + + // Update active state + filterContainer.querySelectorAll('.bt-filter-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + + // Apply filter + currentDeviceFilter = filter; + applyDeviceFilter(); + }); + } + + const searchInput = document.getElementById('btDeviceSearch'); + if (searchInput) { + searchInput.addEventListener('input', () => { + currentSearchTerm = searchInput.value.trim().toLowerCase(); + applyDeviceFilter(); + }); + } + filterListenersBound = true; + } + + function initListInteractions() { + if (listListenersBound) return; + if (deviceContainer) { + deviceContainer.addEventListener('click', (event) => { + const locateBtn = event.target.closest('.bt-locate-btn[data-locate-id]'); + if (locateBtn) { + event.preventDefault(); + locateById(locateBtn.dataset.locateId); + return; + } + + const row = event.target.closest('.bt-device-row[data-bt-device-id]'); + if (!row) return; + selectDevice(row.dataset.btDeviceId); + }); + } + + const trackerList = document.getElementById('btTrackerList'); + if (trackerList) { + trackerList.addEventListener('click', (event) => { + const row = event.target.closest('.bt-tracker-item[data-device-id]'); + if (!row) return; + selectDevice(row.dataset.deviceId); + }); + } + listListenersBound = true; + } /** * Apply current filter to device list */ - function applyDeviceFilter() { - if (!deviceContainer) return; - - const cards = deviceContainer.querySelectorAll('[data-bt-device-id]'); - let visibleCount = 0; - cards.forEach(card => { - const isNew = card.dataset.isNew === 'true'; - const hasName = card.dataset.hasName === 'true'; - const rssi = parseInt(card.dataset.rssi) || -100; - const isTracker = card.dataset.isTracker === 'true'; - const searchHaystack = (card.dataset.search || '').toLowerCase(); - - let matchesFilter = true; - switch (currentDeviceFilter) { - case 'new': - matchesFilter = isNew; - break; - case 'named': - matchesFilter = hasName; - break; - case 'strong': - matchesFilter = rssi >= -70; - break; - case 'trackers': - matchesFilter = isTracker; - break; - case 'all': - default: - matchesFilter = true; - } - - const matchesSearch = !currentSearchTerm || searchHaystack.includes(currentSearchTerm); - const visible = matchesFilter && matchesSearch; - card.style.display = visible ? '' : 'none'; - if (visible) visibleCount++; - }); - - visibleDeviceCount = visibleCount; - - let stateEl = deviceContainer.querySelector('.bt-device-filter-state'); - if (visibleCount === 0 && devices.size > 0) { - if (!stateEl) { - stateEl = document.createElement('div'); - stateEl.className = 'bt-device-filter-state app-collection-state is-empty'; - deviceContainer.appendChild(stateEl); - } - stateEl.textContent = 'No devices match current filters'; - } else if (stateEl) { - stateEl.remove(); - } - - // Update visible count - updateFilteredCount(); - } + function applyDeviceFilter() { + if (!deviceContainer) return; + + const cards = deviceContainer.querySelectorAll('[data-bt-device-id]'); + let visibleCount = 0; + cards.forEach(card => { + const isNew = card.dataset.isNew === 'true'; + const hasName = card.dataset.hasName === 'true'; + const rssi = parseInt(card.dataset.rssi) || -100; + const isTracker = card.dataset.isTracker === 'true'; + const searchHaystack = (card.dataset.search || '').toLowerCase(); + + let matchesFilter = true; + switch (currentDeviceFilter) { + case 'new': + matchesFilter = isNew; + break; + case 'named': + matchesFilter = hasName; + break; + case 'strong': + matchesFilter = rssi >= -70; + break; + case 'trackers': + matchesFilter = isTracker; + break; + case 'all': + default: + matchesFilter = true; + } + + const matchesSearch = !currentSearchTerm || searchHaystack.includes(currentSearchTerm); + const visible = matchesFilter && matchesSearch; + card.style.display = visible ? '' : 'none'; + if (visible) visibleCount++; + }); + + visibleDeviceCount = visibleCount; + + let stateEl = deviceContainer.querySelector('.bt-device-filter-state'); + if (visibleCount === 0 && devices.size > 0) { + if (!stateEl) { + stateEl = document.createElement('div'); + stateEl.className = 'bt-device-filter-state app-collection-state is-empty'; + deviceContainer.appendChild(stateEl); + } + stateEl.textContent = 'No devices match current filters'; + } else if (stateEl) { + stateEl.remove(); + } + + // Update visible count + updateFilteredCount(); + } /** * Update the device count display based on visible devices */ - function updateFilteredCount() { - const countEl = document.getElementById('btDeviceListCount'); - if (!countEl || !deviceContainer) return; - - const hasFilter = currentDeviceFilter !== 'all' || currentSearchTerm.length > 0; - countEl.textContent = hasFilter ? `${visibleDeviceCount}/${devices.size}` : devices.size; - } + function updateFilteredCount() { + const countEl = document.getElementById('btDeviceListCount'); + if (!countEl || !deviceContainer) return; + + const hasFilter = currentDeviceFilter !== 'all' || currentSearchTerm.length > 0; + countEl.textContent = hasFilter ? `${visibleDeviceCount}/${devices.size}` : devices.size; + } /** * Initialize the new proximity radar component @@ -369,20 +369,20 @@ const BluetoothMode = (function() { /** * Update proximity zone counts (simple HTML, no canvas) */ - function updateProximityZones() { - zoneCounts = { immediate: 0, near: 0, far: 0 }; - - devices.forEach(device => { - const rssi = device.rssi_current; - if (rssi == null) return; - - if (rssi >= -50) zoneCounts.immediate++; - else if (rssi >= -70) zoneCounts.near++; - else zoneCounts.far++; - }); - - updateProximityZoneCounts(zoneCounts); - } + function updateProximityZones() { + zoneCounts = { immediate: 0, near: 0, far: 0 }; + + devices.forEach(device => { + const rssi = device.rssi_current; + if (rssi == null) return; + + if (rssi >= -50) zoneCounts.immediate++; + else if (rssi >= -70) zoneCounts.near++; + else zoneCounts.far++; + }); + + updateProximityZoneCounts(zoneCounts); + } // Currently selected device let selectedDeviceId = null; @@ -944,59 +944,59 @@ const BluetoothMode = (function() { } } - async function stopScan() { - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - const timeoutMs = isAgentMode ? 8000 : 2200; - const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; - const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; - - // Optimistic UI teardown keeps mode changes responsive. - setScanning(false); - stopEventStream(); - - try { - if (isAgentMode) { - await fetch(`/controller/agents/${currentAgent}/bluetooth/stop`, { - method: 'POST', - ...(controller ? { signal: controller.signal } : {}), - }); - } else { - await fetch('/api/bluetooth/scan/stop', { - method: 'POST', - ...(controller ? { signal: controller.signal } : {}), - }); - } - } catch (err) { - console.error('Failed to stop scan:', err); - } finally { - if (timeoutId) { - clearTimeout(timeoutId); - } - } - } + async function stopScan() { + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const timeoutMs = isAgentMode ? 8000 : 2200; + const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; + const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; + + // Optimistic UI teardown keeps mode changes responsive. + setScanning(false); + stopEventStream(); + + try { + if (isAgentMode) { + await fetch(`/controller/agents/${currentAgent}/bluetooth/stop`, { + method: 'POST', + ...(controller ? { signal: controller.signal } : {}), + }); + } else { + await fetch('/api/bluetooth/scan/stop', { + method: 'POST', + ...(controller ? { signal: controller.signal } : {}), + }); + } + } catch (err) { + console.error('Failed to stop scan:', err); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + } function setScanning(scanning) { isScanning = scanning; - if (startBtn) startBtn.style.display = scanning ? 'none' : 'block'; - if (stopBtn) stopBtn.style.display = scanning ? 'block' : 'none'; - - if (scanning && deviceContainer) { - pendingDeviceIds.clear(); - selectedDeviceNeedsRefresh = false; - pendingDeviceFlush = false; - if (typeof renderCollectionState === 'function') { - renderCollectionState(deviceContainer, { type: 'loading', message: 'Scanning for Bluetooth devices...' }); - } else { - deviceContainer.innerHTML = ''; - } - devices.clear(); - resetStats(); - } else if (!scanning && deviceContainer && devices.size === 0) { - if (typeof renderCollectionState === 'function') { - renderCollectionState(deviceContainer, { type: 'empty', message: 'Start scanning to discover Bluetooth devices' }); - } - } + if (startBtn) startBtn.style.display = scanning ? 'none' : 'block'; + if (stopBtn) stopBtn.style.display = scanning ? 'block' : 'none'; + + if (scanning && deviceContainer) { + pendingDeviceIds.clear(); + selectedDeviceNeedsRefresh = false; + pendingDeviceFlush = false; + if (typeof renderCollectionState === 'function') { + renderCollectionState(deviceContainer, { type: 'loading', message: 'Scanning for Bluetooth devices...' }); + } else { + deviceContainer.innerHTML = ''; + } + devices.clear(); + resetStats(); + } else if (!scanning && deviceContainer && devices.size === 0) { + if (typeof renderCollectionState === 'function') { + renderCollectionState(deviceContainer, { type: 'empty', message: 'Start scanning to discover Bluetooth devices' }); + } + } const statusDot = document.getElementById('statusDot'); const statusText = document.getElementById('statusText'); @@ -1004,22 +1004,22 @@ const BluetoothMode = (function() { if (statusText) statusText.textContent = scanning ? 'Scanning...' : 'Idle'; } - function resetStats() { - deviceStats = { - strong: 0, - medium: 0, - weak: 0, - trackers: [] - }; - visibleDeviceCount = 0; - updateVisualizationPanels(); - updateProximityZones(); - updateFilteredCount(); - - // Clear radar - if (radarInitialized && typeof ProximityRadar !== 'undefined') { - ProximityRadar.clear(); - } + function resetStats() { + deviceStats = { + strong: 0, + medium: 0, + weak: 0, + trackers: [] + }; + visibleDeviceCount = 0; + updateVisualizationPanels(); + updateProximityZones(); + updateFilteredCount(); + + // Clear radar + if (radarInitialized && typeof ProximityRadar !== 'undefined') { + ProximityRadar.clear(); + } } function startEventStream() { @@ -1161,43 +1161,43 @@ const BluetoothMode = (function() { }, pollInterval); } - function handleDeviceUpdate(device) { - devices.set(device.device_id, device); - pendingDeviceIds.add(device.device_id); - if (selectedDeviceId === device.device_id) { - selectedDeviceNeedsRefresh = true; - } - scheduleDeviceFlush(); - } - - function scheduleDeviceFlush() { - if (pendingDeviceFlush) return; - pendingDeviceFlush = true; - - requestAnimationFrame(() => { - pendingDeviceFlush = false; - - pendingDeviceIds.forEach((deviceId) => { - const device = devices.get(deviceId); - if (device) { - renderDevice(device, false); - } - }); - pendingDeviceIds.clear(); - - applyDeviceFilter(); - updateDeviceCount(); - updateStatsFromDevices(); - updateVisualizationPanels(); - updateProximityZones(); - updateRadar(); - - if (selectedDeviceNeedsRefresh && selectedDeviceId && devices.has(selectedDeviceId)) { - showDeviceDetail(selectedDeviceId); - } - selectedDeviceNeedsRefresh = false; - }); - } + function handleDeviceUpdate(device) { + devices.set(device.device_id, device); + pendingDeviceIds.add(device.device_id); + if (selectedDeviceId === device.device_id) { + selectedDeviceNeedsRefresh = true; + } + scheduleDeviceFlush(); + } + + function scheduleDeviceFlush() { + if (pendingDeviceFlush) return; + pendingDeviceFlush = true; + + requestAnimationFrame(() => { + pendingDeviceFlush = false; + + pendingDeviceIds.forEach((deviceId) => { + const device = devices.get(deviceId); + if (device) { + renderDevice(device, false); + } + }); + pendingDeviceIds.clear(); + + applyDeviceFilter(); + updateDeviceCount(); + updateStatsFromDevices(); + updateVisualizationPanels(); + updateProximityZones(); + updateRadar(); + + if (selectedDeviceNeedsRefresh && selectedDeviceId && devices.has(selectedDeviceId)) { + showDeviceDetail(selectedDeviceId); + } + selectedDeviceNeedsRefresh = false; + }); + } /** * Update stats from all devices @@ -1232,9 +1232,9 @@ const BluetoothMode = (function() { /** * Update visualization panels */ - function updateVisualizationPanels() { - // Signal Distribution - const total = devices.size || 1; + function updateVisualizationPanels() { + // Signal Distribution + const total = devices.size || 1; const strongBar = document.getElementById('btSignalStrong'); const mediumBar = document.getElementById('btSignalMedium'); const weakBar = document.getElementById('btSignalWeak'); @@ -1245,120 +1245,120 @@ const BluetoothMode = (function() { if (strongBar) strongBar.style.width = (deviceStats.strong / total * 100) + '%'; if (mediumBar) mediumBar.style.width = (deviceStats.medium / total * 100) + '%'; if (weakBar) weakBar.style.width = (deviceStats.weak / total * 100) + '%'; - if (strongCount) strongCount.textContent = deviceStats.strong; - if (mediumCount) mediumCount.textContent = deviceStats.medium; - if (weakCount) weakCount.textContent = deviceStats.weak; - - // Device summary strip - const totalEl = document.getElementById('btSummaryTotal'); - const newEl = document.getElementById('btSummaryNew'); - const trackersEl = document.getElementById('btSummaryTrackers'); - const strongestEl = document.getElementById('btSummaryStrongest'); - if (totalEl || newEl || trackersEl || strongestEl) { - let newCount = 0; - let strongest = null; - devices.forEach(d => { - if (!d.in_baseline) newCount++; - if (d.rssi_current != null) { - strongest = strongest == null ? d.rssi_current : Math.max(strongest, d.rssi_current); - } - }); - if (totalEl) totalEl.textContent = devices.size; - if (newEl) newEl.textContent = newCount; - if (trackersEl) trackersEl.textContent = deviceStats.trackers.length; - if (strongestEl) strongestEl.textContent = strongest == null ? '--' : `${strongest} dBm`; - } - - // Tracker Detection - Enhanced display with confidence and evidence - const trackerList = document.getElementById('btTrackerList'); - if (trackerList) { - if (devices.size === 0) { - if (typeof renderCollectionState === 'function') { - renderCollectionState(trackerList, { type: 'empty', message: 'Start scanning to detect trackers' }); - } else { - trackerList.innerHTML = '
Start scanning to detect trackers
'; - } - } else if (deviceStats.trackers.length === 0) { - if (typeof renderCollectionState === 'function') { - renderCollectionState(trackerList, { type: 'empty', message: 'No trackers detected' }); - } else { - trackerList.innerHTML = '
No trackers detected
'; - } - } else { - // Sort by risk score (highest first), then confidence - const sortedTrackers = [...deviceStats.trackers].sort((a, b) => { - const riskA = a.risk_score || 0; - const riskB = b.risk_score || 0; - if (riskB !== riskA) return riskB - riskA; - const confA = a.tracker_confidence_score || 0; - const confB = b.tracker_confidence_score || 0; - return confB - confA; - }); - - trackerList.innerHTML = sortedTrackers.map((t) => { - const confidence = t.tracker_confidence || 'low'; - const riskScore = t.risk_score || 0; - const trackerType = t.tracker_name || t.tracker_type || 'Unknown Tracker'; - const evidence = (t.tracker_evidence || []).slice(0, 2); - const evidenceHtml = evidence.length > 0 - ? `
${evidence.map((e) => `• ${escapeHtml(e)}`).join('
')}
` - : ''; - const riskClass = riskScore >= 0.5 ? 'high' : riskScore >= 0.3 ? 'medium' : 'low'; - const riskHtml = riskScore >= 0.3 - ? `RISK ${Math.round(riskScore * 100)}%` - : ''; - - return ` -
-
-
- ${escapeHtml(confidence.toUpperCase())} - ${escapeHtml(trackerType)} -
-
- ${riskHtml} - ${t.rssi_current != null ? t.rssi_current : '--'} dBm -
-
-
- ${escapeHtml(t.address_type === 'uuid' ? formatAddress(t) : (t.address || '--'))} - Seen ${t.seen_count || 0}x -
- ${evidenceHtml} -
- `; - }).join(''); - } - } - - } + if (strongCount) strongCount.textContent = deviceStats.strong; + if (mediumCount) mediumCount.textContent = deviceStats.medium; + if (weakCount) weakCount.textContent = deviceStats.weak; + + // Device summary strip + const totalEl = document.getElementById('btSummaryTotal'); + const newEl = document.getElementById('btSummaryNew'); + const trackersEl = document.getElementById('btSummaryTrackers'); + const strongestEl = document.getElementById('btSummaryStrongest'); + if (totalEl || newEl || trackersEl || strongestEl) { + let newCount = 0; + let strongest = null; + devices.forEach(d => { + if (!d.in_baseline) newCount++; + if (d.rssi_current != null) { + strongest = strongest == null ? d.rssi_current : Math.max(strongest, d.rssi_current); + } + }); + if (totalEl) totalEl.textContent = devices.size; + if (newEl) newEl.textContent = newCount; + if (trackersEl) trackersEl.textContent = deviceStats.trackers.length; + if (strongestEl) strongestEl.textContent = strongest == null ? '--' : `${strongest} dBm`; + } + + // Tracker Detection - Enhanced display with confidence and evidence + const trackerList = document.getElementById('btTrackerList'); + if (trackerList) { + if (devices.size === 0) { + if (typeof renderCollectionState === 'function') { + renderCollectionState(trackerList, { type: 'empty', message: 'Start scanning to detect trackers' }); + } else { + trackerList.innerHTML = '
Start scanning to detect trackers
'; + } + } else if (deviceStats.trackers.length === 0) { + if (typeof renderCollectionState === 'function') { + renderCollectionState(trackerList, { type: 'empty', message: 'No trackers detected' }); + } else { + trackerList.innerHTML = '
No trackers detected
'; + } + } else { + // Sort by risk score (highest first), then confidence + const sortedTrackers = [...deviceStats.trackers].sort((a, b) => { + const riskA = a.risk_score || 0; + const riskB = b.risk_score || 0; + if (riskB !== riskA) return riskB - riskA; + const confA = a.tracker_confidence_score || 0; + const confB = b.tracker_confidence_score || 0; + return confB - confA; + }); + + trackerList.innerHTML = sortedTrackers.map((t) => { + const confidence = t.tracker_confidence || 'low'; + const riskScore = t.risk_score || 0; + const trackerType = t.tracker_name || t.tracker_type || 'Unknown Tracker'; + const evidence = (t.tracker_evidence || []).slice(0, 2); + const evidenceHtml = evidence.length > 0 + ? `
${evidence.map((e) => `• ${escapeHtml(e)}`).join('
')}
` + : ''; + const riskClass = riskScore >= 0.5 ? 'high' : riskScore >= 0.3 ? 'medium' : 'low'; + const riskHtml = riskScore >= 0.3 + ? `RISK ${Math.round(riskScore * 100)}%` + : ''; + + return ` +
+
+
+ ${escapeHtml(confidence.toUpperCase())} + ${escapeHtml(trackerType)} +
+
+ ${riskHtml} + ${t.rssi_current != null ? t.rssi_current : '--'} dBm +
+
+
+ ${escapeHtml(t.address_type === 'uuid' ? formatAddress(t) : (t.address || '--'))} + Seen ${t.seen_count || 0}x +
+ ${evidenceHtml} +
+ `; + }).join(''); + } + } + + } function updateDeviceCount() { updateFilteredCount(); } - function renderDevice(device, reapplyFilter = true) { - if (!deviceContainer) { - deviceContainer = document.getElementById('btDeviceListContent'); - if (!deviceContainer) return; - } - - deviceContainer.querySelectorAll('.app-collection-state, .bt-device-filter-state').forEach((el) => el.remove()); - - const escapedId = CSS.escape(device.device_id); - const existingCard = deviceContainer.querySelector('[data-bt-device-id="' + escapedId + '"]'); - const cardHtml = createSimpleDeviceCard(device); + function renderDevice(device, reapplyFilter = true) { + if (!deviceContainer) { + deviceContainer = document.getElementById('btDeviceListContent'); + if (!deviceContainer) return; + } - if (existingCard) { - existingCard.outerHTML = cardHtml; - } else { - deviceContainer.insertAdjacentHTML('afterbegin', cardHtml); - } - - if (reapplyFilter) { - applyDeviceFilter(); - } - } + deviceContainer.querySelectorAll('.app-collection-state, .bt-device-filter-state').forEach((el) => el.remove()); + + const escapedId = CSS.escape(device.device_id); + const existingCard = deviceContainer.querySelector('[data-bt-device-id="' + escapedId + '"]'); + const cardHtml = createSimpleDeviceCard(device); + + if (existingCard) { + existingCard.outerHTML = cardHtml; + } else { + deviceContainer.insertAdjacentHTML('afterbegin', cardHtml); + } + + if (reapplyFilter) { + applyDeviceFilter(); + } + } function createSimpleDeviceCard(device) { const protocol = device.protocol || 'ble'; @@ -1378,19 +1378,19 @@ const BluetoothMode = (function() { // RSSI typically ranges from -100 (weak) to -30 (very strong) const rssiPercent = rssi != null ? Math.max(0, Math.min(100, ((rssi + 100) / 70) * 100)) : 0; - const displayName = device.name || formatDeviceId(device.address); - const name = escapeHtml(displayName); - const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown')); - const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : ''; - const seenCount = device.seen_count || 0; - const searchIndex = [ - displayName, - device.address, - device.manufacturer_name, - device.tracker_name, - device.tracker_type, - agentName - ].filter(Boolean).join(' ').toLowerCase(); + const displayName = device.name || formatDeviceId(device.address); + const name = escapeHtml(displayName); + const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown')); + const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : ''; + const seenCount = device.seen_count || 0; + const searchIndex = [ + displayName, + device.address, + device.manufacturer_name, + device.tracker_name, + device.tracker_type, + agentName + ].filter(Boolean).join(' ').toLowerCase(); // Protocol badge - compact const protoBadge = protocol === 'ble' @@ -1473,14 +1473,14 @@ const BluetoothMode = (function() { } const secondaryInfo = secondaryParts.join(' · '); - // Row border color - highlight trackers in red/orange - const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' : - isTracker ? '#f97316' : rssiColor; - - return '
' + - '
' + - '
' + - protoBadge + + // Row border color - highlight trackers in red/orange + const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' : + isTracker ? '#f97316' : rssiColor; + + return '
' + + '
' + + '
' + + protoBadge + '' + name + '' + trackerBadge + irkBadge + @@ -1495,13 +1495,13 @@ const BluetoothMode = (function() { '
' + statusDot + '
' + - '
' + - '
' + secondaryInfo + '
' + - '
' + - '' + - '
' + + '
' + + '
' + secondaryInfo + '
' + + '
' + + '' + + '
' + '
'; } @@ -1514,16 +1514,16 @@ const BluetoothMode = (function() { return '#ef4444'; } - function escapeHtml(text) { - if (!text) return ''; - const div = document.createElement('div'); - div.textContent = String(text); - return div.innerHTML; - } - - function escapeAttr(text) { - return escapeHtml(text).replace(/"/g, '"').replace(/'/g, '''); - } + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = String(text); + return div.innerHTML; + } + + function escapeAttr(text) { + return escapeHtml(text).replace(/"/g, '"').replace(/'/g, '''); + } async function setBaseline() { try { @@ -1632,22 +1632,22 @@ const BluetoothMode = (function() { /** * Clear all collected data. */ - function clearData() { - devices.clear(); - pendingDeviceIds.clear(); - pendingDeviceFlush = false; - selectedDeviceNeedsRefresh = false; - resetStats(); - clearSelection(); - - if (deviceContainer) { - if (typeof renderCollectionState === 'function') { - renderCollectionState(deviceContainer, { type: 'empty', message: 'Start scanning to discover Bluetooth devices' }); - } else { - deviceContainer.innerHTML = ''; - } - } - } + function clearData() { + devices.clear(); + pendingDeviceIds.clear(); + pendingDeviceFlush = false; + selectedDeviceNeedsRefresh = false; + resetStats(); + clearSelection(); + + if (deviceContainer) { + if (typeof renderCollectionState === 'function') { + renderCollectionState(deviceContainer, { type: 'empty', message: 'Start scanning to discover Bluetooth devices' }); + } else { + deviceContainer.innerHTML = ''; + } + } + } /** * Toggle "Show All Agents" mode. @@ -1682,27 +1682,27 @@ const BluetoothMode = (function() { } }); - toRemove.forEach(deviceId => devices.delete(deviceId)); - - // Re-render device list - if (deviceContainer) { - deviceContainer.innerHTML = ''; - devices.forEach(device => renderDevice(device, false)); - applyDeviceFilter(); - if (devices.size === 0 && typeof renderCollectionState === 'function') { - renderCollectionState(deviceContainer, { type: 'empty', message: 'No devices for current agent' }); - } - } - - if (selectedDeviceId && !devices.has(selectedDeviceId)) { - clearSelection(); - } - - updateDeviceCount(); - updateStatsFromDevices(); - updateVisualizationPanels(); - updateProximityZones(); - updateRadar(); + toRemove.forEach(deviceId => devices.delete(deviceId)); + + // Re-render device list + if (deviceContainer) { + deviceContainer.innerHTML = ''; + devices.forEach(device => renderDevice(device, false)); + applyDeviceFilter(); + if (devices.size === 0 && typeof renderCollectionState === 'function') { + renderCollectionState(deviceContainer, { type: 'empty', message: 'No devices for current agent' }); + } + } + + if (selectedDeviceId && !devices.has(selectedDeviceId)) { + clearSelection(); + } + + updateDeviceCount(); + updateStatsFromDevices(); + updateVisualizationPanels(); + updateProximityZones(); + updateRadar(); } /** @@ -1730,23 +1730,23 @@ const BluetoothMode = (function() { function doLocateHandoff(device) { console.log('[BT] doLocateHandoff, BtLocate defined:', typeof BtLocate !== 'undefined'); - if (typeof BtLocate !== 'undefined') { - BtLocate.handoff({ - device_id: device.device_id, - device_key: device.device_key || null, - mac_address: device.address, - address_type: device.address_type || null, - irk_hex: device.irk_hex || null, - known_name: device.name || null, - known_manufacturer: device.manufacturer_name || null, - last_known_rssi: device.rssi_current, - tx_power: device.tx_power || null, - appearance_name: device.appearance_name || null, - fingerprint_id: device.fingerprint_id || device.fingerprint?.id || null, - mac_cluster_count: device.mac_cluster_count || 0 - }); - } - } + if (typeof BtLocate !== 'undefined') { + BtLocate.handoff({ + device_id: device.device_id, + device_key: device.device_key || null, + mac_address: device.address, + address_type: device.address_type || null, + irk_hex: device.irk_hex || null, + known_name: device.name || null, + known_manufacturer: device.manufacturer_name || null, + last_known_rssi: device.rssi_current, + tx_power: device.tx_power || null, + appearance_name: device.appearance_name || null, + fingerprint_id: device.fingerprint_id || device.fingerprint?.id || null, + mac_cluster_count: device.mac_cluster_count || 0 + }); + } + } // Public API return { @@ -1773,8 +1773,18 @@ const BluetoothMode = (function() { // Getters getDevices: () => Array.from(devices.values()), isScanning: () => isScanning, - isShowAllAgents: () => showAllAgentsMode + isShowAllAgents: () => showAllAgentsMode, + + // Lifecycle + destroy }; + + /** + * Destroy — close SSE stream and clear polling timers for clean mode switching. + */ + function destroy() { + stopEventStream(); + } })(); // Global functions for onclick handlers diff --git a/static/js/modes/bt_locate.js b/static/js/modes/bt_locate.js index 7187c45..a52d127 100644 --- a/static/js/modes/bt_locate.js +++ b/static/js/modes/bt_locate.js @@ -1909,7 +1909,42 @@ const BtLocate = (function() { handleDetection, invalidateMap, fetchPairedIrks, + destroy, }; + + /** + * Destroy — close SSE stream and clear all timers for clean mode switching. + */ + function destroy() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + if (durationTimer) { + clearInterval(durationTimer); + durationTimer = null; + } + if (mapStabilizeTimer) { + clearInterval(mapStabilizeTimer); + mapStabilizeTimer = null; + } + if (queuedDetectionTimer) { + clearTimeout(queuedDetectionTimer); + queuedDetectionTimer = null; + } + if (crosshairResetTimer) { + clearTimeout(crosshairResetTimer); + crosshairResetTimer = null; + } + if (beepTimer) { + clearInterval(beepTimer); + beepTimer = null; + } + } })(); window.BtLocate = BtLocate; diff --git a/static/js/modes/meshtastic.js b/static/js/modes/meshtastic.js index 6f6a093..939037e 100644 --- a/static/js/modes/meshtastic.js +++ b/static/js/modes/meshtastic.js @@ -117,13 +117,13 @@ const Meshtastic = (function() { Settings.createTileLayer().addTo(meshMap); Settings.registerMap(meshMap); } else { - L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', { - attribution: '© OSM © CARTO', - maxZoom: 19, - subdomains: 'abcd', - className: 'tile-layer-cyan' - }).addTo(meshMap); - } + L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', { + attribution: '© OSM © CARTO', + maxZoom: 19, + subdomains: 'abcd', + className: 'tile-layer-cyan' + }).addTo(meshMap); + } // Handle resize setTimeout(() => { @@ -401,10 +401,10 @@ const Meshtastic = (function() { // Position is nested in the response const pos = info.position; - if (pos && pos.latitude !== undefined && pos.latitude !== null && pos.longitude !== undefined && pos.longitude !== null) { - if (posRow) posRow.style.display = 'flex'; - if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`; - } else { + if (pos && pos.latitude !== undefined && pos.latitude !== null && pos.longitude !== undefined && pos.longitude !== null) { + if (posRow) posRow.style.display = 'flex'; + if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`; + } else { if (posRow) posRow.style.display = 'none'; } } @@ -2295,7 +2295,8 @@ const Meshtastic = (function() { // Store & Forward showStoreForwardModal, requestStoreForward, - closeStoreForwardModal + closeStoreForwardModal, + destroy }; /** @@ -2306,6 +2307,13 @@ const Meshtastic = (function() { setTimeout(() => meshMap.invalidateSize(), 100); } } + + /** + * Destroy — tear down SSE, timers, and event listeners for clean mode switching. + */ + function destroy() { + stopStream(); + } })(); // Initialize when DOM is ready (will be called by selectMode) diff --git a/static/js/modes/spy-stations.js b/static/js/modes/spy-stations.js index a6176f6..09b4955 100644 --- a/static/js/modes/spy-stations.js +++ b/static/js/modes/spy-stations.js @@ -515,6 +515,13 @@ const SpyStations = (function() { } } + /** + * Destroy — no-op placeholder for consistent lifecycle interface. + */ + function destroy() { + // SpyStations has no background timers or streams to clean up. + } + // Public API return { init, @@ -524,7 +531,8 @@ const SpyStations = (function() { showDetails, closeDetails, showHelp, - closeHelp + closeHelp, + destroy }; })(); diff --git a/static/js/modes/sstv-general.js b/static/js/modes/sstv-general.js index c16791d..3bec33d 100644 --- a/static/js/modes/sstv-general.js +++ b/static/js/modes/sstv-general.js @@ -858,6 +858,13 @@ const SSTVGeneral = (function() { } } + /** + * Destroy — close SSE stream and stop scope animation for clean mode switching. + */ + function destroy() { + stopStream(); + } + // Public API return { init, @@ -869,6 +876,7 @@ const SSTVGeneral = (function() { deleteImage, deleteAllImages, downloadImage, - selectPreset + selectPreset, + destroy }; })(); diff --git a/static/js/modes/sstv.js b/static/js/modes/sstv.js index 24e2f29..bb60d1d 100644 --- a/static/js/modes/sstv.js +++ b/static/js/modes/sstv.js @@ -12,12 +12,12 @@ const SSTV = (function() { let progress = 0; let issMap = null; let issMarker = null; - let issTrackLine = null; - let issPosition = null; - let issUpdateInterval = null; - let countdownInterval = null; - let nextPassData = null; - let pendingMapInvalidate = false; + let issTrackLine = null; + let issPosition = null; + let issUpdateInterval = null; + let countdownInterval = null; + let nextPassData = null; + let pendingMapInvalidate = false; // ISS frequency const ISS_FREQ = 145.800; @@ -38,31 +38,31 @@ const SSTV = (function() { /** * Initialize the SSTV mode */ - function init() { - checkStatus(); - loadImages(); - loadLocationInputs(); - loadIssSchedule(); - initMap(); - startIssTracking(); - startCountdown(); - // Ensure Leaflet recomputes dimensions after the SSTV pane becomes visible. - setTimeout(() => invalidateMap(), 80); - setTimeout(() => invalidateMap(), 260); - } - - function isMapContainerVisible() { - if (!issMap || typeof issMap.getContainer !== 'function') return false; - const container = issMap.getContainer(); - if (!container) return false; - if (container.offsetWidth <= 0 || container.offsetHeight <= 0) return false; - if (container.style && container.style.display === 'none') return false; - if (typeof window.getComputedStyle === 'function') { - const style = window.getComputedStyle(container); - if (style.display === 'none' || style.visibility === 'hidden') return false; - } - return true; - } + function init() { + checkStatus(); + loadImages(); + loadLocationInputs(); + loadIssSchedule(); + initMap(); + startIssTracking(); + startCountdown(); + // Ensure Leaflet recomputes dimensions after the SSTV pane becomes visible. + setTimeout(() => invalidateMap(), 80); + setTimeout(() => invalidateMap(), 260); + } + + function isMapContainerVisible() { + if (!issMap || typeof issMap.getContainer !== 'function') return false; + const container = issMap.getContainer(); + if (!container) return false; + if (container.offsetWidth <= 0 || container.offsetHeight <= 0) return false; + if (container.style && container.style.display === 'none') return false; + if (typeof window.getComputedStyle === 'function') { + const style = window.getComputedStyle(container); + if (style.display === 'none' || style.visibility === 'hidden') return false; + } + return true; + } /** * Load location into input fields @@ -189,9 +189,9 @@ const SSTV = (function() { /** * Initialize Leaflet map for ISS tracking */ - async function initMap() { - const mapContainer = document.getElementById('sstvIssMap'); - if (!mapContainer || issMap) return; + async function initMap() { + const mapContainer = document.getElementById('sstvIssMap'); + if (!mapContainer || issMap) return; // Create map issMap = L.map('sstvIssMap', { @@ -231,21 +231,21 @@ const SSTV = (function() { issMarker = L.marker([0, 0], { icon: issIcon }).addTo(issMap); // Create ground track line - issTrackLine = L.polyline([], { - color: '#00d4ff', - weight: 2, - opacity: 0.6, - dashArray: '5, 5' - }).addTo(issMap); - - issMap.on('resize moveend zoomend', () => { - if (pendingMapInvalidate) invalidateMap(); - }); - - // Initial layout passes for first-time mode load. - setTimeout(() => invalidateMap(), 40); - setTimeout(() => invalidateMap(), 180); - } + issTrackLine = L.polyline([], { + color: '#00d4ff', + weight: 2, + opacity: 0.6, + dashArray: '5, 5' + }).addTo(issMap); + + issMap.on('resize moveend zoomend', () => { + if (pendingMapInvalidate) invalidateMap(); + }); + + // Initial layout passes for first-time mode load. + setTimeout(() => invalidateMap(), 40); + setTimeout(() => invalidateMap(), 180); + } /** * Start ISS position tracking @@ -454,9 +454,9 @@ const SSTV = (function() { /** * Update map with ISS position */ - function updateMap() { - if (!issMap || !issPosition) return; - if (pendingMapInvalidate) invalidateMap(); + function updateMap() { + if (!issMap || !issPosition) return; + if (pendingMapInvalidate) invalidateMap(); const lat = issPosition.lat; const lon = issPosition.lon; @@ -516,13 +516,13 @@ const SSTV = (function() { issTrackLine.setLatLngs(segments.length > 0 ? segments : []); } - // Pan map to follow ISS only when the map pane is currently renderable. - if (isMapContainerVisible()) { - issMap.panTo([lat, lon], { animate: true, duration: 0.5 }); - } else { - pendingMapInvalidate = true; - } - } + // Pan map to follow ISS only when the map pane is currently renderable. + if (isMapContainerVisible()) { + issMap.panTo([lat, lon], { animate: true, duration: 0.5 }); + } else { + pendingMapInvalidate = true; + } + } /** * Check current decoder status @@ -1335,27 +1335,27 @@ const SSTV = (function() { /** * Show status message */ - function showStatusMessage(message, type) { - if (typeof showNotification === 'function') { - showNotification('SSTV', message); - } else { - console.log(`[SSTV ${type}] ${message}`); - } - } - - /** - * Invalidate ISS map size after pane/layout changes. - */ - function invalidateMap() { - if (!issMap) return false; - if (!isMapContainerVisible()) { - pendingMapInvalidate = true; - return false; - } - issMap.invalidateSize({ pan: false, animate: false }); - pendingMapInvalidate = false; - return true; - } + function showStatusMessage(message, type) { + if (typeof showNotification === 'function') { + showNotification('SSTV', message); + } else { + console.log(`[SSTV ${type}] ${message}`); + } + } + + /** + * Invalidate ISS map size after pane/layout changes. + */ + function invalidateMap() { + if (!issMap) return false; + if (!isMapContainerVisible()) { + pendingMapInvalidate = true; + return false; + } + issMap.invalidateSize({ pan: false, animate: false }); + pendingMapInvalidate = false; + return true; + } // Public API return { @@ -1370,12 +1370,25 @@ const SSTV = (function() { deleteAllImages, downloadImage, useGPS, - updateTLE, - stopIssTracking, - stopCountdown, - invalidateMap - }; -})(); + updateTLE, + stopIssTracking, + stopCountdown, + invalidateMap, + destroy + }; + + /** + * Destroy — close SSE stream and clear ISS tracking/countdown timers for clean mode switching. + */ + function destroy() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + stopIssTracking(); + stopCountdown(); + } +})(); // Initialize when DOM is ready (will be called by selectMode) document.addEventListener('DOMContentLoaded', function() { diff --git a/static/js/modes/websdr.js b/static/js/modes/websdr.js index b2a60fe..f99a6ea 100644 --- a/static/js/modes/websdr.js +++ b/static/js/modes/websdr.js @@ -1005,6 +1005,15 @@ function escapeHtmlWebsdr(str) { // ============== EXPORTS ============== +/** + * Destroy — disconnect audio and clear S-meter timer for clean mode switching. + */ +function destroyWebSDR() { + disconnectFromReceiver(); +} + +const WebSDR = { destroy: destroyWebSDR }; + window.initWebSDR = initWebSDR; window.searchReceivers = searchReceivers; window.selectReceiver = selectReceiver; @@ -1015,3 +1024,4 @@ window.disconnectFromReceiver = disconnectFromReceiver; window.tuneKiwi = tuneKiwi; window.tuneFromBar = tuneFromBar; window.setKiwiVolume = setKiwiVolume; +window.WebSDR = WebSDR; diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js index 35c93c0..bc44c02 100644 --- a/static/js/modes/wifi.js +++ b/static/js/modes/wifi.js @@ -28,9 +28,9 @@ const WiFiMode = (function() { maxProbes: 1000, }; - // ========================================================================== - // Agent Support - // ========================================================================== + // ========================================================================== + // Agent Support + // ========================================================================== /** * Get the API base URL, routing through agent proxy if agent is selected. @@ -59,49 +59,49 @@ const WiFiMode = (function() { /** * Check for agent mode conflicts before starting WiFi scan. */ - function checkAgentConflicts() { - if (typeof currentAgent === 'undefined' || currentAgent === 'local') { - return true; - } - if (typeof checkAgentModeConflict === 'function') { - return checkAgentModeConflict('wifi'); - } - return true; - } - - function getChannelPresetList(preset) { - switch (preset) { - case '2.4-common': - return '1,6,11'; - case '2.4-all': - return '1,2,3,4,5,6,7,8,9,10,11,12,13'; - case '5-low': - return '36,40,44,48'; - case '5-mid': - return '52,56,60,64'; - case '5-high': - return '149,153,157,161,165'; - default: - return ''; - } - } - - function buildChannelConfig() { - const preset = document.getElementById('wifiChannelPreset')?.value || ''; - const listInput = document.getElementById('wifiChannelList')?.value || ''; - const singleInput = document.getElementById('wifiChannel')?.value || ''; - - const listValue = listInput.trim(); - const presetValue = getChannelPresetList(preset); - - const channels = listValue || presetValue || ''; - const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null); - - return { - channels: channels || null, - channel: Number.isFinite(channel) ? channel : null, - }; - } + function checkAgentConflicts() { + if (typeof currentAgent === 'undefined' || currentAgent === 'local') { + return true; + } + if (typeof checkAgentModeConflict === 'function') { + return checkAgentModeConflict('wifi'); + } + return true; + } + + function getChannelPresetList(preset) { + switch (preset) { + case '2.4-common': + return '1,6,11'; + case '2.4-all': + return '1,2,3,4,5,6,7,8,9,10,11,12,13'; + case '5-low': + return '36,40,44,48'; + case '5-mid': + return '52,56,60,64'; + case '5-high': + return '149,153,157,161,165'; + default: + return ''; + } + } + + function buildChannelConfig() { + const preset = document.getElementById('wifiChannelPreset')?.value || ''; + const listInput = document.getElementById('wifiChannelList')?.value || ''; + const singleInput = document.getElementById('wifiChannel')?.value || ''; + + const listValue = listInput.trim(); + const presetValue = getChannelPresetList(preset); + + const channels = listValue || presetValue || ''; + const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null); + + return { + channels: channels || null, + channel: Number.isFinite(channel) ? channel : null, + }; + } // ========================================================================== // State @@ -120,23 +120,23 @@ const WiFiMode = (function() { let channelStats = []; let recommendations = []; - // UI state - let selectedNetwork = null; - let currentFilter = 'all'; - let currentSort = { field: 'rssi', order: 'desc' }; - let renderFramePending = false; - const pendingRender = { - table: false, - stats: false, - radar: false, - chart: false, - detail: false, - }; - const listenersBound = { - scanTabs: false, - filters: false, - sort: false, - }; + // UI state + let selectedNetwork = null; + let currentFilter = 'all'; + let currentSort = { field: 'rssi', order: 'desc' }; + let renderFramePending = false; + const pendingRender = { + table: false, + stats: false, + radar: false, + chart: false, + detail: false, + }; + const listenersBound = { + scanTabs: false, + filters: false, + sort: false, + }; // Agent state let showAllAgentsMode = false; // Show combined results from all agents @@ -165,11 +165,11 @@ const WiFiMode = (function() { // Initialize components initScanModeTabs(); - initNetworkFilters(); - initSortControls(); - initProximityRadar(); - initChannelChart(); - scheduleRender({ table: true, stats: true, radar: true, chart: true }); + initNetworkFilters(); + initSortControls(); + initProximityRadar(); + initChannelChart(); + scheduleRender({ table: true, stats: true, radar: true, chart: true }); // Check if already scanning checkScanStatus(); @@ -378,16 +378,16 @@ const WiFiMode = (function() { // Scan Mode Tabs // ========================================================================== - function initScanModeTabs() { - if (listenersBound.scanTabs) return; - if (elements.scanModeQuick) { - elements.scanModeQuick.addEventListener('click', () => setScanMode('quick')); - } - if (elements.scanModeDeep) { - elements.scanModeDeep.addEventListener('click', () => setScanMode('deep')); - } - listenersBound.scanTabs = true; - } + function initScanModeTabs() { + if (listenersBound.scanTabs) return; + if (elements.scanModeQuick) { + elements.scanModeQuick.addEventListener('click', () => setScanMode('quick')); + } + if (elements.scanModeDeep) { + elements.scanModeDeep.addEventListener('click', () => setScanMode('deep')); + } + listenersBound.scanTabs = true; + } function setScanMode(mode) { scanMode = mode; @@ -511,10 +511,10 @@ const WiFiMode = (function() { setScanning(true, 'deep'); try { - const iface = elements.interfaceSelect?.value || null; - const band = document.getElementById('wifiBand')?.value || 'all'; - const channelConfig = buildChannelConfig(); - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const iface = elements.interfaceSelect?.value || null; + const band = document.getElementById('wifiBand')?.value || 'all'; + const channelConfig = buildChannelConfig(); + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; let response; if (isAgentMode) { @@ -523,25 +523,25 @@ const WiFiMode = (function() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - interface: iface, - scan_type: 'deep', - band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', - channel: channelConfig.channel, - channels: channelConfig.channels, - }), - }); - } else { - response = await fetch(`${CONFIG.apiBase}/scan/start`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - interface: iface, - band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', - channel: channelConfig.channel, - channels: channelConfig.channels, - }), - }); - } + interface: iface, + scan_type: 'deep', + band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', + channel: channelConfig.channel, + channels: channelConfig.channels, + }), + }); + } else { + response = await fetch(`${CONFIG.apiBase}/scan/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + interface: iface, + band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', + channel: channelConfig.channel, + channels: channelConfig.channels, + }), + }); + } if (!response.ok) { const error = await response.json(); @@ -572,8 +572,8 @@ const WiFiMode = (function() { } } - async function stopScan() { - console.log('[WiFiMode] Stopping scan...'); + async function stopScan() { + console.log('[WiFiMode] Stopping scan...'); // Stop polling if (pollTimer) { @@ -585,41 +585,41 @@ const WiFiMode = (function() { stopAgentDeepScanPolling(); // Close event stream - if (eventSource) { - eventSource.close(); - eventSource = null; - } - - // Update UI immediately so mode transitions are responsive even if the - // backend needs extra time to terminate subprocesses. - setScanning(false); - - // Stop scan on server (local or agent) - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - const timeoutMs = isAgentMode ? 8000 : 2200; - const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; - const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; - - try { - if (isAgentMode) { - await fetch(`/controller/agents/${currentAgent}/wifi/stop`, { - method: 'POST', - ...(controller ? { signal: controller.signal } : {}), - }); - } else if (scanMode === 'deep') { - await fetch(`${CONFIG.apiBase}/scan/stop`, { - method: 'POST', - ...(controller ? { signal: controller.signal } : {}), - }); - } - } catch (error) { - console.warn('[WiFiMode] Error stopping scan:', error); - } finally { - if (timeoutId) { - clearTimeout(timeoutId); - } - } - } + if (eventSource) { + eventSource.close(); + eventSource = null; + } + + // Update UI immediately so mode transitions are responsive even if the + // backend needs extra time to terminate subprocesses. + setScanning(false); + + // Stop scan on server (local or agent) + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + const timeoutMs = isAgentMode ? 8000 : 2200; + const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; + const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null; + + try { + if (isAgentMode) { + await fetch(`/controller/agents/${currentAgent}/wifi/stop`, { + method: 'POST', + ...(controller ? { signal: controller.signal } : {}), + }); + } else if (scanMode === 'deep') { + await fetch(`${CONFIG.apiBase}/scan/stop`, { + method: 'POST', + ...(controller ? { signal: controller.signal } : {}), + }); + } + } catch (error) { + console.warn('[WiFiMode] Error stopping scan:', error); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + } function setScanning(scanning, mode = null) { isScanning = scanning; @@ -713,10 +713,10 @@ const WiFiMode = (function() { }, CONFIG.pollInterval); } - function processQuickScanResult(result) { - // Update networks - result.access_points.forEach(ap => { - networks.set(ap.bssid, ap); + function processQuickScanResult(result) { + // Update networks + result.access_points.forEach(ap => { + networks.set(ap.bssid, ap); }); // Update channel stats (calculate from networks if not provided by API) @@ -724,12 +724,12 @@ const WiFiMode = (function() { recommendations = result.recommendations || []; // If no channel stats from API, calculate from networks - if (channelStats.length === 0 && networks.size > 0) { - channelStats = calculateChannelStats(); - } - - // Update UI - scheduleRender({ table: true, stats: true, radar: true, chart: true }); + if (channelStats.length === 0 && networks.size > 0) { + channelStats = calculateChannelStats(); + } + + // Update UI + scheduleRender({ table: true, stats: true, radar: true, chart: true }); // Callbacks result.access_points.forEach(ap => { @@ -938,25 +938,25 @@ const WiFiMode = (function() { } } - function handleNetworkUpdate(network) { - networks.set(network.bssid, network); - scheduleRender({ - table: true, - stats: true, - radar: true, - chart: true, - detail: selectedNetwork === network.bssid, - }); - - if (onNetworkUpdate) onNetworkUpdate(network); - } - - function handleClientUpdate(client) { - clients.set(client.mac, client); - scheduleRender({ stats: true }); - - // Update client display if this client belongs to the selected network - updateClientInList(client); + function handleNetworkUpdate(network) { + networks.set(network.bssid, network); + scheduleRender({ + table: true, + stats: true, + radar: true, + chart: true, + detail: selectedNetwork === network.bssid, + }); + + if (onNetworkUpdate) onNetworkUpdate(network); + } + + function handleClientUpdate(client) { + clients.set(client.mac, client); + scheduleRender({ stats: true }); + + // Update client display if this client belongs to the selected network + updateClientInList(client); if (onClientUpdate) onClientUpdate(client); } @@ -970,37 +970,37 @@ const WiFiMode = (function() { if (onProbeRequest) onProbeRequest(probe); } - function handleHiddenRevealed(bssid, revealedSsid) { - const network = networks.get(bssid); - if (network) { - network.revealed_essid = revealedSsid; - network.display_name = `${revealedSsid} (revealed)`; - scheduleRender({ - table: true, - detail: selectedNetwork === bssid, - }); - - // Show notification - showInfo(`Hidden SSID revealed: ${revealedSsid}`); - } - } + function handleHiddenRevealed(bssid, revealedSsid) { + const network = networks.get(bssid); + if (network) { + network.revealed_essid = revealedSsid; + network.display_name = `${revealedSsid} (revealed)`; + scheduleRender({ + table: true, + detail: selectedNetwork === bssid, + }); + + // Show notification + showInfo(`Hidden SSID revealed: ${revealedSsid}`); + } + } // ========================================================================== // Network Table // ========================================================================== - function initNetworkFilters() { - if (listenersBound.filters) return; - if (!elements.networkFilters) return; - - elements.networkFilters.addEventListener('click', (e) => { - if (e.target.matches('.wifi-filter-btn')) { - const filter = e.target.dataset.filter; - setNetworkFilter(filter); - } - }); - listenersBound.filters = true; - } + function initNetworkFilters() { + if (listenersBound.filters) return; + if (!elements.networkFilters) return; + + elements.networkFilters.addEventListener('click', (e) => { + if (e.target.matches('.wifi-filter-btn')) { + const filter = e.target.dataset.filter; + setNetworkFilter(filter); + } + }); + listenersBound.filters = true; + } function setNetworkFilter(filter) { currentFilter = filter; @@ -1015,11 +1015,11 @@ const WiFiMode = (function() { updateNetworkTable(); } - function initSortControls() { - if (listenersBound.sort) return; - if (!elements.networkTable) return; - - elements.networkTable.addEventListener('click', (e) => { + function initSortControls() { + if (listenersBound.sort) return; + if (!elements.networkTable) return; + + elements.networkTable.addEventListener('click', (e) => { const th = e.target.closest('th[data-sort]'); if (th) { const field = th.dataset.sort; @@ -1029,54 +1029,54 @@ const WiFiMode = (function() { currentSort.field = field; currentSort.order = 'desc'; } - updateNetworkTable(); - } - }); - - if (elements.networkTableBody) { - elements.networkTableBody.addEventListener('click', (e) => { - const row = e.target.closest('tr[data-bssid]'); - if (!row) return; - selectNetwork(row.dataset.bssid); - }); - } - listenersBound.sort = true; - } - - function scheduleRender(flags = {}) { - pendingRender.table = pendingRender.table || Boolean(flags.table); - pendingRender.stats = pendingRender.stats || Boolean(flags.stats); - pendingRender.radar = pendingRender.radar || Boolean(flags.radar); - pendingRender.chart = pendingRender.chart || Boolean(flags.chart); - pendingRender.detail = pendingRender.detail || Boolean(flags.detail); - - if (renderFramePending) return; - renderFramePending = true; - - requestAnimationFrame(() => { - renderFramePending = false; - - if (pendingRender.table) updateNetworkTable(); - if (pendingRender.stats) updateStats(); - if (pendingRender.radar) updateProximityRadar(); - if (pendingRender.chart) updateChannelChart(); - if (pendingRender.detail && selectedNetwork) { - updateDetailPanel(selectedNetwork, { refreshClients: false }); - } - - pendingRender.table = false; - pendingRender.stats = false; - pendingRender.radar = false; - pendingRender.chart = false; - pendingRender.detail = false; - }); - } - - function updateNetworkTable() { - if (!elements.networkTableBody) return; - - // Filter networks - let filtered = Array.from(networks.values()); + updateNetworkTable(); + } + }); + + if (elements.networkTableBody) { + elements.networkTableBody.addEventListener('click', (e) => { + const row = e.target.closest('tr[data-bssid]'); + if (!row) return; + selectNetwork(row.dataset.bssid); + }); + } + listenersBound.sort = true; + } + + function scheduleRender(flags = {}) { + pendingRender.table = pendingRender.table || Boolean(flags.table); + pendingRender.stats = pendingRender.stats || Boolean(flags.stats); + pendingRender.radar = pendingRender.radar || Boolean(flags.radar); + pendingRender.chart = pendingRender.chart || Boolean(flags.chart); + pendingRender.detail = pendingRender.detail || Boolean(flags.detail); + + if (renderFramePending) return; + renderFramePending = true; + + requestAnimationFrame(() => { + renderFramePending = false; + + if (pendingRender.table) updateNetworkTable(); + if (pendingRender.stats) updateStats(); + if (pendingRender.radar) updateProximityRadar(); + if (pendingRender.chart) updateChannelChart(); + if (pendingRender.detail && selectedNetwork) { + updateDetailPanel(selectedNetwork, { refreshClients: false }); + } + + pendingRender.table = false; + pendingRender.stats = false; + pendingRender.radar = false; + pendingRender.chart = false; + pendingRender.detail = false; + }); + } + + function updateNetworkTable() { + if (!elements.networkTableBody) return; + + // Filter networks + let filtered = Array.from(networks.values()); switch (currentFilter) { case 'hidden': @@ -1126,44 +1126,44 @@ const WiFiMode = (function() { return bVal > aVal ? 1 : bVal < aVal ? -1 : 0; } else { return aVal > bVal ? 1 : aVal < bVal ? -1 : 0; - } - }); - - if (filtered.length === 0) { - let message = 'Start scanning to discover networks'; - let type = 'empty'; - if (isScanning) { - message = 'Scanning for networks...'; - type = 'loading'; - } else if (networks.size > 0) { - message = 'No networks match current filters'; - } - if (typeof renderCollectionState === 'function') { - renderCollectionState(elements.networkTableBody, { - type, - message, - columns: 7, - }); - } else { - elements.networkTableBody.innerHTML = `
${escapeHtml(message)}
`; - } - return; - } - - // Render table - elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join(''); - } + } + }); - function createNetworkRow(network) { - const rssi = network.rssi_current; - const security = network.security || 'Unknown'; - const signalClass = rssi >= -50 ? 'signal-strong' : - rssi >= -70 ? 'signal-medium' : - rssi >= -85 ? 'signal-weak' : 'signal-very-weak'; - - const securityClass = security === 'Open' ? 'security-open' : - security === 'WEP' ? 'security-wep' : - security.includes('WPA3') ? 'security-wpa3' : 'security-wpa'; + if (filtered.length === 0) { + let message = 'Start scanning to discover networks'; + let type = 'empty'; + if (isScanning) { + message = 'Scanning for networks...'; + type = 'loading'; + } else if (networks.size > 0) { + message = 'No networks match current filters'; + } + if (typeof renderCollectionState === 'function') { + renderCollectionState(elements.networkTableBody, { + type, + message, + columns: 7, + }); + } else { + elements.networkTableBody.innerHTML = `
${escapeHtml(message)}
`; + } + return; + } + + // Render table + elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join(''); + } + + function createNetworkRow(network) { + const rssi = network.rssi_current; + const security = network.security || 'Unknown'; + const signalClass = rssi >= -50 ? 'signal-strong' : + rssi >= -70 ? 'signal-medium' : + rssi >= -85 ? 'signal-weak' : 'signal-very-weak'; + + const securityClass = security === 'Open' ? 'security-open' : + security === 'WEP' ? 'security-wep' : + security.includes('WPA3') ? 'security-wpa3' : 'security-wpa'; const hiddenBadge = network.is_hidden ? 'Hidden' : ''; const newBadge = network.is_new ? 'New' : ''; @@ -1172,25 +1172,25 @@ const WiFiMode = (function() { const agentName = network._agent || 'Local'; const agentClass = agentName === 'Local' ? 'agent-local' : 'agent-remote'; - return ` - - - ${escapeHtml(network.display_name || network.essid || '[Hidden]')} - ${hiddenBadge}${newBadge} - + return ` + + + ${escapeHtml(network.display_name || network.essid || '[Hidden]')} + ${hiddenBadge}${newBadge} + ${escapeHtml(network.bssid)} ${network.channel || '-'} - - ${rssi != null ? rssi : '-'} - - - ${escapeHtml(security)} - + + ${rssi != null ? rssi : '-'} + + + ${escapeHtml(security)} + ${network.client_count || 0} ${escapeHtml(agentName)} @@ -1199,12 +1199,12 @@ const WiFiMode = (function() { `; } - function updateNetworkRow(network) { - scheduleRender({ - table: true, - detail: selectedNetwork === network.bssid, - }); - } + function updateNetworkRow(network) { + scheduleRender({ + table: true, + detail: selectedNetwork === network.bssid, + }); + } function selectNetwork(bssid) { selectedNetwork = bssid; @@ -1227,9 +1227,9 @@ const WiFiMode = (function() { // Detail Panel // ========================================================================== - function updateDetailPanel(bssid, options = {}) { - const { refreshClients = true } = options; - if (!elements.detailDrawer) return; + function updateDetailPanel(bssid, options = {}) { + const { refreshClients = true } = options; + if (!elements.detailDrawer) return; const network = networks.get(bssid); if (!network) { @@ -1274,11 +1274,11 @@ const WiFiMode = (function() { // Show the drawer elements.detailDrawer.classList.add('open'); - // Fetch and display clients for this network - if (refreshClients) { - fetchClientsForNetwork(network.bssid); - } - } + // Fetch and display clients for this network + if (refreshClients) { + fetchClientsForNetwork(network.bssid); + } + } function closeDetail() { selectedNetwork = null; @@ -1294,18 +1294,18 @@ const WiFiMode = (function() { // Client Display // ========================================================================== - async function fetchClientsForNetwork(bssid) { - if (!elements.detailClientList) return; - const listContainer = elements.detailClientList.querySelector('.wifi-client-list'); - - if (listContainer && typeof renderCollectionState === 'function') { - renderCollectionState(listContainer, { type: 'loading', message: 'Loading clients...' }); - elements.detailClientList.style.display = 'block'; - } - - try { - const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; - let response; + async function fetchClientsForNetwork(bssid) { + if (!elements.detailClientList) return; + const listContainer = elements.detailClientList.querySelector('.wifi-client-list'); + + if (listContainer && typeof renderCollectionState === 'function') { + renderCollectionState(listContainer, { type: 'loading', message: 'Loading clients...' }); + elements.detailClientList.style.display = 'block'; + } + + try { + const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local'; + let response; if (isAgentMode) { // Route through agent proxy @@ -1314,44 +1314,44 @@ const WiFiMode = (function() { response = await fetch(`${CONFIG.apiBase}/clients?bssid=${encodeURIComponent(bssid)}&associated=true`); } - if (!response.ok) { - if (listContainer && typeof renderCollectionState === 'function') { - renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' }); - elements.detailClientList.style.display = 'block'; - } else { - elements.detailClientList.style.display = 'none'; - } - return; - } + if (!response.ok) { + if (listContainer && typeof renderCollectionState === 'function') { + renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' }); + elements.detailClientList.style.display = 'block'; + } else { + elements.detailClientList.style.display = 'none'; + } + return; + } const data = await response.json(); // Handle agent response format (may be nested in 'result') const result = isAgentMode && data.result ? data.result : data; const clientList = result.clients || []; - if (clientList.length > 0) { - renderClientList(clientList, bssid); - elements.detailClientList.style.display = 'block'; - } else { - const countBadge = document.getElementById('wifiClientCountBadge'); - if (countBadge) countBadge.textContent = '0'; - if (listContainer && typeof renderCollectionState === 'function') { - renderCollectionState(listContainer, { type: 'empty', message: 'No associated clients' }); - elements.detailClientList.style.display = 'block'; - } else { - elements.detailClientList.style.display = 'none'; - } - } - } catch (error) { - console.debug('[WiFiMode] Error fetching clients:', error); - if (listContainer && typeof renderCollectionState === 'function') { - renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' }); - elements.detailClientList.style.display = 'block'; - } else { - elements.detailClientList.style.display = 'none'; - } - } - } + if (clientList.length > 0) { + renderClientList(clientList, bssid); + elements.detailClientList.style.display = 'block'; + } else { + const countBadge = document.getElementById('wifiClientCountBadge'); + if (countBadge) countBadge.textContent = '0'; + if (listContainer && typeof renderCollectionState === 'function') { + renderCollectionState(listContainer, { type: 'empty', message: 'No associated clients' }); + elements.detailClientList.style.display = 'block'; + } else { + elements.detailClientList.style.display = 'none'; + } + } + } catch (error) { + console.debug('[WiFiMode] Error fetching clients:', error); + if (listContainer && typeof renderCollectionState === 'function') { + renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' }); + elements.detailClientList.style.display = 'block'; + } else { + elements.detailClientList.style.display = 'none'; + } + } + } function renderClientList(clientList, bssid) { const container = elements.detailClientList?.querySelector('.wifi-client-list'); @@ -1708,16 +1708,16 @@ const WiFiMode = (function() { /** * Clear all collected data. */ - function clearData() { - networks.clear(); - clients.clear(); - probeRequests = []; - channelStats = []; - recommendations = []; - if (selectedNetwork) { - closeDetail(); - } - scheduleRender({ table: true, stats: true, radar: true, chart: true }); + function clearData() { + networks.clear(); + clients.clear(); + probeRequests = []; + channelStats = []; + recommendations = []; + if (selectedNetwork) { + closeDetail(); + } + scheduleRender({ table: true, stats: true, radar: true, chart: true }); } /** @@ -1763,12 +1763,12 @@ const WiFiMode = (function() { clientsToRemove.push(mac); } }); - clientsToRemove.forEach(mac => clients.delete(mac)); - if (selectedNetwork && !networks.has(selectedNetwork)) { - closeDetail(); - } - scheduleRender({ table: true, stats: true, radar: true, chart: true }); - } + clientsToRemove.forEach(mac => clients.delete(mac)); + if (selectedNetwork && !networks.has(selectedNetwork)) { + closeDetail(); + } + scheduleRender({ table: true, stats: true, radar: true, chart: true }); + } /** * Refresh WiFi interfaces from current agent. @@ -1811,7 +1811,28 @@ const WiFiMode = (function() { onNetworkUpdate: (cb) => { onNetworkUpdate = cb; }, onClientUpdate: (cb) => { onClientUpdate = cb; }, onProbeRequest: (cb) => { onProbeRequest = cb; }, + + // Lifecycle + destroy, }; + + /** + * Destroy — close SSE stream and clear polling timers for clean mode switching. + */ + function destroy() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + if (agentPollTimer) { + clearInterval(agentPollTimer); + agentPollTimer = null; + } + } })(); // Auto-initialize when DOM is ready diff --git a/templates/index.html b/templates/index.html index 2844a23..92a459a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4140,12 +4140,27 @@ const stopPhaseMs = Math.round(performance.now() - stopPhaseStartMs); await styleReadyPromise; - // Clean up SubGHz SSE connection when leaving the mode - if (typeof SubGhz !== 'undefined' && currentMode === 'subghz' && mode !== 'subghz') { - SubGhz.destroy(); - } - if (typeof MorseMode !== 'undefined' && currentMode === 'morse' && mode !== 'morse' && typeof MorseMode.destroy === 'function') { - MorseMode.destroy(); + // Generic module cleanup — destroy previous mode's timers, SSE, etc. + const moduleDestroyMap = { + subghz: () => typeof SubGhz !== 'undefined' && SubGhz.destroy(), + morse: () => typeof MorseMode !== 'undefined' && MorseMode.destroy?.(), + spaceweather: () => typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy?.(), + weathersat: () => typeof WeatherSat !== 'undefined' && WeatherSat.suspend?.(), + wefax: () => typeof WeFax !== 'undefined' && WeFax.destroy?.(), + system: () => typeof SystemHealth !== 'undefined' && SystemHealth.destroy?.(), + waterfall: () => typeof Waterfall !== 'undefined' && Waterfall.destroy?.(), + gps: () => typeof GPS !== 'undefined' && GPS.destroy?.(), + meshtastic: () => typeof Meshtastic !== 'undefined' && Meshtastic.destroy?.(), + bluetooth: () => typeof BluetoothMode !== 'undefined' && BluetoothMode.destroy?.(), + wifi: () => typeof WiFiMode !== 'undefined' && WiFiMode.destroy?.(), + bt_locate: () => typeof BtLocate !== 'undefined' && BtLocate.destroy?.(), + sstv: () => typeof SSTV !== 'undefined' && SSTV.destroy?.(), + sstv_general: () => typeof SSTVGeneral !== 'undefined' && SSTVGeneral.destroy?.(), + websdr: () => typeof WebSDR !== 'undefined' && WebSDR.destroy?.(), + spystations: () => typeof SpyStations !== 'undefined' && SpyStations.destroy?.(), + }; + if (previousMode && previousMode !== mode && moduleDestroyMap[previousMode]) { + try { moduleDestroyMap[previousMode](); } catch(e) { console.warn(`[switchMode] destroy ${previousMode} failed:`, e); } } currentMode = mode; @@ -4301,25 +4316,7 @@ refreshTscmDevices(); } - // Initialize/destroy Space Weather mode - if (mode !== 'spaceweather') { - if (typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy) SpaceWeather.destroy(); - } - - // Suspend Weather Satellite background timers/streams when leaving the mode - if (mode !== 'weathersat') { - if (typeof WeatherSat !== 'undefined' && WeatherSat.suspend) WeatherSat.suspend(); - } - - // Suspend WeFax background streams when leaving the mode - if (mode !== 'wefax') { - 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(); - } + // Module destroy is now handled by moduleDestroyMap above. // Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm) const reconBtn = document.getElementById('reconBtn'); @@ -4460,10 +4457,7 @@ SystemHealth.init(); } - // Destroy Waterfall WebSocket when leaving SDR receiver modes - if (mode !== 'waterfall' && typeof Waterfall !== 'undefined' && Waterfall.destroy) { - Promise.resolve(Waterfall.destroy()).catch(() => {}); - } + // Waterfall destroy is now handled by moduleDestroyMap above. const totalMs = Math.round(performance.now() - switchStartMs); console.info(