Merge remote-tracking branch 'upstream/main'

This commit is contained in:
thatsatechnique
2026-03-04 14:28:02 -08:00
17 changed files with 1968 additions and 603 deletions

7
.gitignore vendored
View File

@@ -18,10 +18,6 @@ pager_messages.log
downloads/
pgdata/
# Local data
downloads/
pgdata/
# IDE
.idea/
.vscode/
@@ -58,6 +54,9 @@ intercept_agent_*.cfg
# Weather satellite runtime data (decoded images, samples, SatDump output)
data/weather_sat/
# Radiosonde runtime data (station config, logs)
data/radiosonde/
# SDR capture files (large IQ recordings)
data/subghz/captures/

View File

@@ -25,14 +25,25 @@ docker compose --profile basic up -d --build
### Local Setup (Alternative)
```bash
# Initial setup (installs dependencies and configures SDR tools)
# First-time setup (interactive wizard with install profiles)
./setup.sh
# Or headless full install
./setup.sh --non-interactive
# Or install specific profiles
./setup.sh --profile=core,weather
# Run with production server (gunicorn + gevent, handles concurrent SSE/WebSocket)
sudo ./start.sh
# Or for quick local dev (Flask dev server)
sudo -E venv/bin/python intercept.py
# Other setup utilities
./setup.sh --health-check # Verify installation
./setup.sh --postgres-setup # Set up ADS-B history database
./setup.sh --menu # Force interactive menu
```
### Testing
@@ -68,7 +79,8 @@ mypy .
## Architecture
### Entry Points
- `start.sh` - Production startup script (gunicorn + gevent auto-detection, CLI flags, HTTPS, fallback to Flask dev server)
- `setup.sh` - Menu-driven installer with profile system (wizard, health check, PostgreSQL setup, env configurator, update, uninstall). Sources `.env` on startup via `start.sh`.
- `start.sh` - Production startup script (gunicorn + gevent auto-detection, CLI flags, HTTPS, `.env` sourcing, fallback to Flask dev server)
- `intercept.py` - Direct Flask dev server entry point (quick local development)
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure, conditional gevent monkey-patch

118
README.md
View File

@@ -81,18 +81,61 @@ Troubleshooting (no decode / noisy decode):
---
## Installation / Debian / Ubuntu / MacOS
## Installation / Debian / Ubuntu / macOS
### Quick Start
**1. Clone and run:**
```bash
git clone https://github.com/smittix/intercept.git
cd intercept
./setup.sh
./setup.sh # Interactive menu (first run launches setup wizard)
sudo ./start.sh
```
On first run, `setup.sh` launches a **guided wizard** that detects your OS, lets you choose install profiles, sets up the Python environment, and optionally configures environment variables and PostgreSQL.
On subsequent runs, it opens an **interactive menu**:
```
INTERCEPT Setup Menu
════════════════════════════════════════
1) Install / Add Modules
2) System Health Check
3) Database Setup (ADS-B History)
4) Update Tools
5) Environment Configurator
6) Uninstall / Cleanup
7) View Status
0) Exit
```
> **Production vs Dev server:** `start.sh` auto-detects gunicorn + gevent and runs a production server with cooperative greenlets — handles multiple SSE/WebSocket clients without blocking. Falls back to Flask dev server if gunicorn is not installed. For quick local development, you can still use `sudo -E venv/bin/python intercept.py` directly.
### Install Profiles
Choose what to install during the wizard or via menu option 1:
| # | Profile | Tools |
|---|---------|-------|
| 1 | Core SIGINT | rtl_sdr, multimon-ng, rtl_433, dump1090, acarsdec, dumpvdl2, ffmpeg, gpsd |
| 2 | Maritime & Radio | AIS-catcher, direwolf |
| 3 | Weather & Space | SatDump, radiosonde_auto_rx |
| 4 | RF Security | aircrack-ng, HackRF, BlueZ, hcxtools, Ubertooth, SoapySDR |
| 5 | Full SIGINT | All of the above |
| 6 | Custom | Per-tool checklist |
Multiple profiles can be combined (e.g. enter `1 3` for Core + Weather).
### CLI Flags
```bash
./setup.sh --non-interactive # Headless full install (same as legacy behavior)
./setup.sh --profile=core,weather # Install specific profiles
./setup.sh --health-check # Check system health and exit
./setup.sh --postgres-setup # Run PostgreSQL setup and exit
./setup.sh --menu # Force interactive menu
```
### Docker
```bash
@@ -142,16 +185,40 @@ INTERCEPT_IMAGE=ghcr.io/youruser/intercept:latest
docker compose --profile basic up -d
```
### ADS-B History (Optional)
### Environment Configuration
The ADS-B history feature persists aircraft messages to Postgres for long-term analysis.
Use the **Environment Configurator** (menu option 5) to interactively set any `INTERCEPT_*` variable. Settings are saved to a `.env` file that `start.sh` sources automatically on startup.
You can also create or edit `.env` manually:
```bash
# .env (auto-loaded by start.sh)
INTERCEPT_PORT=5050
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_DEFAULT_LAT=51.5074
INTERCEPT_DEFAULT_LON=-0.1278
```
### ADS-B History (Optional)
The ADS-B history feature persists aircraft messages to PostgreSQL for long-term analysis.
**Automated setup (local install):**
```bash
./setup.sh --postgres-setup
# Or use menu option 3: Database Setup
```
This will install PostgreSQL if needed, create the database/user/tables, and write the connection settings to `.env`.
**Docker:**
```bash
# Start with ADS-B history and Postgres
docker compose --profile history up -d
```
Set the following environment variables (for example in a `.env` file):
Set the following environment variables (in `.env`):
```bash
INTERCEPT_ADSB_HISTORY_ENABLED=true
@@ -162,30 +229,6 @@ INTERCEPT_ADSB_DB_USER=intercept
INTERCEPT_ADSB_DB_PASSWORD=intercept
```
### Other ADS-B Settings
Set these as environment variables for either local installs or Docker:
| Variable | Default | Description |
|----------|---------|-------------|
| `INTERCEPT_ADSB_AUTO_START` | `false` | Auto-start ADS-B tracking when the dashboard loads |
| `INTERCEPT_SHARED_OBSERVER_LOCATION` | `true` | Share observer location across ADS-B/AIS/SSTV/Satellite modules |
**Local install example**
```bash
INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
sudo ./start.sh
```
**Docker example (.env)**
```bash
INTERCEPT_ADSB_AUTO_START=true
INTERCEPT_SHARED_OBSERVER_LOCATION=false
```
To store Postgres data on external storage, set `PGDATA_PATH` (defaults to `./pgdata`):
```bash
@@ -194,9 +237,20 @@ PGDATA_PATH=/mnt/usbpi1/intercept/pgdata
Then open **/adsb/history** for the reporting dashboard.
### System Health Check
Verify your installation is complete and working:
```bash
./setup.sh --health-check
# Or use menu option 2
```
Checks installed tools, SDR devices, port availability, permissions, Python venv, `.env` configuration, and PostgreSQL connectivity.
### Open the Interface
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
The credentials can be changed in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py

View File

@@ -38,8 +38,8 @@ The controller is the main Intercept application:
```bash
cd intercept
python app.py
# Runs on http://localhost:5050
./setup.sh # First-time setup (choose install profiles)
sudo ./start.sh # Production server on http://localhost:5050
```
### 2. Configure an Agent

View File

@@ -14,7 +14,39 @@ INTERCEPT automatically detects connected devices.
## Quick Install
### macOS (Homebrew)
### Recommended: Use the Setup Script
The setup script provides an interactive menu with install profiles for selective installation:
```bash
git clone https://github.com/smittix/intercept.git
cd intercept
./setup.sh
```
On first run, a guided wizard walks you through profile selection:
| Profile | What it installs |
|---------|-----------------|
| Core SIGINT | rtl_sdr, multimon-ng, rtl_433, dump1090, acarsdec, dumpvdl2, ffmpeg, gpsd |
| Maritime & Radio | AIS-catcher, direwolf |
| Weather & Space | SatDump, radiosonde_auto_rx |
| RF Security | aircrack-ng, HackRF, BlueZ, hcxtools, Ubertooth, SoapySDR |
| Full SIGINT | All of the above |
For headless/CI installs:
```bash
./setup.sh --non-interactive # Install everything
./setup.sh --profile=core,maritime # Install specific profiles
```
After installation, use the menu to manage your setup:
```bash
./setup.sh # Opens interactive menu
./setup.sh --health-check # Verify installation
```
### Manual Install: macOS (Homebrew)
```bash
# Install Homebrew if needed
@@ -36,7 +68,7 @@ brew install soapysdr limesuite soapylms7
brew install hackrf soapyhackrf
```
### Debian / Ubuntu / Raspberry Pi OS
### Manual Install: Debian / Ubuntu / Raspberry Pi OS
```bash
# Update package lists
@@ -239,11 +271,19 @@ SoapySDRUtil --find
./setup.sh
```
This automatically:
- Detects your OS
- Creates a virtual environment if needed (for PEP 668 systems)
- Installs Python dependencies
- Checks for required tools
The setup wizard automatically:
- Detects your OS (macOS, Debian/Ubuntu, DragonOS)
- Lets you choose install profiles (Core, Maritime, Weather, Security, Full, Custom)
- Creates a virtual environment with system site-packages
- Installs Python dependencies (core + optional)
- Runs a health check to verify everything works
After initial setup, use the menu to manage your environment:
- **Install / Add Modules** — add tools you didn't install initially
- **System Health Check** — verify all tools and dependencies
- **Environment Configurator** — set `INTERCEPT_*` variables interactively
- **Update Tools** — rebuild source-built tools (dump1090, SatDump, etc.)
- **View Status** — see what's installed at a glance
### Manual setup
```bash

View File

@@ -66,13 +66,16 @@ sudo ./start.sh
### Alternative: Use the setup script
The setup script handles all installation automatically, including apt packages:
The setup script handles all installation automatically, including apt packages and source builds:
```bash
chmod +x setup.sh
./setup.sh
./setup.sh # Interactive wizard (first run) or menu
./setup.sh --non-interactive # Headless full install
./setup.sh --health-check # Diagnose installation issues
```
The setup menu also includes a **System Health Check** (option 2) that verifies all tools, SDR devices, ports, permissions, and Python packages — useful for diagnosing installation problems.
### "pip: command not found"
```bash
@@ -373,7 +376,14 @@ sudo usermod -a -G bluetooth $USER
### Cannot install dump1090 in Debian (ADS-B mode)
On newer Debian versions, dump1090 may not be in repositories. The recommended action is to build from source or use the setup.sh script which will do it for you.
On newer Debian versions, dump1090 may not be in repositories. Use the setup script which builds it from source automatically:
```bash
./setup.sh # Select Core SIGINT profile, or
./setup.sh --profile=core # Install core tools including dump1090
```
The setup menu's **Install / Add Modules** option also lets you install dump1090 individually via the Custom tool checklist.
### No aircraft appearing (ADS-B mode)

View File

@@ -330,10 +330,10 @@
<div class="code-block">
<pre><code>git clone https://github.com/smittix/intercept.git
cd intercept
./setup.sh
./setup.sh # Interactive wizard with install profiles
sudo ./start.sh</code></pre>
</div>
<p class="install-note">Requires Python 3.9+ and RTL-SDR drivers</p>
<p class="install-note">Menu-driven setup: choose Core, Maritime, Weather, Security, or Full SIGINT profiles. Headless mode: <code>./setup.sh --non-interactive</code></p>
</div>
<div class="install-card">
@@ -350,6 +350,7 @@ docker compose --profile basic up -d --build</code></pre>
<div class="post-install">
<p>After starting, open <code>http://localhost:5050</code> in your browser.</p>
<p>Default credentials: <code>admin</code> / <code>admin</code></p>
<p>Run <code>./setup.sh --health-check</code> to verify your installation, or use menu option 2 for a full system health check.</p>
</div>
</div>
</section>

View File

@@ -29,6 +29,7 @@ rtl_tcp_lock = threading.Lock()
# Track which device is being used
rtlamr_active_device: int | None = None
rtlamr_active_sdr_type: str = 'rtlsdr'
def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
@@ -62,7 +63,7 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
except Exception as e:
app_module.rtlamr_queue.put({'type': 'error', 'text': str(e)})
finally:
global rtl_tcp_process, rtlamr_active_device
global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
# Ensure rtlamr process is terminated
try:
process.terminate()
@@ -91,19 +92,26 @@ def stream_rtlamr_output(process: subprocess.Popen[bytes]) -> None:
app_module.rtlamr_process = None
# Release SDR device
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
rtlamr_active_device = None
@rtlamr_bp.route('/start_rtlamr', methods=['POST'])
def start_rtlamr() -> Response:
global rtl_tcp_process, rtlamr_active_device
global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
with app_module.rtlamr_lock:
if app_module.rtlamr_process:
return jsonify({'status': 'error', 'message': 'RTLAMR already running'}), 409
data = request.json or {}
sdr_type_str = data.get('sdr_type', 'rtlsdr')
if sdr_type_str != 'rtlsdr':
return jsonify({
'status': 'error',
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
}), 400
# Validate inputs
try:
@@ -116,7 +124,7 @@ def start_rtlamr() -> Response:
# Check if device is available
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'rtlamr')
error = app_module.claim_sdr_device(device_int, 'rtlamr', sdr_type_str)
if error:
return jsonify({
'status': 'error',
@@ -125,6 +133,7 @@ def start_rtlamr() -> Response:
}), 409
rtlamr_active_device = device_int
rtlamr_active_sdr_type = sdr_type_str
# Clear queue
while not app_module.rtlamr_queue.empty():
@@ -170,7 +179,7 @@ def start_rtlamr() -> Response:
logger.error(f"Failed to start rtl_tcp: {e}")
# Release SDR device on rtl_tcp failure
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
rtlamr_active_device = None
return jsonify({'status': 'error', 'message': f'Failed to start rtl_tcp: {e}'}), 500
@@ -242,7 +251,7 @@ def start_rtlamr() -> Response:
rtl_tcp_process.wait(timeout=2)
rtl_tcp_process = None
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
rtlamr_active_device = None
return jsonify({'status': 'error', 'message': 'rtlamr not found. Install from https://github.com/bemasher/rtlamr'})
except Exception as e:
@@ -253,14 +262,14 @@ def start_rtlamr() -> Response:
rtl_tcp_process.wait(timeout=2)
rtl_tcp_process = None
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
rtlamr_active_device = None
return jsonify({'status': 'error', 'message': str(e)})
@rtlamr_bp.route('/stop_rtlamr', methods=['POST'])
def stop_rtlamr() -> Response:
global rtl_tcp_process, rtlamr_active_device
global rtl_tcp_process, rtlamr_active_device, rtlamr_active_sdr_type
# Grab process refs inside locks, clear state, then terminate outside
rtlamr_proc = None
@@ -293,7 +302,7 @@ def stop_rtlamr() -> Response:
# Release device from registry
if rtlamr_active_device is not None:
app_module.release_sdr_device(rtlamr_active_device)
app_module.release_sdr_device(rtlamr_active_device, rtlamr_active_sdr_type)
rtlamr_active_device = None
return jsonify({'status': 'stopped'})

View File

@@ -57,6 +57,7 @@ _timescale_lock = threading.Lock()
# Track which device is being used
sstv_active_device: int | None = None
sstv_active_sdr_type: str = 'rtlsdr'
def _progress_callback(data: dict) -> None:
@@ -154,6 +155,14 @@ def start_decoder():
# Get parameters
data = request.get_json(silent=True) or {}
sdr_type_str = data.get('sdr_type', 'rtlsdr')
if sdr_type_str != 'rtlsdr':
return jsonify({
'status': 'error',
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
}), 400
frequency = data.get('frequency', ISS_SSTV_FREQ)
modulation = str(data.get('modulation', ISS_SSTV_MODULATION)).strip().lower()
device_index = data.get('device', 0)
@@ -209,9 +218,9 @@ def start_decoder():
longitude = None
# Claim SDR device
global sstv_active_device
global sstv_active_device, sstv_active_sdr_type
device_int = int(device_index)
error = app_module.claim_sdr_device(device_int, 'sstv')
error = app_module.claim_sdr_device(device_int, 'sstv', sdr_type_str)
if error:
return jsonify({
'status': 'error',
@@ -231,6 +240,7 @@ def start_decoder():
if success:
sstv_active_device = device_int
sstv_active_sdr_type = sdr_type_str
result = {
'status': 'started',
@@ -247,7 +257,7 @@ def start_decoder():
return jsonify(result)
else:
# 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': 'Failed to start decoder'
@@ -262,13 +272,13 @@ def stop_decoder():
Returns:
JSON confirmation.
"""
global sstv_active_device
global sstv_active_device, sstv_active_sdr_type
decoder = get_sstv_decoder()
decoder.stop()
# Release device from registry
if sstv_active_device is not None:
app_module.release_sdr_device(sstv_active_device)
app_module.release_sdr_device(sstv_active_device, sstv_active_sdr_type)
sstv_active_device = None
return jsonify({'status': 'stopped'})

View File

@@ -30,6 +30,7 @@ _sstv_general_queue: queue.Queue = queue.Queue(maxsize=100)
# Track which device is being used
_sstv_general_active_device: int | None = None
_sstv_general_active_sdr_type: str = 'rtlsdr'
# Predefined SSTV frequencies
SSTV_FREQUENCIES = [
@@ -119,6 +120,14 @@ def start_decoder():
break
data = request.get_json(silent=True) or {}
sdr_type_str = data.get('sdr_type', 'rtlsdr')
if sdr_type_str != 'rtlsdr':
return jsonify({
'status': 'error',
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
}), 400
frequency = data.get('frequency')
modulation = data.get('modulation')
device_index = data.get('device', 0)
@@ -155,9 +164,9 @@ def start_decoder():
}), 400
# Claim SDR device
global _sstv_general_active_device
global _sstv_general_active_device, _sstv_general_active_sdr_type
device_int = int(device_index)
error = app_module.claim_sdr_device(device_int, 'sstv_general')
error = app_module.claim_sdr_device(device_int, 'sstv_general', sdr_type_str)
if error:
return jsonify({
'status': 'error',
@@ -175,6 +184,7 @@ def start_decoder():
if success:
_sstv_general_active_device = device_int
_sstv_general_active_sdr_type = sdr_type_str
return jsonify({
'status': 'started',
'frequency': frequency,
@@ -182,7 +192,7 @@ def start_decoder():
'device': device_index,
})
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',
@@ -192,12 +202,12 @@ def start_decoder():
@sstv_general_bp.route('/stop', methods=['POST'])
def stop_decoder():
"""Stop general SSTV decoder."""
global _sstv_general_active_device
global _sstv_general_active_device, _sstv_general_active_sdr_type
decoder = get_general_sstv_decoder()
decoder.stop()
if _sstv_general_active_device is not None:
app_module.release_sdr_device(_sstv_general_active_device)
app_module.release_sdr_device(_sstv_general_active_device, _sstv_general_active_sdr_type)
_sstv_general_active_device = None
return jsonify({'status': 'stopped'})

View File

@@ -136,6 +136,13 @@ def start_capture():
})
data = request.get_json(silent=True) or {}
sdr_type_str = data.get('sdr_type', 'rtlsdr')
if sdr_type_str != 'rtlsdr':
return jsonify({
'status': 'error',
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
}), 400
# Validate satellite
satellite = data.get('satellite')
@@ -173,7 +180,7 @@ def start_capture():
if not rtl_tcp_host:
try:
import app as app_module
error = app_module.claim_sdr_device(device_index, 'weather_sat')
error = app_module.claim_sdr_device(device_index, 'weather_sat', sdr_type_str)
if error:
return jsonify({
'status': 'error',

2140
setup.sh

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,14 @@ set -euo pipefail
# ── Resolve Python from venv or system ───────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ── Load .env if present ──────────────────────────────────────────────────────
if [[ -f "$SCRIPT_DIR/.env" ]]; then
set -a
source "$SCRIPT_DIR/.env"
set +a
fi
if [[ -x "$SCRIPT_DIR/venv/bin/python" ]]; then
PYTHON="$SCRIPT_DIR/venv/bin/python"
elif [[ -n "${VIRTUAL_ENV:-}" && -x "$VIRTUAL_ENV/bin/python" ]]; then

View File

@@ -108,7 +108,7 @@ const SSTVGeneral = (function() {
const response = await fetch('/sstv-general/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ frequency, modulation, device })
body: JSON.stringify({ frequency, modulation, device, sdr_type: typeof getSelectedSDRType === 'function' ? getSelectedSDRType() : 'rtlsdr' })
});
const data = await response.json();

View File

@@ -258,6 +258,7 @@ const WeatherSat = (function() {
device,
gain,
bias_t: biasT,
sdr_type: typeof getSelectedSDRType === 'function' ? getSelectedSDRType() : 'rtlsdr',
};
// Add rtl_tcp params if using remote SDR

View File

@@ -4044,6 +4044,43 @@
'/satellite/dashboard',
]);
// Shared module destroy map — closes SSE EventSources, timers, etc.
// Used by both switchMode() and dashboard navigation cleanup.
function getModuleDestroyFn(mode) {
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?.(),
ais: () => { if (aisEventSource) { aisEventSource.close(); aisEventSource = null; } },
acars: () => { if (acarsMainEventSource) { acarsMainEventSource.close(); acarsMainEventSource = null; } },
vdl2: () => { if (vdl2MainEventSource) { vdl2MainEventSource.close(); vdl2MainEventSource = null; } },
radiosonde: () => { if (radiosondeEventSource) { radiosondeEventSource.close(); radiosondeEventSource = null; } },
meteor: () => typeof MeteorScatter !== 'undefined' && MeteorScatter.destroy?.(),
};
return moduleDestroyMap[mode] || null;
}
function destroyCurrentMode() {
if (!currentMode) return;
const destroyFn = getModuleDestroyFn(currentMode);
if (destroyFn) {
try { destroyFn(); } catch(e) { console.warn(`[destroyCurrentMode] destroy ${currentMode} failed:`, e); }
}
}
function getActiveScanSummary() {
return {
pager: Boolean(isRunning),
@@ -4105,6 +4142,29 @@
if (typeof isTscmRunning !== 'undefined' && isTscmRunning && typeof stopTscmSweep === 'function') {
Promise.resolve(stopTscmSweep()).catch(() => { });
}
// Additional modes with server-side processes that need stopping
if (typeof WeFax !== 'undefined' && typeof WeFax.stop === 'function') {
Promise.resolve(WeFax.stop()).catch(() => { });
}
if (typeof WeatherSat !== 'undefined' && typeof WeatherSat.stop === 'function') {
Promise.resolve(WeatherSat.stop()).catch(() => { });
}
if (typeof SSTV !== 'undefined' && typeof SSTV.stop === 'function') {
Promise.resolve(SSTV.stop()).catch(() => { });
}
if (typeof SSTVGeneral !== 'undefined' && typeof SSTVGeneral.stop === 'function') {
Promise.resolve(SSTVGeneral.stop()).catch(() => { });
}
if (typeof SubGhz !== 'undefined' && typeof SubGhz.stop === 'function') {
Promise.resolve(SubGhz.stop()).catch(() => { });
}
if (typeof Meshtastic !== 'undefined' && typeof Meshtastic.stop === 'function') {
Promise.resolve(Meshtastic.stop()).catch(() => { });
}
if (typeof GPS !== 'undefined' && typeof GPS.stop === 'function') {
Promise.resolve(GPS.stop()).catch(() => { });
}
}
if (!window._dashboardNavigationStopHookBound) {
@@ -4130,6 +4190,7 @@
activeScans: getActiveScanSummary(),
});
}
destroyCurrentMode();
stopActiveLocalScansForNavigation();
} catch (_) {
// Ignore malformed hrefs.
@@ -4244,31 +4305,11 @@
await styleReadyPromise;
// 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?.(),
ais: () => { if (aisEventSource) { aisEventSource.close(); aisEventSource = null; } },
acars: () => { if (acarsMainEventSource) { acarsMainEventSource.close(); acarsMainEventSource = null; } },
vdl2: () => { if (vdl2MainEventSource) { vdl2MainEventSource.close(); vdl2MainEventSource = null; } },
radiosonde: () => { if (radiosondeEventSource) { radiosondeEventSource.close(); radiosondeEventSource = null; } },
meteor: () => typeof MeteorScatter !== 'undefined' && MeteorScatter.destroy?.(),
};
if (previousMode && previousMode !== mode && moduleDestroyMap[previousMode]) {
try { moduleDestroyMap[previousMode](); } catch(e) { console.warn(`[switchMode] destroy ${previousMode} failed:`, e); }
if (previousMode && previousMode !== mode) {
const destroyFn = getModuleDestroyFn(previousMode);
if (destroyFn) {
try { destroyFn(); } catch(e) { console.warn(`[switchMode] destroy ${previousMode} failed:`, e); }
}
}
currentMode = mode;
@@ -5334,6 +5375,7 @@
gain: gain,
ppm: ppm,
device: device,
sdr_type: getSelectedSDRType(),
msgtype: msgtype,
filterid: filterid,
unique: unique,
@@ -11692,6 +11734,7 @@
tscmBaselineComparison = null;
tscmIdentityClusters = [];
tscmIdentitySummary = null;
tscmHighInterestDevices = [];
updateTscmDisplays();
updateTscmThreatCounts();
@@ -12527,7 +12570,7 @@
const exists = tscmWifiDevices.some(d => d.bssid === device.bssid);
if (!exists) {
tscmWifiDevices.push(device);
updateTscmDisplays();
debouncedUpdateTscmDisplays();
updateTscmThreatCounts();
// Add to findings panel if score >= 3 (review level or higher)
if (device.score >= 3) {
@@ -12556,7 +12599,7 @@
if (!client.mac) client.mac = mac;
client.is_client = true;
tscmWifiClients.push(client);
updateTscmDisplays();
debouncedUpdateTscmDisplays();
updateTscmThreatCounts();
if (client.score >= 3) {
addHighInterestDevice(client, 'wifi');
@@ -12578,7 +12621,7 @@
if (!exists) {
if (!device.mac && mac) device.mac = mac;
tscmBtDevices.push(device);
updateTscmDisplays();
debouncedUpdateTscmDisplays();
updateTscmThreatCounts();
// Add to threats panel if score >= 3 (review level or higher)
if (device.score >= 3) {
@@ -12612,7 +12655,7 @@
: 3;
if (!exists) {
tscmRfSignals.push(signal);
updateTscmDisplays();
debouncedUpdateTscmDisplays();
updateTscmThreatCounts();
// Add to findings panel if score >= 3 (review level or higher)
if (signal.score >= 3) {
@@ -12654,6 +12697,18 @@
// If there are signals, updateTscmDisplays() will handle the display
}
// Debounced versions of expensive display updates to batch rapid-fire device additions
let _tscmDisplayTimer = null;
function debouncedUpdateTscmDisplays() {
if (_tscmDisplayTimer) clearTimeout(_tscmDisplayTimer);
_tscmDisplayTimer = setTimeout(() => { _tscmDisplayTimer = null; updateTscmDisplays(); }, 250);
}
let _tscmHighInterestTimer = null;
function debouncedUpdateHighInterestPanel() {
if (_tscmHighInterestTimer) clearTimeout(_tscmHighInterestTimer);
_tscmHighInterestTimer = setTimeout(() => { _tscmHighInterestTimer = null; updateHighInterestPanel(); }, 250);
}
// Track high-interest devices for the threats panel
let tscmHighInterestDevices = [];
function addHighInterestDevice(device, protocol) {
@@ -12667,10 +12722,9 @@
score: device.score,
classification: device.classification,
indicators: device.indicators || [],
recommended_action: device.recommended_action,
device: device
recommended_action: device.recommended_action
});
updateHighInterestPanel();
debouncedUpdateHighInterestPanel();
}
}
@@ -12735,7 +12789,7 @@
// Update dashboard counts
updateTscmThreatCounts();
updateTscmDisplays();
debouncedUpdateTscmDisplays();
}
function readTscmFilters() {

View File

@@ -493,7 +493,7 @@ def probe_rtlsdr_device(device_index: int) -> str | None:
)
return (
f'SDR device {device_index} is not available — '
f'check that the RTL-SDR is connected and not in use by another process.'
f'check that the SDR device is connected and not in use by another process.'
)
except Exception as e: