Merge branch 'smittix:main' into main

This commit is contained in:
Mitch Ross
2026-02-28 16:30:56 -05:00
committed by GitHub
34 changed files with 1251 additions and 353 deletions

View File

@@ -2,6 +2,44 @@
All notable changes to iNTERCEPT will be documented in this file.
## [2.23.0] - 2026-02-27
### Added
- **Radiosonde Weather Balloon Tracking** - 400-406 MHz reception via radiosonde_auto_rx with telemetry, map, and station distance tracking
- **CW/Morse Code Decoder** - Custom Goertzel tone detection with OOK/AM envelope detection mode for ISM bands
- **WeFax (Weather Fax) Decoder** - HF weather fax reception with auto-scheduler, broadcast timeline, and image gallery
- **System Health Monitoring** - Telemetry dashboard with process monitoring and system metrics
- **HTTPS Support** - TLS via `INTERCEPT_HTTPS` configuration
- **ADS-B Voice Alerts** - Text-to-speech notifications for military and emergency aircraft detections
- **HackRF TSCM RF Scan** - HackRF support added to TSCM counter-surveillance RF sweep
- **Multi-SDR WeFax** - Multiple SDR hardware support for WeFax decoder
- **Tool Path Overrides** - `INTERCEPT_*_PATH` environment variables for custom tool locations
- **Homebrew Tool Detection** - Native path detection for Apple Silicon Homebrew installations
- **Production Server** - `start.sh` with gunicorn + gevent for concurrent SSE/WebSocket handling — eliminates multi-client page load delays
### Changed
- Morse decoder rebuilt with custom Goertzel decoder, replacing multimon-ng dependency
- GPS mode upgraded to textured 3D globe visualization
- Destroy lifecycle added to all mode modules to prevent resource leaks
- Docker container now uses gunicorn + gevent by default via `start.sh`
### Fixed
- ADS-B device release leak and startup performance regression
- ADS-B probe incorrectly treating "No devices found" as success
- USB claim race condition after SDR probe
- SDR device registry collision when multiple SDR types present
- APRS 15-minute startup delay caused by pipe buffering
- APRS map centering at [0,0] when GPS unavailable
- DSC decoder ITU-R M.493 compliance issues
- Weather satellite 0dB SNR — increased sample rate for Meteor LRPT
- SSE fanout backlog causing delayed updates across all modes
- SSE reconnect packet loss during client reconnection
- Waterfall monitor tuning race conditions
- Mode FOUC (flash of unstyled content) on initial navigation
- Various Morse decoder stability and lifecycle fixes
---
## [2.22.3] - 2026-02-23
### Fixed

View File

@@ -28,12 +28,11 @@ docker compose --profile basic up -d --build
# Initial setup (installs dependencies and configures SDR tools)
./setup.sh
# Run the application (requires sudo for SDR/network access)
sudo -E venv/bin/python intercept.py
# Run with production server (gunicorn + gevent, handles concurrent SSE/WebSocket)
sudo ./start.sh
# Or activate venv first
source venv/bin/activate
sudo -E python intercept.py
# Or for quick local dev (Flask dev server)
sudo -E venv/bin/python intercept.py
```
### Testing
@@ -69,8 +68,9 @@ mypy .
## Architecture
### Entry Points
- `intercept.py` - Main entry point script
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure
- `start.sh` - Production startup script (gunicorn + gevent auto-detection, CLI flags, HTTPS, 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
### Route Blueprints (routes/)
Each signal type has its own Flask blueprint:
@@ -121,7 +121,7 @@ Each signal type has its own Flask blueprint:
### Key Patterns
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages.
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages. Under gunicorn + gevent, each SSE connection is a lightweight greenlet instead of an OS thread.
**Process Management**: External decoders run as subprocesses with output threads feeding queues. Use `safe_terminate()` for cleanup. Global locks prevent race conditions.
@@ -152,7 +152,7 @@ Each signal type has its own Flask blueprint:
- **Mode Integration**: Each mode needs entries in `index.html` at ~12 points: CSS include, welcome card, partial include, visuals container, JS include, `validModes` set, `modeGroups` map, classList toggle, `modeNames`, visuals display toggle, titles, and init call in `switchMode()`
### Docker
- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.)
- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.). CMD runs `start.sh` (gunicorn + gevent)
- `docker-compose.yml` - Two profiles: `basic` (standalone) and `history` (with Postgres for ADS-B)
- `build-multiarch.sh` - Multi-arch build script for amd64 + arm64 (RPi5)
- Data persisted via `./data:/app/data` volume mount

View File

@@ -274,4 +274,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -sf http://localhost:5050/health || exit 1
# Run the application
CMD ["python", "intercept.py"]
CMD ["/bin/bash", "start.sh"]

View File

@@ -50,47 +50,49 @@ Support the developer of this open-source project
- **Meshtastic** - LoRa mesh network integration
- **Space Weather** - Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL (no SDR required)
- **Spy Stations** - Number stations and diplomatic HF network database
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
- **Offline Mode** - Bundled assets for air-gapped/field deployments
---
## CW / Morse Decoder Notes
Live backend:
- Uses `rtl_fm` piped into `multimon-ng` (`MORSE_CW`) for real-time decode.
Recommended baseline settings:
- **Tone**: `700 Hz`
- **Bandwidth**: `200 Hz` (use `100 Hz` for crowded bands, `400 Hz` for drifting signals)
- **Threshold Mode**: `Auto`
- **WPM Mode**: `Auto`
Auto Tone Track behavior:
- Continuously measures nearby tone energy around the configured CW pitch.
- Steers the detector toward the strongest valid CW tone when signal-to-noise is sufficient.
- Use **Hold Tone Lock** to freeze tracking once the desired signal is centered.
Troubleshooting (no decode / noisy decode):
- Confirm demod path is **USB/CW-compatible** and frequency is tuned correctly.
- If multiple SDRs are connected and the selected one has no PCM output, Morse startup now auto-tries other detected SDR devices and reports the active device/serial in status logs.
- Match **tone** and **bandwidth** to the actual sidetone/pitch.
- Try **Threshold Auto** first; if needed, switch to manual threshold and recalibrate.
- Use **Reset/Calibrate** after major frequency or band condition changes.
- Raise **Minimum Signal Gate** to suppress random noise keying.
---
## Installation / Debian / Ubuntu / MacOS
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
- **Offline Mode** - Bundled assets for air-gapped/field deployments
---
## CW / Morse Decoder Notes
Live backend:
- Uses `rtl_fm` piped into `multimon-ng` (`MORSE_CW`) for real-time decode.
Recommended baseline settings:
- **Tone**: `700 Hz`
- **Bandwidth**: `200 Hz` (use `100 Hz` for crowded bands, `400 Hz` for drifting signals)
- **Threshold Mode**: `Auto`
- **WPM Mode**: `Auto`
Auto Tone Track behavior:
- Continuously measures nearby tone energy around the configured CW pitch.
- Steers the detector toward the strongest valid CW tone when signal-to-noise is sufficient.
- Use **Hold Tone Lock** to freeze tracking once the desired signal is centered.
Troubleshooting (no decode / noisy decode):
- Confirm demod path is **USB/CW-compatible** and frequency is tuned correctly.
- If multiple SDRs are connected and the selected one has no PCM output, Morse startup now auto-tries other detected SDR devices and reports the active device/serial in status logs.
- Match **tone** and **bandwidth** to the actual sidetone/pitch.
- Try **Threshold Auto** first; if needed, switch to manual threshold and recalibrate.
- Use **Reset/Calibrate** after major frequency or band condition changes.
- Raise **Minimum Signal Gate** to suppress random noise keying.
---
## Installation / Debian / Ubuntu / MacOS
**1. Clone and run:**
```bash
git clone https://github.com/smittix/intercept.git
cd intercept
./setup.sh
sudo -E venv/bin/python intercept.py
sudo ./start.sh
```
> **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.
### Docker
```bash
@@ -174,7 +176,7 @@ Set these as environment variables for either local installs or Docker:
```bash
INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
sudo -E venv/bin/python intercept.py
sudo ./start.sh
```
**Docker example (.env)**

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
{
"version": "2026-02-15_ae16bb62",
"downloaded": "2026-02-20T00:29:06.228007Z"
"version": "2026-02-22_17194a71",
"downloaded": "2026-02-27T10:41:04.872620Z"
}

177
app.py
View File

@@ -55,9 +55,9 @@ app.secret_key = "signals_intelligence_secret" # Required for flash messages
# Set up rate limiting
limiter = Limiter(
key_func=get_remote_address, # Identifies the user by their IP
key_func=get_remote_address,
app=app,
storage_uri="memory://", # Use RAM memory (change to redis:// etc. for distributed setups)
storage_uri="memory://",
)
# Disable Werkzeug debugger PIN (not needed for local development tool)
@@ -928,6 +928,102 @@ def _ensure_self_signed_cert(cert_dir: str) -> tuple:
return cert_path, key_path
_app_initialized = False
def _init_app() -> None:
"""Initialize blueprints, database, and websockets.
Safe to call multiple times — subsequent calls are no-ops.
Called automatically at module level for gunicorn, and also
from main() for the Flask dev server path.
Heavy/network operations (TLE updates, process cleanup) are
deferred to a background thread so the worker can serve
requests immediately.
"""
global _app_initialized
if _app_initialized:
return
_app_initialized = True
import os
# Initialize database for settings storage
from utils.database import init_db
init_db()
# Register blueprints (essential — without these, all routes 404)
from routes import register_blueprints
register_blueprints(app)
# Initialize WebSocket for audio streaming
try:
from routes.audio_websocket import init_audio_websocket
init_audio_websocket(app)
except ImportError:
pass
# Initialize KiwiSDR WebSocket audio proxy
try:
from routes.websdr import init_websdr_audio
init_websdr_audio(app)
except ImportError:
pass
# Initialize WebSocket for waterfall streaming
try:
from routes.waterfall_websocket import init_waterfall_websocket
init_waterfall_websocket(app)
except ImportError:
pass
# Defer heavy/network operations so the worker can serve requests immediately
import threading
def _deferred_init():
"""Run heavy initialization after a short delay."""
import time
time.sleep(1) # Let the worker start serving first
# Clean up stale processes from previous runs
try:
cleanup_stale_processes()
cleanup_stale_dump1090()
except Exception as e:
logger.warning(f"Stale process cleanup failed: {e}")
# Register and start database cleanup
try:
from utils.database import (
cleanup_old_signal_history,
cleanup_old_timeline_entries,
cleanup_old_dsc_alerts,
cleanup_old_payloads
)
cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440)
cleanup_manager.register_db_cleanup(cleanup_old_timeline_entries, interval_multiplier=1440)
cleanup_manager.register_db_cleanup(cleanup_old_dsc_alerts, interval_multiplier=1440)
cleanup_manager.register_db_cleanup(cleanup_old_payloads, interval_multiplier=1440)
cleanup_manager.start()
except Exception as e:
logger.warning(f"Cleanup manager init failed: {e}")
# Initialize TLE auto-refresh (must be after blueprint registration)
try:
from routes.satellite import init_tle_auto_refresh
if not os.environ.get('TESTING'):
init_tle_auto_refresh()
except Exception as e:
logger.warning(f"Failed to initialize TLE auto-refresh: {e}")
threading.Thread(target=_deferred_init, daemon=True).start()
# Auto-initialize when imported (e.g. by gunicorn)
_init_app()
def main() -> None:
"""Main entry point."""
import argparse
@@ -1009,81 +1105,8 @@ def main() -> None:
print("Running as root - full capabilities enabled")
print()
# Clean up any stale processes from previous runs
cleanup_stale_processes()
cleanup_stale_dump1090()
# Initialize database for settings storage
from utils.database import init_db
init_db()
# Register database cleanup functions
from utils.database import (
cleanup_old_signal_history,
cleanup_old_timeline_entries,
cleanup_old_dsc_alerts,
cleanup_old_payloads
)
cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440) # Every 24 hours
cleanup_manager.register_db_cleanup(cleanup_old_timeline_entries, interval_multiplier=1440) # Every 24 hours
cleanup_manager.register_db_cleanup(cleanup_old_dsc_alerts, interval_multiplier=1440) # Every 24 hours
cleanup_manager.register_db_cleanup(cleanup_old_payloads, interval_multiplier=1440) # Every 24 hours
# Start automatic cleanup of stale data entries
cleanup_manager.start()
# Register blueprints
from routes import register_blueprints
register_blueprints(app)
# Initialize TLE auto-refresh (must be after blueprint registration)
try:
from routes.satellite import init_tle_auto_refresh
import os
if not os.environ.get('TESTING'):
init_tle_auto_refresh()
except Exception as e:
logger.warning(f"Failed to initialize TLE auto-refresh: {e}")
# Update TLE data in background thread (non-blocking)
def update_tle_background():
try:
from routes.satellite import refresh_tle_data
print("Updating satellite TLE data from CelesTrak...")
updated = refresh_tle_data()
if updated:
print(f"TLE data updated for: {', '.join(updated)}")
else:
print("TLE update: No satellites updated (may be offline)")
except Exception as e:
print(f"TLE update failed (will use cached data): {e}")
tle_thread = threading.Thread(target=update_tle_background, daemon=True)
tle_thread.start()
# Initialize WebSocket for audio streaming
try:
from routes.audio_websocket import init_audio_websocket
init_audio_websocket(app)
print("WebSocket audio streaming enabled")
except ImportError as e:
print(f"WebSocket audio disabled (install flask-sock): {e}")
# Initialize KiwiSDR WebSocket audio proxy
try:
from routes.websdr import init_websdr_audio
init_websdr_audio(app)
print("KiwiSDR audio proxy enabled")
except ImportError as e:
print(f"KiwiSDR audio proxy disabled: {e}")
# Initialize WebSocket for waterfall streaming
try:
from routes.waterfall_websocket import init_waterfall_websocket
init_waterfall_websocket(app)
print("WebSocket waterfall streaming enabled")
except ImportError as e:
print(f"WebSocket waterfall disabled: {e}")
# Ensure app is initialized (no-op if already done by module-level call)
_init_app()
# Configure SSL if HTTPS is enabled
ssl_context = None

View File

@@ -7,10 +7,26 @@ import os
import sys
# Application version
VERSION = "2.22.3"
VERSION = "2.23.0"
# Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [
{
"version": "2.23.0",
"date": "February 2026",
"highlights": [
"Radiosonde weather balloon tracking mode with telemetry, map, and station distance",
"CW/Morse code decoder with Goertzel tone detection and OOK envelope mode",
"WeFax (Weather Fax) decoder with auto-scheduler and broadcast timeline",
"System Health monitoring mode with telemetry dashboard",
"HTTPS support, HackRF TSCM RF scan, ADS-B voice alerts",
"Production server (start.sh) with gunicorn + gevent for concurrent multi-client support",
"Multi-SDR support for WeFax, tool path overrides, native Homebrew detection",
"GPS mode upgraded to textured 3D globe",
"Destroy lifecycle added to all mode modules to prevent resource leaks",
"Dozens of bug fixes across ADS-B, APRS, SSE, Morse, waterfall, and more",
]
},
{
"version": "2.22.3",
"date": "February 2026",

View File

@@ -1,6 +1,8 @@
# INTERCEPT - Signal Intelligence Platform
# Docker Compose configuration for easy deployment
#
# Uses gunicorn + gevent production server via start.sh (handles concurrent SSE/WebSocket)
#
# Basic usage (build locally):
# docker compose --profile basic up -d --build
#

View File

@@ -100,11 +100,30 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
- **CSV/JSON export** - export captured messages for offline analysis
- **Integrated with ADS-B dashboard** - VDL2 messages linked to aircraft tracking
## CW/Morse Code Decoder
- **Custom Goertzel tone detection** for CW (continuous wave) Morse decoding
- **OOK/AM envelope detection** mode for on-off keying signals in ISM bands
- **HF frequency presets** for amateur CW bands (160m-10m)
- **ISM band presets** for OOK envelope mode (315 MHz, 433 MHz, 868 MHz, 915 MHz)
- **Real-time character and word output** with WPM estimation
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
## WeFax (Weather Fax)
- **HF weather fax reception** from marine and meteorological broadcast stations
- **Broadcast timeline** with scheduled transmission times by station
- **Auto-scheduler** for unattended capture of scheduled broadcasts
- **Image gallery** with timestamped decoded weather charts
- **Station presets** for major WeFax broadcasters worldwide
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
## Listening Post
- **Wideband frequency scanning** via rtl_power sweep with SNR filtering
- **Real-time audio monitoring** with FM and SSB demodulation
- **Cross-module frequency routing** from scanner to decoders
- **Waterfall spectrum display** for visual signal identification
- **Customizable frequency presets** and band bookmarks
- **Multi-SDR support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay
@@ -170,6 +189,16 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
- **Auto-refresh** - 5-minute polling with manual refresh option
- **No SDR required** - Data fetched from NOAA SWPC, NASA SDO, and HamQSL public APIs
## Radiosonde Weather Balloon Tracking
- **400-406 MHz reception** via radiosonde_auto_rx for weather balloon telemetry
- **Frequency presets** for common radiosonde bands
- **Real-time telemetry** - altitude, temperature, humidity, pressure, GPS position
- **Interactive map** with balloon trajectory and burst point prediction
- **Station location** with configurable observer position
- **Distance tracking** - real-time distance-to-balloon calculation
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
## Satellite Tracking
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
@@ -270,7 +299,7 @@ Technical Surveillance Countermeasures (TSCM) screening for detecting wireless s
### Wireless Sweep Features
- **BLE scanning** with manufacturer data detection (AirTags, Tile, SmartTags, ESP32)
- **WiFi scanning** for rogue APs, hidden SSIDs, camera devices
- **RF spectrum analysis** (requires RTL-SDR) - FM bugs, ISM bands, video transmitters
- **RF spectrum analysis** (RTL-SDR or HackRF) - FM bugs, ISM bands, video transmitters
- **Cross-protocol correlation** - links devices across BLE/WiFi/RF
- **Baseline comparison** - detect new/unknown devices vs known environment
@@ -369,6 +398,14 @@ Deploy lightweight sensor nodes across multiple locations and aggregate data to
- **Redundancy** - Multiple nodes for reliable coverage
- **Triangulation** - Use multiple GPS-enabled agents for signal location
## System Health
- **Telemetry dashboard** with real-time system metrics
- **Process monitoring** for all running SDR tools and decoders
- **CPU, memory, and disk usage** tracking
- **SDR device status** overview
- **No SDR required** - monitors system health independently
## User Interface
- **Mode-specific header stats** - real-time badges showing key metrics per mode
@@ -429,14 +466,19 @@ The settings modal shows availability status for each bundled asset:
## General
- **Web-based interface** - no desktop app needed
- **Production server** - gunicorn + gevent via `start.sh` for concurrent SSE/WebSocket handling (falls back to Flask dev server)
- **Live message streaming** via Server-Sent Events (SSE)
- **Audio alerts** with mute toggle
- **Message export** to CSV/JSON
- **Signal activity meter** and waterfall display
- **Message logging** to file with timestamps
- **Multi-SDR hardware support** - RTL-SDR, LimeSDR, HackRF
- **HTTPS support** via `INTERCEPT_HTTPS` configuration for secure deployments
- **Voice alerts** for configurable event notifications across modes
- **Multi-SDR hardware support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay
- **Automatic device detection** across all supported hardware
- **Hardware-specific validation** - frequency/gain ranges per device type
- **Tool path overrides** via `INTERCEPT_*_PATH` environment variables
- **Native Homebrew detection** for Apple Silicon tool paths
- **Configurable gain and PPM correction**
- **Device intelligence** dashboard with tracking
- **GPS dongle support** - USB GPS receivers for precise observer location

View File

@@ -259,10 +259,13 @@ pip install -r requirements.txt
After installation:
```bash
sudo -E venv/bin/python intercept.py
sudo ./start.sh
# Custom port
INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py
sudo ./start.sh -p 8080
# HTTPS
sudo ./start.sh --https
```
Open **http://localhost:5050** in your browser.

View File

@@ -18,10 +18,9 @@ By default, INTERCEPT binds to `0.0.0.0:5050`, making it accessible from any net
echo "block in on en0 proto tcp from any to any port 5050" | sudo pfctl -ef -
```
2. **Bind to Localhost**: For local-only access, set the host environment variable:
2. **Bind to Localhost**: For local-only access, set the host or use the CLI flag:
```bash
export INTERCEPT_HOST=127.0.0.1
python intercept.py
sudo ./start.sh -H 127.0.0.1
```
3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. The application has no authentication mechanism.

View File

@@ -25,7 +25,7 @@ sudo apt install python3-flask python3-requests python3-serial python3-skyfield
# Then create venv with system packages
python3 -m venv --system-site-packages venv
source venv/bin/activate
sudo venv/bin/python intercept.py
sudo ./start.sh
```
### "error: externally-managed-environment" (pip blocked)
@@ -61,7 +61,7 @@ sudo apt install python3.11 python3.11-venv python3-pip
python3.11 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
sudo venv/bin/python intercept.py
sudo ./start.sh
```
### Alternative: Use the setup script
@@ -336,7 +336,7 @@ rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>/dev/null | \
Run INTERCEPT with sudo:
```bash
sudo -E venv/bin/python intercept.py
sudo ./start.sh
```
### Interface not found after enabling monitor mode

View File

@@ -172,7 +172,7 @@ Set the following environment variables (Docker recommended):
```bash
INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
sudo -E venv/bin/python intercept.py
sudo ./start.sh
```
**Docker example (.env)**
@@ -518,10 +518,28 @@ INTERCEPT can be configured via environment variables:
| `INTERCEPT_LOG_LEVEL` | `WARNING` | Log level (DEBUG, INFO, WARNING, ERROR) |
| `INTERCEPT_DEFAULT_GAIN` | `40` | Default RTL-SDR gain |
Example: `INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py`
Example: `INTERCEPT_PORT=8080 sudo ./start.sh`
## Command-line Options
### Production server (recommended)
```
sudo ./start.sh --help
-p, --port PORT Port to listen on (default: 5050)
-H, --host HOST Host to bind to (default: 0.0.0.0)
-d, --debug Run in debug mode (Flask dev server)
--https Enable HTTPS with self-signed certificate
--check-deps Check dependencies and exit
```
> **Note:** `sudo` is required for SDR hardware access, WiFi monitor mode, and Bluetooth low-level operations.
`start.sh` auto-detects gunicorn + gevent and runs a production WSGI server with cooperative greenlets — this handles multiple SSE streams and WebSocket connections concurrently without blocking. Falls back to the Flask dev server if gunicorn is not installed.
### Development server
```
python3 intercept.py --help

View File

@@ -36,7 +36,7 @@
</div>
<div class="hero-stats">
<div class="stat">
<span class="stat-value">25+</span>
<span class="stat-value">30+</span>
<span class="stat-label">Modes</span>
</div>
<div class="stat">
@@ -92,6 +92,11 @@
<h3>Listening Post</h3>
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
</div>
<div class="feature-card" data-category="signals">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h2"/><path d="M8 12h1"/><path d="M11 12h2"/><path d="M15 12h1"/><path d="M18 12h2"/><circle cx="6" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="18" cy="12" r="1"/><path d="M4 8h16"/><path d="M4 16h16"/></svg></div>
<h3>CW/Morse Decoder</h3>
<p>Morse code decoding with custom Goertzel tone detection for CW and OOK/AM envelope detection for ISM band signals.</p>
</div>
<div class="feature-card" data-category="intel">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
<h3>WebSDR</h3>
@@ -152,11 +157,21 @@
<h3>HF SSTV</h3>
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
</div>
<div class="feature-card" data-category="space">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 15h18"/><path d="M3 9h18"/><path d="M6 3v18"/><path d="M18 3v18"/><path d="M9 6h6"/></svg></div>
<h3>WeFax</h3>
<p>HF weather fax decoder with broadcast timeline, auto-scheduler, and image gallery for marine weather charts.</p>
</div>
<div class="feature-card" data-category="tracking">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/><path d="M12 8l4 4-4 4"/></svg></div>
<h3>GPS Tracking</h3>
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
</div>
<div class="feature-card" data-category="tracking">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6"/><path d="M12 22v-6"/><circle cx="12" cy="12" r="4"/><path d="M8 12H2"/><path d="M22 12h-6"/><path d="M12 8a20 20 0 0 1 0 8"/><path d="M7 4l2 3"/><path d="M17 20l-2-3"/></svg></div>
<h3>Radiosonde</h3>
<p>Weather balloon tracking on 400-406 MHz via radiosonde_auto_rx. Real-time telemetry, trajectory map, and station distance.</p>
</div>
<div class="feature-card" data-category="space">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></div>
<h3>Space Weather</h3>
@@ -197,6 +212,11 @@
<h3>Offline Mode</h3>
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
</div>
<div class="feature-card" data-category="platform">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></div>
<h3>System Health</h3>
<p>Real-time telemetry dashboard with process monitoring, system metrics, and SDR device status overview.</p>
</div>
</div>
<button class="carousel-arrow carousel-arrow-right" aria-label="Scroll right">&#8250;</button>
</div>
@@ -311,7 +331,7 @@
<pre><code>git clone https://github.com/smittix/intercept.git
cd intercept
./setup.sh
sudo -E venv/bin/python intercept.py</code></pre>
sudo ./start.sh</code></pre>
</div>
<p class="install-note">Requires Python 3.9+ and RTL-SDR drivers</p>
</div>

View File

@@ -1,6 +1,6 @@
[project]
name = "intercept"
version = "2.22.3"
version = "2.23.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md"
requires-python = ">=3.9"

View File

@@ -47,3 +47,7 @@ websocket-client>=1.6.0
# System health monitoring (optional - graceful fallback if unavailable)
psutil>=5.9.0
# Production WSGI server (optional - falls back to Flask dev server)
gunicorn>=21.2.0
gevent>=23.9.0

View File

@@ -22,7 +22,13 @@ 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_device_index, validate_gain, validate_ppm
from utils.validation import (
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.sdr import SDRFactory, SDRType
@@ -1689,6 +1695,10 @@ def start_aprs() -> Response:
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)
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
try:
sdr_type = SDRType(sdr_type_str)
@@ -1708,16 +1718,17 @@ def start_aprs() -> Response:
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
}), 400
# Reserve SDR device to prevent conflicts with other modes
error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
aprs_active_device = device
aprs_active_sdr_type = sdr_type_str
# Reserve SDR device to prevent conflicts (skip for remote rtl_tcp)
if not rtl_tcp_host:
error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
aprs_active_device = device
aprs_active_sdr_type = sdr_type_str
# Get frequency for region
region = data.get('region', 'north_america')
@@ -1741,8 +1752,17 @@ def start_aprs() -> Response:
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
try:
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type)
if rtl_tcp_host:
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:
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_device.sdr_type)
rtl_cmd = builder.build_fm_demod_command(
device=sdr_device,
frequency_mhz=float(frequency),

View File

@@ -261,7 +261,7 @@ def start_scan():
# Check if already scanning
if scanner.is_scanning:
return jsonify({
'status': 'already_running',
'status': 'already_scanning',
'scan_status': scanner.get_status().to_dict()
})

View File

@@ -37,7 +37,12 @@ from utils.database import (
from utils.dsc.parser import parse_dsc_message
from utils.sse import sse_stream_fanout
from utils.event_pipeline import process_event
from utils.validation import validate_device_index, validate_gain
from utils.validation import (
validate_device_index,
validate_gain,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
)
from utils.sdr import SDRFactory, SDRType
from utils.dependencies import get_tool_path
from utils.process import register_process, unregister_process
@@ -336,19 +341,29 @@ def start_decoding() -> Response:
# 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, dsc_active_sdr_type
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'dsc', sdr_type_str)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
dsc_active_device = device_int
dsc_active_sdr_type = sdr_type_str
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
# Check if device is available using centralized registry (skip for remote rtl_tcp)
global dsc_active_device, dsc_active_sdr_type
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'dsc', sdr_type_str)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error
}), 409
dsc_active_device = device_int
dsc_active_sdr_type = sdr_type_str
# Clear queue
while not app_module.dsc_queue.empty():
@@ -357,22 +372,32 @@ def start_decoding() -> Response:
except queue.Empty:
break
# Build rtl_fm command
rtl_fm_path = tools['rtl_fm']['path']
# Build rtl_fm command via SDR abstraction layer
decoder_path = tools['dsc_decoder']['path']
# rtl_fm command for DSC decoding
# DSC uses narrow FM at 156.525 MHz with 48kHz sample rate
rtl_cmd = [
rtl_fm_path,
'-f', f'{DSC_VHF_FREQUENCY_MHZ}M',
'-s', str(DSC_SAMPLE_RATE),
'-d', str(device),
'-g', str(gain),
'-M', 'fm', # FM demodulation
'-l', '0', # No squelch for DSC
'-E', 'dc' # DC blocking filter
]
if rtl_tcp_host:
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:
sdr_device = SDRFactory.create_default_device(sdr_type, index=int(device))
builder = SDRFactory.get_builder(sdr_device.sdr_type)
rtl_cmd = list(builder.build_fm_demod_command(
device=sdr_device,
frequency_mhz=DSC_VHF_FREQUENCY_MHZ,
sample_rate=DSC_SAMPLE_RATE,
gain=float(gain) if gain and str(gain) != '0' else None,
modulation='fm',
squelch=0,
))
# Ensure trailing '-' for stdin piping and add DC blocking filter
if rtl_cmd and rtl_cmd[-1] == '-':
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-']
# Decoder command
decoder_cmd = [decoder_path]

View File

@@ -28,6 +28,8 @@ from utils.validation import (
validate_frequency,
validate_gain,
validate_ppm,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
)
morse_bp = Blueprint('morse', __name__)
@@ -219,6 +221,14 @@ def _validate_signal_gate(value: Any) -> float:
raise ValueError(f'Invalid signal gate: {value}') from e
def _validate_detect_mode(value: Any) -> str:
"""Validate detection mode ('goertzel' or 'envelope')."""
mode = str(value or 'goertzel').lower().strip()
if mode not in ('goertzel', 'envelope'):
raise ValueError("detect_mode must be 'goertzel' or 'envelope'")
return mode
def _snapshot_live_resources() -> list[str]:
alive: list[str] = []
if morse_decoder_worker and morse_decoder_worker.is_alive():
@@ -238,8 +248,15 @@ def start_morse() -> Response:
data = request.json or {}
# Validate detect_mode first — it determines frequency limits.
try:
freq = validate_frequency(data.get('frequency', '14.060'), min_mhz=0.5, max_mhz=30.0)
detect_mode = _validate_detect_mode(data.get('detect_mode', 'goertzel'))
except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400
freq_max = 1766.0 if detect_mode == 'envelope' else 30.0
try:
freq = validate_frequency(data.get('frequency', '14.060'), min_mhz=0.5, max_mhz=freq_max)
gain = validate_gain(data.get('gain', '0'))
ppm = validate_ppm(data.get('ppm', '0'))
device = validate_device_index(data.get('device', '0'))
@@ -264,6 +281,10 @@ def start_morse() -> Response:
sdr_type_str = data.get('sdr_type', 'rtlsdr')
# Check for rtl_tcp (remote SDR) connection
rtl_tcp_host = data.get('rtl_tcp_host')
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
with app_module.morse_lock:
if morse_state in {MORSE_STARTING, MORSE_RUNNING, MORSE_STOPPING}:
return jsonify({
@@ -272,24 +293,34 @@ def start_morse() -> Response:
'state': morse_state,
}), 409
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'morse', sdr_type_str)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error,
}), 409
# Reserve SDR device (skip for remote rtl_tcp)
if not rtl_tcp_host:
device_int = int(device)
error = app_module.claim_sdr_device(device_int, 'morse', sdr_type_str)
if error:
return jsonify({
'status': 'error',
'error_type': 'DEVICE_BUSY',
'message': error,
}), 409
morse_active_device = device_int
morse_active_sdr_type = sdr_type_str
morse_active_device = device_int
morse_active_sdr_type = sdr_type_str
morse_last_error = ''
morse_session_id += 1
_drain_queue(app_module.morse_queue)
_set_state(MORSE_STARTING, 'Starting decoder...')
sample_rate = 22050
# Envelope mode (OOK/AM): use AM demod, higher sample rate for better
# envelope resolution. Goertzel mode (HF CW): use USB demod.
if detect_mode == 'envelope':
sample_rate = 48000
modulation = 'am'
else:
sample_rate = 22050
modulation = 'usb'
bias_t = _bool_value(data.get('bias_t', False), False)
try:
@@ -297,23 +328,35 @@ def start_morse() -> Response:
except ValueError:
sdr_type = SDRType.RTL_SDR
# Create network or local SDR device
network_sdr_device = None
if rtl_tcp_host:
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
network_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}")
requested_device_index = int(device)
active_device_index = requested_device_index
builder = SDRFactory.get_builder(sdr_type)
builder = SDRFactory.get_builder(network_sdr_device.sdr_type if network_sdr_device else sdr_type)
device_catalog: dict[int, dict[str, str]] = {}
candidate_device_indices: list[int] = [requested_device_index]
with contextlib.suppress(Exception):
detected_devices = SDRFactory.detect_devices()
same_type_devices = [d for d in detected_devices if d.sdr_type == sdr_type]
for d in same_type_devices:
device_catalog[d.index] = {
'name': str(d.name or f'SDR {d.index}'),
'serial': str(d.serial or 'Unknown'),
}
for d in sorted(same_type_devices, key=lambda dev: dev.index):
if d.index not in candidate_device_indices:
candidate_device_indices.append(d.index)
if not network_sdr_device:
with contextlib.suppress(Exception):
detected_devices = SDRFactory.detect_devices()
same_type_devices = [d for d in detected_devices if d.sdr_type == sdr_type]
for d in same_type_devices:
device_catalog[d.index] = {
'name': str(d.name or f'SDR {d.index}'),
'serial': str(d.serial or 'Unknown'),
}
for d in sorted(same_type_devices, key=lambda dev: dev.index):
if d.index not in candidate_device_indices:
candidate_device_indices.append(d.index)
def _device_label(device_index: int) -> str:
meta = device_catalog.get(device_index, {})
@@ -322,15 +365,19 @@ def start_morse() -> Response:
return f'device {device_index} ({name}, SN: {serial})'
def _build_rtl_cmd(device_index: int, direct_sampling_mode: int | None) -> list[str]:
tuned_frequency_mhz = max(0.5, float(freq) - (float(tone_freq) / 1_000_000.0))
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_index)
# Envelope mode tunes directly to center freq (no tone offset).
if detect_mode == 'envelope':
tuned_frequency_mhz = max(0.5, float(freq))
else:
tuned_frequency_mhz = max(0.5, float(freq) - (float(tone_freq) / 1_000_000.0))
sdr_device = network_sdr_device or SDRFactory.create_default_device(sdr_type, index=device_index)
fm_kwargs: dict[str, Any] = {
'device': sdr_device,
'frequency_mhz': tuned_frequency_mhz,
'sample_rate': sample_rate,
'gain': float(gain) if gain and gain != '0' else None,
'ppm': int(ppm) if ppm and ppm != '0' else None,
'modulation': 'usb',
'modulation': modulation,
'bias_t': bias_t,
}
if direct_sampling_mode in (1, 2):
@@ -342,13 +389,19 @@ def start_morse() -> Response:
cmd.append('-')
return cmd
can_try_direct_sampling = bool(sdr_type == SDRType.RTL_SDR and float(freq) < 24.0)
can_try_direct_sampling = bool(
sdr_type == SDRType.RTL_SDR
and detect_mode != 'envelope' # direct sampling is HF-only
and float(freq) < 24.0
)
direct_sampling_attempts: list[int | None] = [2, 1, None] if can_try_direct_sampling else [None]
runtime_config: dict[str, Any] = {
'sample_rate': sample_rate,
'detect_mode': detect_mode,
'modulation': modulation,
'rf_frequency_mhz': float(freq),
'tuned_frequency_mhz': max(0.5, float(freq) - (float(tone_freq) / 1_000_000.0)),
'tuned_frequency_mhz': max(0.5, float(freq)) if detect_mode == 'envelope' else max(0.5, float(freq) - (float(tone_freq) / 1_000_000.0)),
'tone_freq': tone_freq,
'wpm': wpm,
'bandwidth_hz': bandwidth_hz,
@@ -663,6 +716,8 @@ def start_morse() -> Response:
'status': 'started',
'state': MORSE_RUNNING,
'command': full_cmd,
'detect_mode': detect_mode,
'modulation': modulation,
'tone_freq': tone_freq,
'wpm': wpm,
'config': runtime_config,

View File

@@ -12,8 +12,8 @@ import os
import queue
import shutil
import socket
import sys
import subprocess
import sys
import threading
import time
from typing import Any
@@ -29,10 +29,16 @@ from utils.constants import (
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
)
from utils.gps import is_gpsd_running
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
from utils.validation import (
validate_device_index,
validate_gain,
validate_latitude,
validate_longitude,
)
logger = get_logger('intercept.radiosonde')
@@ -83,6 +89,10 @@ def generate_station_cfg(
ppm: int = 0,
bias_t: bool = False,
udp_port: int = RADIOSONDE_UDP_PORT,
latitude: float = 0.0,
longitude: float = 0.0,
station_alt: float = 0.0,
gpsd_enabled: bool = False,
) -> str:
"""Generate a station.cfg for radiosonde_auto_rx and return the file path."""
cfg_dir = os.path.abspath(os.path.join('data', 'radiosonde'))
@@ -116,10 +126,10 @@ always_scan = []
always_decode = []
[location]
station_lat = 0.0
station_lon = 0.0
station_alt = 0.0
gpsd_enabled = False
station_lat = {latitude}
station_lon = {longitude}
station_alt = {station_alt}
gpsd_enabled = {str(gpsd_enabled)}
gpsd_host = localhost
gpsd_port = 2947
@@ -471,6 +481,20 @@ def start_radiosonde():
bias_t = data.get('bias_t', False)
ppm = int(data.get('ppm', 0))
# Validate optional location
latitude = 0.0
longitude = 0.0
if data.get('latitude') is not None and data.get('longitude') is not None:
try:
latitude = validate_latitude(data['latitude'])
longitude = validate_longitude(data['longitude'])
except ValueError:
latitude = 0.0
longitude = 0.0
# Check if gpsd is available for live position updates
gpsd_enabled = is_gpsd_running()
# Find auto_rx
auto_rx_path = find_auto_rx()
if not auto_rx_path:
@@ -515,6 +539,9 @@ def start_radiosonde():
device_index=device_int,
ppm=ppm,
bias_t=bias_t,
latitude=latitude,
longitude=longitude,
gpsd_enabled=gpsd_enabled,
)
# Build command - auto_rx -c expects a file path, not a directory

View File

@@ -337,7 +337,8 @@ install_python_deps() {
info "Installing optional packages..."
for pkg in "numpy>=1.24.0" "scipy>=1.10.0" "Pillow>=9.0.0" "skyfield>=1.45" \
"bleak>=0.21.0" "psycopg2-binary>=2.9.9" "meshtastic>=2.0.0" \
"scapy>=2.4.5" "qrcode[pil]>=7.4" "cryptography>=41.0.0"; do
"scapy>=2.4.5" "qrcode[pil]>=7.4" "cryptography>=41.0.0" \
"gunicorn>=21.2.0" "gevent>=23.9.0" "psutil>=5.9.0"; do
pkg_name="${pkg%%>=*}"
if ! $PIP install "$pkg" 2>/dev/null; then
warn "${pkg_name} failed to install (optional - related features may be unavailable)"
@@ -1590,6 +1591,9 @@ final_summary_and_hard_fail() {
echo "============================================"
echo
echo "To start INTERCEPT:"
echo " sudo ./start.sh"
echo
echo "Or for quick local dev:"
echo " sudo -E venv/bin/python intercept.py"
echo
echo "Then open http://localhost:5050 in your browser"

161
start.sh Executable file
View File

@@ -0,0 +1,161 @@
#!/usr/bin/env bash
# INTERCEPT - Production Startup Script
#
# Starts INTERCEPT with gunicorn + gevent for production use.
# Falls back to Flask dev server if gunicorn is not installed.
#
# Requires sudo for SDR, WiFi monitor mode, and Bluetooth access.
#
# Usage:
# sudo ./start.sh # Default: 0.0.0.0:5050
# sudo ./start.sh -p 8080 # Custom port
# sudo ./start.sh --https # HTTPS with self-signed cert
# sudo ./start.sh --debug # Debug mode (Flask dev server)
# sudo ./start.sh --check-deps # Check dependencies and exit
set -euo pipefail
# ── Resolve Python from venv or system ───────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -x "$SCRIPT_DIR/venv/bin/python" ]]; then
PYTHON="$SCRIPT_DIR/venv/bin/python"
elif [[ -n "${VIRTUAL_ENV:-}" && -x "$VIRTUAL_ENV/bin/python" ]]; then
PYTHON="$VIRTUAL_ENV/bin/python"
else
PYTHON="$(command -v python3 || command -v python)"
fi
# ── Defaults (can be overridden by env vars or CLI flags) ────────────────────
HOST="${INTERCEPT_HOST:-0.0.0.0}"
PORT="${INTERCEPT_PORT:-5050}"
DEBUG=0
HTTPS=0
CHECK_DEPS=0
# ── Parse CLI arguments ─────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
-p|--port)
PORT="$2"
shift 2
;;
-H|--host)
HOST="$2"
shift 2
;;
-d|--debug)
DEBUG=1
shift
;;
--https)
HTTPS=1
shift
;;
--check-deps)
CHECK_DEPS=1
shift
;;
-h|--help)
echo "Usage: start.sh [OPTIONS]"
echo ""
echo "Options:"
echo " -p, --port PORT Port to listen on (default: 5050)"
echo " -H, --host HOST Host to bind to (default: 0.0.0.0)"
echo " -d, --debug Run in debug mode (Flask dev server)"
echo " --https Enable HTTPS with self-signed certificate"
echo " --check-deps Check dependencies and exit"
echo " -h, --help Show this help message"
exit 0
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
# ── Export for config.py ─────────────────────────────────────────────────────
export INTERCEPT_HOST="$HOST"
export INTERCEPT_PORT="$PORT"
# ── Dependency check (delegate to intercept.py) ─────────────────────────────
if [[ "$CHECK_DEPS" -eq 1 ]]; then
exec "$PYTHON" intercept.py --check-deps
fi
# ── Debug mode always uses Flask dev server ──────────────────────────────────
if [[ "$DEBUG" -eq 1 ]]; then
echo "[INTERCEPT] Starting in debug mode (Flask dev server)..."
export INTERCEPT_DEBUG=1
exec "$PYTHON" intercept.py --host "$HOST" --port "$PORT" --debug
fi
# ── HTTPS certificate generation ────────────────────────────────────────────
CERT_DIR="certs"
CERT_FILE="$CERT_DIR/intercept.crt"
KEY_FILE="$CERT_DIR/intercept.key"
if [[ "$HTTPS" -eq 1 ]]; then
if [[ ! -f "$CERT_FILE" || ! -f "$KEY_FILE" ]]; then
echo "[INTERCEPT] Generating self-signed SSL certificate..."
mkdir -p "$CERT_DIR"
openssl req -x509 -newkey rsa:2048 \
-keyout "$KEY_FILE" -out "$CERT_FILE" \
-days 365 -nodes \
-subj '/CN=intercept/O=INTERCEPT/C=US' 2>/dev/null
echo "[INTERCEPT] SSL certificate generated: $CERT_FILE"
else
echo "[INTERCEPT] Using existing SSL certificate: $CERT_FILE"
fi
fi
# ── Detect gunicorn + gevent ─────────────────────────────────────────────────
HAS_GUNICORN=0
HAS_GEVENT=0
if "$PYTHON" -c "import gunicorn" 2>/dev/null; then
HAS_GUNICORN=1
fi
if "$PYTHON" -c "import gevent" 2>/dev/null; then
HAS_GEVENT=1
fi
# ── Start the server ─────────────────────────────────────────────────────────
if [[ "$HAS_GUNICORN" -eq 1 && "$HAS_GEVENT" -eq 1 ]]; then
echo "[INTERCEPT] Starting production server (gunicorn + gevent)..."
echo "[INTERCEPT] Listening on ${HOST}:${PORT}"
GUNICORN_ARGS=(
-k gevent
-w 1
--timeout 300
--graceful-timeout 5
--worker-connections 1000
--bind "${HOST}:${PORT}"
--access-logfile -
--error-logfile -
)
if [[ "$HTTPS" -eq 1 ]]; then
GUNICORN_ARGS+=(--certfile "$CERT_FILE" --keyfile "$KEY_FILE")
echo "[INTERCEPT] HTTPS enabled"
fi
exec "$PYTHON" -m gunicorn "${GUNICORN_ARGS[@]}" app:app
else
if [[ "$HAS_GUNICORN" -eq 0 ]]; then
echo "[INTERCEPT] gunicorn not found — falling back to Flask dev server"
fi
if [[ "$HAS_GEVENT" -eq 0 ]]; then
echo "[INTERCEPT] gevent not found — falling back to Flask dev server"
fi
echo "[INTERCEPT] Install with: pip install gunicorn gevent"
echo ""
FLASK_ARGS=(--host "$HOST" --port "$PORT")
if [[ "$HTTPS" -eq 1 ]]; then
FLASK_ARGS+=(--https)
fi
exec "$PYTHON" intercept.py "${FLASK_ARGS[@]}"
fi

View File

@@ -1784,6 +1784,13 @@ const BluetoothMode = (function() {
*/
function destroy() {
stopEventStream();
devices.clear();
pendingDeviceIds.clear();
if (deviceContainer) {
deviceContainer.innerHTML = '';
}
const countEl = document.getElementById('btDeviceListCount');
if (countEl) countEl.textContent = '0';
}
})();

View File

@@ -96,13 +96,14 @@ var MorseMode = (function () {
}
function collectConfig() {
return {
var config = {
frequency: (el('morseFrequency') && el('morseFrequency').value) || '14.060',
gain: (el('morseGain') && el('morseGain').value) || '40',
ppm: (el('morsePPM') && el('morsePPM').value) || '0',
device: (el('deviceSelect') && el('deviceSelect').value) || '0',
sdr_type: (el('sdrTypeSelect') && el('sdrTypeSelect').value) || 'rtlsdr',
bias_t: (typeof getBiasTEnabled === 'function') ? getBiasTEnabled() : false,
detect_mode: (el('morseDetectMode') && el('morseDetectMode').value) || 'goertzel',
tone_freq: (el('morseToneFreq') && el('morseToneFreq').value) || '700',
bandwidth_hz: (el('morseBandwidth') && el('morseBandwidth').value) || '200',
auto_tone_track: !!(el('morseAutoToneTrack') && el('morseAutoToneTrack').checked),
@@ -116,6 +117,17 @@ var MorseMode = (function () {
wpm: (el('morseWpm') && el('morseWpm').value) || '15',
wpm_lock: !!(el('morseWpmLock') && el('morseWpmLock').checked),
};
// Add rtl_tcp params if using remote SDR
if (typeof getRemoteSDRConfig === 'function') {
var remoteConfig = getRemoteSDRConfig();
if (remoteConfig) {
config.rtl_tcp_host = remoteConfig.host;
config.rtl_tcp_port = remoteConfig.port;
}
}
return config;
}
function persistSettings() {
@@ -124,6 +136,7 @@ var MorseMode = (function () {
frequency: (el('morseFrequency') && el('morseFrequency').value) || '14.060',
gain: (el('morseGain') && el('morseGain').value) || '40',
ppm: (el('morsePPM') && el('morsePPM').value) || '0',
detect_mode: (el('morseDetectMode') && el('morseDetectMode').value) || 'goertzel',
tone_freq: (el('morseToneFreq') && el('morseToneFreq').value) || '700',
bandwidth_hz: (el('morseBandwidth') && el('morseBandwidth').value) || '200',
auto_tone_track: !!(el('morseAutoToneTrack') && el('morseAutoToneTrack').checked),
@@ -167,6 +180,9 @@ var MorseMode = (function () {
if (el('morseShowRaw') && settings.show_raw !== undefined) el('morseShowRaw').checked = !!settings.show_raw;
if (el('morseShowDiag') && settings.show_diag !== undefined) el('morseShowDiag').checked = !!settings.show_diag;
if (settings.detect_mode) {
setDetectMode(settings.detect_mode);
}
updateToneLabel((el('morseToneFreq') && el('morseToneFreq').value) || '700');
updateWpmLabel((el('morseWpm') && el('morseWpm').value) || '15');
onThresholdModeChange();
@@ -198,10 +214,11 @@ var MorseMode = (function () {
state.controlsBound = true;
var ids = [
'morseFrequency', 'morseGain', 'morsePPM', 'morseToneFreq', 'morseBandwidth',
'morseAutoToneTrack', 'morseToneLock', 'morseThresholdMode', 'morseManualThreshold',
'morseThresholdMultiplier', 'morseThresholdOffset', 'morseSignalGate',
'morseWpmMode', 'morseWpm', 'morseWpmLock', 'morseShowRaw', 'morseShowDiag'
'morseFrequency', 'morseGain', 'morsePPM', 'morseDetectMode', 'morseToneFreq',
'morseBandwidth', 'morseAutoToneTrack', 'morseToneLock', 'morseThresholdMode',
'morseManualThreshold', 'morseThresholdMultiplier', 'morseThresholdOffset',
'morseSignalGate', 'morseWpmMode', 'morseWpm', 'morseWpmLock',
'morseShowRaw', 'morseShowDiag'
];
ids.forEach(function (id) {
@@ -1199,12 +1216,80 @@ var MorseMode = (function () {
});
}
function setDetectMode(mode) {
var hidden = el('morseDetectMode');
if (hidden) hidden.value = mode;
// Update toggle button styles
var btnGoertzel = el('morseDetectGoertzel');
var btnEnvelope = el('morseDetectEnvelope');
if (btnGoertzel && btnEnvelope) {
if (mode === 'envelope') {
btnEnvelope.style.background = 'var(--accent)';
btnEnvelope.style.color = '#000';
btnGoertzel.style.background = '';
btnGoertzel.style.color = '';
} else {
btnGoertzel.style.background = 'var(--accent)';
btnGoertzel.style.color = '#000';
btnEnvelope.style.background = '';
btnEnvelope.style.color = '';
}
}
// Toggle preset groups
var hfPresets = el('morseHFPresets');
var ismPresets = el('morseISMPresets');
if (hfPresets) hfPresets.style.display = mode === 'envelope' ? 'none' : 'flex';
if (ismPresets) ismPresets.style.display = mode === 'envelope' ? 'flex' : 'none';
// Toggle CW detector section (tone freq, bandwidth, tone track -- not needed for envelope)
var toneGroup = el('morseToneFreqGroup');
if (toneGroup) toneGroup.style.display = mode === 'envelope' ? 'none' : '';
// Toggle antenna notes
var hfNote = el('morseHFNote');
var envNote = el('morseEnvelopeNote');
if (hfNote) hfNote.style.display = mode === 'envelope' ? 'none' : '';
if (envNote) envNote.style.display = mode === 'envelope' ? '' : 'none';
// Update hint text
var hint = el('morseDetectHint');
if (hint) {
hint.textContent = mode === 'envelope'
? 'OOK Envelope: AM demod, RMS detection. For ISM-band OOK/CW.'
: 'CW Tone: HF bands, USB demod, Goertzel filter. For amateur CW.';
}
// Set sensible default frequency when switching modes
var freqEl = el('morseFrequency');
if (freqEl) {
var curFreq = parseFloat(freqEl.value);
if (mode === 'envelope' && curFreq < 30) {
freqEl.value = '433.300';
} else if (mode === 'goertzel' && curFreq > 30) {
freqEl.value = '14.060';
}
}
// Set WPM default for envelope mode (OOK transmitters tend to be slower)
var wpmEl = el('morseWpm');
var wpmLabel = el('morseWpmLabel');
if (mode === 'envelope' && wpmEl) {
wpmEl.value = '12';
if (wpmLabel) wpmLabel.textContent = '12';
}
persistSettings();
}
return {
init: init,
destroy: destroy,
start: start,
stop: stop,
setFreq: setFreq,
setDetectMode: setDetectMode,
exportTxt: exportTxt,
exportCsv: exportCsv,
copyToClipboard: copyToClipboard,

View File

@@ -1179,6 +1179,19 @@
const isAgentMode = typeof aisCurrentAgent !== 'undefined' && aisCurrentAgent !== 'local';
dscCurrentAgent = isAgentMode ? aisCurrentAgent : null;
// Check for remote SDR (only for local mode)
const remoteConfig = (!isAgentMode && typeof getRemoteSDRConfig === 'function')
? getRemoteSDRConfig() : null;
if (remoteConfig === false) return; // Validation failed
const requestBody = { device, gain };
// Add rtl_tcp params if using remote SDR
if (remoteConfig) {
requestBody.rtl_tcp_host = remoteConfig.host;
requestBody.rtl_tcp_port = remoteConfig.port;
}
// Determine endpoint based on agent mode
const endpoint = isAgentMode
? `/controller/agents/${aisCurrentAgent}/dsc/start`
@@ -1187,7 +1200,7 @@
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, gain })
body: JSON.stringify(requestBody)
})
.then(r => r.json())
.then(data => {

View File

@@ -4296,6 +4296,10 @@
const sensorTimelineContainer = document.getElementById('sensorTimelineContainer');
if (pagerTimelineContainer) pagerTimelineContainer.style.display = mode === 'pager' ? 'block' : 'none';
if (sensorTimelineContainer) sensorTimelineContainer.style.display = mode === 'sensor' ? 'block' : 'none';
const pagerScopePanel = document.getElementById('pagerScopePanel');
if (pagerScopePanel && mode !== 'pager') pagerScopePanel.style.display = 'none';
const sensorScopePanel = document.getElementById('sensorScopePanel');
if (sensorScopePanel && mode !== 'sensor') sensorScopePanel.style.display = 'none';
const morseScopePanel = document.getElementById('morseScopePanel');
const morseOutputPanel = document.getElementById('morseOutputPanel');
if (morseScopePanel && mode !== 'morse') morseScopePanel.style.display = 'none';
@@ -9793,6 +9797,10 @@
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
aprsCurrentAgent = isAgentMode ? currentAgent : null;
// Check for remote SDR (only for local mode)
const remoteConfig = isAgentMode ? null : getRemoteSDRConfig();
if (remoteConfig === false) return; // Validation failed
// Build request body
const requestBody = {
region,
@@ -9801,6 +9809,12 @@
sdr_type: sdrType
};
// Add rtl_tcp params if using remote SDR
if (remoteConfig) {
requestBody.rtl_tcp_host = remoteConfig.host;
requestBody.rtl_tcp_port = remoteConfig.port;
}
// Add custom frequency if selected
if (region === 'custom') {
const customFreq = document.getElementById('aprsStripCustomFreq').value;

View File

@@ -3,21 +3,39 @@
<div class="section">
<h3>CW/Morse Decoder</h3>
<p class="info-text morse-mode-help">
Decode CW (continuous wave) Morse with USB demod + Goertzel tone detection.
Start with 700 Hz tone and 200 Hz bandwidth.
Decode CW (continuous wave) Morse code. Supports HF amateur bands (USB + Goertzel tone
detection) and ISM/UHF OOK signals (AM + envelope detection).
</p>
</div>
<div class="section">
<h3>Detection Mode</h3>
<div class="form-group">
<div style="display: flex; gap: 4px;">
<button class="preset-btn morseDetectBtn" id="morseDetectGoertzel"
onclick="MorseMode.setDetectMode('goertzel')"
style="flex: 1; background: var(--accent); color: #000;">CW Tone</button>
<button class="preset-btn morseDetectBtn" id="morseDetectEnvelope"
onclick="MorseMode.setDetectMode('envelope')"
style="flex: 1;">OOK Envelope</button>
</div>
<input type="hidden" id="morseDetectMode" value="goertzel">
<p id="morseDetectHint" class="info-text" style="font-size: 10px; color: var(--text-dim); margin-top: 4px;">
CW Tone: HF bands, USB demod, Goertzel filter. For amateur CW.
</p>
</div>
</div>
<div class="section">
<h3>Frequency</h3>
<div class="form-group">
<label>Frequency (MHz)</label>
<input type="number" id="morseFrequency" value="14.060" step="0.001" min="0.5" max="30" placeholder="e.g., 14.060">
<input type="number" id="morseFrequency" value="14.060" step="0.001" min="0.5" max="1766" placeholder="e.g., 14.060">
<span class="help-text morse-help-text">Enter CW center frequency in MHz (e.g., 7.030 for 40m).</span>
</div>
<div class="form-group">
<label>Band Presets</label>
<div class="morse-presets">
<div class="morse-presets" id="morseHFPresets">
<button class="preset-btn" onclick="MorseMode.setFreq(3.560)">80m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(7.030)">40m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(10.116)">30m</button>
@@ -27,6 +45,13 @@
<button class="preset-btn" onclick="MorseMode.setFreq(24.910)">12m</button>
<button class="preset-btn" onclick="MorseMode.setFreq(28.060)">10m</button>
</div>
<div class="morse-presets" id="morseISMPresets" style="display: none; flex-wrap: wrap; gap: 4px;">
<button class="preset-btn" onclick="MorseMode.setFreq(315.000)">315</button>
<button class="preset-btn" onclick="MorseMode.setFreq(433.300)">433.3</button>
<button class="preset-btn" onclick="MorseMode.setFreq(433.920)">433.9</button>
<button class="preset-btn" onclick="MorseMode.setFreq(868.000)">868</button>
<button class="preset-btn" onclick="MorseMode.setFreq(915.000)">915</button>
</div>
</div>
</div>
@@ -42,7 +67,7 @@
</div>
</div>
<div class="section">
<div class="section" id="morseToneFreqGroup">
<h3>CW Detector</h3>
<div class="form-group">
<label>Tone Frequency: <span id="morseToneFreqLabel">700</span> Hz</label>
@@ -154,12 +179,18 @@
</div>
</div>
<div class="section">
<div class="section" id="morseHFNote">
<p class="info-text morse-hf-note">
CW on HF (1-30 MHz) requires an HF-capable SDR path (direct sampling or upconverter)
and an appropriate antenna.
</p>
</div>
<div class="section" id="morseEnvelopeNote" style="display: none;">
<p class="info-text" style="font-size: 11px; color: #ffaa00; line-height: 1.5;">
OOK Envelope mode uses AM demodulation to detect carrier on/off keying.
Suitable for ISM-band (315/433/868/915 MHz) Morse transmitters.
</p>
</div>
<button class="run-btn" id="morseStartBtn" onclick="MorseMode.start()">Start Decoder</button>
<button class="stop-btn" id="morseStopBtn" onclick="MorseMode.stop()" style="display: none;">Stop Decoder</button>

View File

@@ -148,6 +148,8 @@
freq_min: freqMin,
freq_max: freqMax,
bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false,
latitude: radiosondeStationLocation.lat,
longitude: radiosondeStationLocation.lon,
})
})
.then(r => r.json())
@@ -230,15 +232,23 @@
let radiosondeMarkers = new Map();
let radiosondeTracks = new Map();
let radiosondeTrackPoints = new Map();
let radiosondeStationLocation = { lat: 0, lon: 0 };
let radiosondeStationMarker = null;
function initRadiosondeMap() {
if (radiosondeMap) return;
const container = document.getElementById('radiosondeMapContainer');
if (!container) return;
// Resolve observer location
if (window.ObserverLocation && ObserverLocation.getForModule) {
radiosondeStationLocation = ObserverLocation.getForModule('radiosonde_observerLocation');
}
const hasLocation = radiosondeStationLocation.lat !== 0 || radiosondeStationLocation.lon !== 0;
radiosondeMap = L.map('radiosondeMapContainer', {
center: [40, -95],
zoom: 4,
center: hasLocation ? [radiosondeStationLocation.lat, radiosondeStationLocation.lon] : [40, -95],
zoom: hasLocation ? 7 : 4,
zoomControl: true,
});
@@ -246,6 +256,50 @@
attribution: '&copy; OpenStreetMap &copy; CARTO',
maxZoom: 18,
}).addTo(radiosondeMap);
// Add station marker if we have a location
if (hasLocation) {
radiosondeStationMarker = L.circleMarker(
[radiosondeStationLocation.lat, radiosondeStationLocation.lon], {
radius: 8,
fillColor: '#00e5ff',
color: '#00e5ff',
weight: 2,
opacity: 1,
fillOpacity: 0.5,
}).addTo(radiosondeMap);
radiosondeStationMarker.bindTooltip('Station', { permanent: false, direction: 'top' });
}
// Try GPS for live position updates
fetch('/gps/position')
.then(r => r.json())
.then(data => {
if (data.status === 'ok' && data.position && data.position.latitude != null) {
radiosondeStationLocation = { lat: data.position.latitude, lon: data.position.longitude };
const ll = [data.position.latitude, data.position.longitude];
if (radiosondeStationMarker) {
radiosondeStationMarker.setLatLng(ll);
} else {
radiosondeStationMarker = L.circleMarker(ll, {
radius: 8,
fillColor: '#00e5ff',
color: '#00e5ff',
weight: 2,
opacity: 1,
fillOpacity: 0.5,
}).addTo(radiosondeMap);
radiosondeStationMarker.bindTooltip('Station (GPS)', { permanent: false, direction: 'top' });
}
if (!radiosondeMap._gpsInitialized) {
radiosondeMap.setView(ll, 7);
radiosondeMap._gpsInitialized = true;
}
// Re-render cards with updated distances
updateRadiosondeCards();
}
})
.catch(() => {});
}
function updateRadiosondeMap(balloon) {
@@ -284,12 +338,19 @@
const tempStr = balloon.temp != null ? `${balloon.temp.toFixed(1)} °C` : '--';
const humStr = balloon.humidity != null ? `${balloon.humidity.toFixed(0)}%` : '--';
const velStr = balloon.vel_v != null ? `${balloon.vel_v.toFixed(1)} m/s` : '--';
let distStr = '';
if ((radiosondeStationLocation.lat !== 0 || radiosondeStationLocation.lon !== 0) && balloon.lat && balloon.lon) {
const distM = radiosondeMap.distance(
[radiosondeStationLocation.lat, radiosondeStationLocation.lon], latlng);
distStr = `Dist: ${(distM / 1000).toFixed(1)} km<br>`;
}
radiosondeMarkers.get(id).bindPopup(
`<strong>${id}</strong><br>` +
`Type: ${balloon.sonde_type || '--'}<br>` +
`Alt: ${altStr}<br>` +
`Temp: ${tempStr} | Hum: ${humStr}<br>` +
`Vert: ${velStr}<br>` +
distStr +
(balloon.freq ? `Freq: ${balloon.freq.toFixed(3)} MHz` : '')
);
@@ -322,6 +383,7 @@
if (!container) return;
const sorted = Object.values(radiosondeBalloons).sort((a, b) => (b.alt || 0) - (a.alt || 0));
const hasStation = radiosondeStationLocation.lat !== 0 || radiosondeStationLocation.lon !== 0;
container.innerHTML = sorted.map(b => {
const alt = b.alt ? `${Math.round(b.alt).toLocaleString()} m` : '--';
const temp = b.temp != null ? `${b.temp.toFixed(1)}°C` : '--';
@@ -329,6 +391,13 @@
const press = b.pressure != null ? `${b.pressure.toFixed(1)} hPa` : '--';
const vel = b.vel_v != null ? `${b.vel_v > 0 ? '+' : ''}${b.vel_v.toFixed(1)} m/s` : '--';
const freq = b.freq ? `${b.freq.toFixed(3)} MHz` : '--';
let dist = '--';
if (hasStation && b.lat && b.lon && radiosondeMap) {
const distM = radiosondeMap.distance(
[radiosondeStationLocation.lat, radiosondeStationLocation.lon],
[b.lat, b.lon]);
dist = `${(distM / 1000).toFixed(1)} km`;
}
return `
<div class="radiosonde-card" onclick="radiosondeMap && radiosondeMap.setView([${b.lat || 0}, ${b.lon || 0}], 10)">
<div class="radiosonde-card-header">
@@ -360,6 +429,10 @@
<span class="radiosonde-stat-value">${freq}</span>
<span class="radiosonde-stat-label">FREQ</span>
</div>
<div class="radiosonde-stat">
<span class="radiosonde-stat-value">${dist}</span>
<span class="radiosonde-stat-label">DIST</span>
</div>
</div>
</div>
`;

View File

@@ -12,20 +12,18 @@ import time
import wave
from collections import Counter
import pytest
import app as app_module
import routes.morse as morse_routes
from utils.morse import (
CHAR_TO_MORSE,
MORSE_TABLE,
EnvelopeDetector,
GoertzelFilter,
MorseDecoder,
decode_morse_wav_file,
morse_decoder_thread,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -133,6 +131,93 @@ class TestToneDetector:
assert gf.magnitude(on_tone) > gf.magnitude(off_tone) * 3.0
class TestEnvelopeDetector:
def test_magnitude_of_silence_is_near_zero(self):
det = EnvelopeDetector(block_size=160)
silence = [0.0] * 160
assert det.magnitude(silence) < 1e-6
def test_magnitude_of_constant_amplitude(self):
det = EnvelopeDetector(block_size=160)
loud = [0.8] * 160
mag = det.magnitude(loud)
assert abs(mag - 0.8) < 0.01
def test_magnitude_of_sine_wave(self):
det = EnvelopeDetector(block_size=160)
samples = [0.5 * math.sin(2 * math.pi * 700 * i / 8000.0) for i in range(160)]
mag = det.magnitude(samples)
# RMS of a sine at amplitude 0.5 is 0.5/sqrt(2) ~ 0.354
assert 0.30 < mag < 0.40
def test_magnitude_with_numpy_array(self):
import numpy as np
det = EnvelopeDetector(block_size=100)
arr = np.ones(100, dtype=np.float64) * 0.6
assert abs(det.magnitude(arr) - 0.6) < 0.01
def test_empty_samples_returns_zero(self):
det = EnvelopeDetector(block_size=0)
assert det.magnitude([]) == 0.0
class TestEnvelopeMorseDecoder:
def test_envelope_decoder_detects_ook_elements(self):
"""Verify envelope mode can distinguish on/off keying."""
sample_rate = 48000
wpm = 15
dit_dur = 1.2 / wpm
def ook_on(duration):
n = int(sample_rate * duration)
return struct.pack(f'<{n}h', *([int(0.7 * 32767)] * n))
def ook_off(duration):
n = int(sample_rate * duration)
return b'\x00\x00' * n
# Generate dit-dah (A = .-)
audio = (
ook_off(0.3)
+ ook_on(dit_dur)
+ ook_off(dit_dur)
+ ook_on(3 * dit_dur)
+ ook_off(0.5)
)
decoder = MorseDecoder(
sample_rate=sample_rate,
tone_freq=700.0,
wpm=wpm,
detect_mode='envelope',
)
events = decoder.process_block(audio)
events.extend(decoder.flush())
elements = [e['element'] for e in events if e.get('type') == 'morse_element']
assert '.' in elements
assert '-' in elements
def test_envelope_metrics_have_zero_snr(self):
"""Envelope mode metrics should report zero SNR fields."""
decoder = MorseDecoder(
sample_rate=8000,
detect_mode='envelope',
)
metrics = decoder.get_metrics()
assert metrics['detect_mode'] == 'envelope'
assert metrics['snr'] == 0.0
assert metrics['noise_ref'] == 0.0
def test_goertzel_mode_unchanged(self):
"""Default goertzel mode still works as before."""
decoder = MorseDecoder(sample_rate=8000, wpm=15)
assert decoder.detect_mode == 'goertzel'
metrics = decoder.get_metrics()
assert 'detect_mode' in metrics
assert metrics['detect_mode'] == 'goertzel'
class TestTimingAndWpmEstimator:
def test_timing_classifier_distinguishes_dit_and_dah(self):
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)

View File

@@ -1,10 +1,12 @@
"""Morse code (CW) decoding helpers.
"""Morse code (CW) decoding helpers with dual detection modes.
Signal chain:
- SDR audio from `rtl_fm -M usb` (16-bit LE PCM)
- Goertzel tone detection with optional auto-tone tracking
- Adaptive threshold + hysteresis + minimum signal gate
- Timing estimator (auto/manual WPM) and Morse symbol decoding
Supports two signal chains:
goertzel: rtl_fm -M usb -> raw PCM -> Goertzel tone filter -> timing state machine -> characters
envelope: rtl_fm -M am -> raw PCM -> RMS envelope -> timing state machine -> characters
Goertzel mode is the original path for HF CW (beat note detection).
Envelope mode adds support for OOK/AM signals (e.g. 433 MHz carrier keying)
where AM demod already produces a baseband envelope -- no tone to detect.
"""
from __future__ import annotations
@@ -80,6 +82,25 @@ class GoertzelFilter:
return math.sqrt(max(power, 0.0))
class EnvelopeDetector:
"""RMS envelope detector for AM-demodulated OOK signals.
When rtl_fm uses -M am, carrier-on produces a high amplitude envelope
and carrier-off produces near-silence. RMS over a short block gives
a clean on/off metric without needing a specific tone frequency.
"""
def __init__(self, block_size: int):
self.block_size = block_size
def magnitude(self, samples: list[float] | tuple[float, ...] | np.ndarray) -> float:
"""Compute RMS magnitude of the sample block."""
arr = np.asarray(samples, dtype=np.float64)
if arr.size == 0:
return 0.0
return float(np.sqrt(np.mean(np.square(arr))))
def _goertzel_mag(samples: np.ndarray, target_freq: float, sample_rate: int) -> float:
"""Compute Goertzel magnitude, preferring shared DSP helper."""
if _shared_goertzel_mag is not None:
@@ -137,10 +158,12 @@ class MorseDecoder:
wpm_mode: str = 'auto',
wpm_lock: bool = False,
min_signal_gate: float = 0.0,
detect_mode: str = 'goertzel',
):
self.sample_rate = int(sample_rate)
self.tone_freq = float(tone_freq)
self.wpm = int(wpm)
self.detect_mode = detect_mode if detect_mode in ('goertzel', 'envelope') else 'goertzel'
self.bandwidth_hz = int(_clamp(float(bandwidth_hz), 50, 400))
self.auto_tone_track = bool(auto_tone_track)
@@ -163,17 +186,22 @@ class MorseDecoder:
self._tone_scan_step_hz = 10.0
self._tone_scan_interval_blocks = 8
self._detector = GoertzelFilter(self._active_tone_freq, self.sample_rate, self._block_size)
self._noise_detector_low = GoertzelFilter(
_clamp(self._active_tone_freq - max(150.0, self.bandwidth_hz), 150.0, 2000.0),
self.sample_rate,
self._block_size,
)
self._noise_detector_high = GoertzelFilter(
_clamp(self._active_tone_freq + max(150.0, self.bandwidth_hz), 150.0, 2000.0),
self.sample_rate,
self._block_size,
)
if self.detect_mode == 'envelope':
self._detector = EnvelopeDetector(self._block_size)
self._noise_detector_low = None
self._noise_detector_high = None
else:
self._detector = GoertzelFilter(self._active_tone_freq, self.sample_rate, self._block_size)
self._noise_detector_low = GoertzelFilter(
_clamp(self._active_tone_freq - max(150.0, self.bandwidth_hz), 150.0, 2000.0),
self.sample_rate,
self._block_size,
)
self._noise_detector_high = GoertzelFilter(
_clamp(self._active_tone_freq + max(150.0, self.bandwidth_hz), 150.0, 2000.0),
self.sample_rate,
self._block_size,
)
# AGC for weak HF/direct-sampling signals.
self._agc_target = 0.22
@@ -181,8 +209,14 @@ class MorseDecoder:
self._agc_alpha = 0.06
# Envelope smoothing.
self._attack_alpha = 0.55
self._release_alpha = 0.45
# OOK has clean binary transitions; use symmetric fast alpha.
# HF CW has gradual fading (QSB); use asymmetric slower release.
if self.detect_mode == 'envelope':
self._attack_alpha = 0.55
self._release_alpha = 0.55
else:
self._attack_alpha = 0.55
self._release_alpha = 0.45
self._envelope = 0.0
# Adaptive threshold model.
@@ -203,8 +237,13 @@ class MorseDecoder:
dit_blocks = max(1.0, dit_sec / self._block_duration)
self._dah_threshold = 2.2 * dit_blocks
self._dit_min = 0.38 * dit_blocks
self._char_gap = 2.6 * dit_blocks
self._word_gap = 6.0 * dit_blocks
if self.detect_mode == 'envelope':
# Tighter gaps for OOK — clean binary transitions tolerate this.
self._char_gap = 2.0 * dit_blocks
self._word_gap = 5.0 * dit_blocks
else:
self._char_gap = 2.6 * dit_blocks
self._word_gap = 6.0 * dit_blocks
self._dit_observations: deque[float] = deque(maxlen=32)
self._estimated_wpm = float(self.wpm)
@@ -236,10 +275,7 @@ class MorseDecoder:
def get_metrics(self) -> dict[str, float | bool]:
"""Return latest decoder metrics for UI/status messages."""
snr_mult = max(1.15, self.threshold_multiplier * 0.5)
snr_on = snr_mult * (1.0 + self._hysteresis)
snr_off = snr_mult * (1.0 - self._hysteresis)
return {
metrics: dict[str, Any] = {
'wpm': float(self._estimated_wpm),
'tone_freq': float(self._active_tone_freq),
'level': float(self._last_level),
@@ -247,14 +283,27 @@ class MorseDecoder:
'threshold': float(self._threshold),
'tone_on': bool(self._tone_on),
'dit_ms': float((self._effective_dit_blocks() * self._block_duration) * 1000.0),
'snr': float(self._last_level / max(self._noise_floor, 1e-6)),
'noise_ref': float(self._noise_floor),
'snr_on': float(snr_on),
'snr_off': float(snr_off),
'detect_mode': self.detect_mode,
}
if self.detect_mode == 'envelope':
metrics['snr'] = 0.0
metrics['noise_ref'] = 0.0
metrics['snr_on'] = 0.0
metrics['snr_off'] = 0.0
else:
snr_mult = max(1.15, self.threshold_multiplier * 0.5)
snr_on = snr_mult * (1.0 + self._hysteresis)
snr_off = snr_mult * (1.0 - self._hysteresis)
metrics['snr'] = float(self._last_level / max(self._noise_floor, 1e-6))
metrics['noise_ref'] = float(self._noise_floor)
metrics['snr_on'] = float(snr_on)
metrics['snr_off'] = float(snr_off)
return metrics
def _rebuild_detectors(self) -> None:
"""Rebuild target/noise Goertzel filters after tone updates."""
if self.detect_mode == 'envelope':
return # Envelope detector is frequency-agnostic
self._detector = GoertzelFilter(self._active_tone_freq, self.sample_rate, self._block_size)
ref_offset = max(150.0, self.bandwidth_hz)
self._noise_detector_low = GoertzelFilter(
@@ -391,93 +440,143 @@ class MorseDecoder:
self._blocks_processed += 1
mag = self._detector.magnitude(normalized)
noise_low = self._noise_detector_low.magnitude(normalized)
noise_high = self._noise_detector_high.magnitude(normalized)
noise_ref = max(1e-9, (noise_low + noise_high) * 0.5)
if (
self.auto_tone_track
and not self.tone_lock
and self._blocks_processed > self._WARMUP_BLOCKS
and (self._blocks_processed % self._tone_scan_interval_blocks == 0)
and self._estimate_tone_frequency(normalized, mag, noise_ref)
):
# Detector changed; refresh magnitudes for this window.
mag = self._detector.magnitude(normalized)
if self.detect_mode == 'envelope':
# Envelope mode: direct magnitude threshold, no noise detectors
noise_ref = 0.0
level = float(mag)
alpha = self._attack_alpha if level >= self._envelope else self._release_alpha
self._envelope += alpha * (level - self._envelope)
self._last_level = self._envelope
self._last_noise_ref = 0.0
amplitudes.append(level)
if self._blocks_processed <= self._WARMUP_BLOCKS:
self._mag_min = min(self._mag_min, level)
self._mag_max = max(self._mag_max, level)
if self._blocks_processed == self._WARMUP_BLOCKS:
self._noise_floor = self._mag_min if math.isfinite(self._mag_min) else 0.0
if self._mag_max <= (self._noise_floor * 1.2):
self._signal_peak = max(self._noise_floor + 0.5, self._noise_floor * 2.5)
else:
self._signal_peak = max(self._mag_max, self._noise_floor * 1.8)
self._threshold = self._noise_floor + 0.22 * (
self._signal_peak - self._noise_floor
)
tone_detected = False
else:
settle_alpha = 0.30 if self._blocks_processed < (self._WARMUP_BLOCKS + self._SETTLE_BLOCKS) else 0.06
if level <= self._threshold:
self._noise_floor += settle_alpha * (level - self._noise_floor)
else:
self._signal_peak += settle_alpha * (level - self._signal_peak)
self._signal_peak = max(self._signal_peak, self._noise_floor * 1.05)
if self.threshold_mode == 'manual':
self._threshold = max(0.0, self.manual_threshold)
else:
self._threshold = (
max(0.0, self._noise_floor * self.threshold_multiplier)
+ self.threshold_offset
)
self._threshold = max(self._threshold, self._noise_floor + 0.35)
dynamic_span = max(0.0, self._signal_peak - self._noise_floor)
gate_level = self._noise_floor + (self.min_signal_gate * dynamic_span)
gate_ok = self.min_signal_gate <= 0.0 or level >= gate_level
# Direct magnitude threshold with hysteresis (no SNR)
if self._tone_on:
tone_detected = gate_ok and level >= (self._threshold * (1.0 - self._hysteresis))
else:
tone_detected = gate_ok and level >= (self._threshold * (1.0 + self._hysteresis))
else:
# Goertzel mode: SNR-based tone detection with noise reference
noise_low = self._noise_detector_low.magnitude(normalized)
noise_high = self._noise_detector_high.magnitude(normalized)
noise_ref = max(1e-9, (noise_low + noise_high) * 0.5)
level = float(mag)
alpha = self._attack_alpha if level >= self._envelope else self._release_alpha
self._envelope += alpha * (level - self._envelope)
self._last_level = self._envelope
self._last_noise_ref = noise_ref
amplitudes.append(level)
if (
self.auto_tone_track
and not self.tone_lock
and self._blocks_processed > self._WARMUP_BLOCKS
and (self._blocks_processed % self._tone_scan_interval_blocks == 0)
and self._estimate_tone_frequency(normalized, mag, noise_ref)
):
# Detector changed; refresh magnitudes for this window.
mag = self._detector.magnitude(normalized)
noise_low = self._noise_detector_low.magnitude(normalized)
noise_high = self._noise_detector_high.magnitude(normalized)
noise_ref = max(1e-9, (noise_low + noise_high) * 0.5)
if self._blocks_processed <= self._WARMUP_BLOCKS:
self._mag_min = min(self._mag_min, level)
self._mag_max = max(self._mag_max, level)
if self._blocks_processed == self._WARMUP_BLOCKS:
self._noise_floor = self._mag_min if math.isfinite(self._mag_min) else 0.0
if self._mag_max <= (self._noise_floor * 1.2):
self._signal_peak = max(self._noise_floor + 0.5, self._noise_floor * 2.5)
level = float(mag)
alpha = self._attack_alpha if level >= self._envelope else self._release_alpha
self._envelope += alpha * (level - self._envelope)
self._last_level = self._envelope
self._last_noise_ref = noise_ref
amplitudes.append(level)
if self._blocks_processed <= self._WARMUP_BLOCKS:
self._mag_min = min(self._mag_min, level)
self._mag_max = max(self._mag_max, level)
if self._blocks_processed == self._WARMUP_BLOCKS:
self._noise_floor = self._mag_min if math.isfinite(self._mag_min) else 0.0
if self._mag_max <= (self._noise_floor * 1.2):
self._signal_peak = max(self._noise_floor + 0.5, self._noise_floor * 2.5)
else:
self._signal_peak = max(self._mag_max, self._noise_floor * 1.8)
self._threshold = self._noise_floor + 0.22 * (
self._signal_peak - self._noise_floor
)
tone_detected = False
else:
settle_alpha = 0.30 if self._blocks_processed < (self._WARMUP_BLOCKS + self._SETTLE_BLOCKS) else 0.06
detector_level = level
if detector_level <= self._threshold:
self._noise_floor += settle_alpha * (detector_level - self._noise_floor)
else:
self._signal_peak = max(self._mag_max, self._noise_floor * 1.8)
self._threshold = self._noise_floor + 0.22 * (
self._signal_peak - self._noise_floor
)
tone_detected = False
else:
settle_alpha = 0.30 if self._blocks_processed < (self._WARMUP_BLOCKS + self._SETTLE_BLOCKS) else 0.06
self._signal_peak += settle_alpha * (detector_level - self._signal_peak)
detector_level = level
self._signal_peak = max(self._signal_peak, self._noise_floor * 1.05)
if detector_level <= self._threshold:
self._noise_floor += settle_alpha * (detector_level - self._noise_floor)
else:
self._signal_peak += settle_alpha * (detector_level - self._signal_peak)
# Blend adjacent-band noise reference into noise floor.
self._noise_floor += (settle_alpha * 0.25) * (noise_ref - self._noise_floor)
self._signal_peak = max(self._signal_peak, self._noise_floor * 1.05)
if self.threshold_mode == 'manual':
self._threshold = max(0.0, self.manual_threshold)
else:
self._threshold = (
max(0.0, self._noise_floor * self.threshold_multiplier)
+ self.threshold_offset
)
self._threshold = max(self._threshold, self._noise_floor + 0.35)
# Always blend adjacent-band noise reference into noise floor.
# Adjacent bands track the same AGC gain but exclude the tone,
# so this prevents noise floor from staying stuck at warmup-era
# low values after AGC converges.
self._noise_floor += (settle_alpha * 0.25) * (noise_ref - self._noise_floor)
dynamic_span = max(0.0, self._signal_peak - self._noise_floor)
gate_level = self._noise_floor + (self.min_signal_gate * dynamic_span)
gate_ok = self.min_signal_gate <= 0.0 or detector_level >= gate_level
if self.threshold_mode == 'manual':
self._threshold = max(0.0, self.manual_threshold)
else:
self._threshold = (
max(0.0, self._noise_floor * self.threshold_multiplier)
+ self.threshold_offset
)
self._threshold = max(self._threshold, self._noise_floor + 0.35)
# SNR-based tone detection (gain-invariant).
snr = level / max(noise_ref, 1e-6)
snr_mult = max(1.15, self.threshold_multiplier * 0.5)
snr_on = snr_mult * (1.0 + self._hysteresis)
snr_off = snr_mult * (1.0 - self._hysteresis)
dynamic_span = max(0.0, self._signal_peak - self._noise_floor)
gate_level = self._noise_floor + (self.min_signal_gate * dynamic_span)
gate_ok = self.min_signal_gate <= 0.0 or detector_level >= gate_level
# Use SNR (tone mag / adjacent-band noise) for tone detection.
# Both bands are equally amplified by AGC, so the ratio is
# gain-invariant — fixes stuck-ON tone when AGC amplifies
# inter-element silence above the raw magnitude threshold.
snr = level / max(noise_ref, 1e-6)
snr_mult = max(1.15, self.threshold_multiplier * 0.5)
snr_on = snr_mult * (1.0 + self._hysteresis)
snr_off = snr_mult * (1.0 - self._hysteresis)
if self._tone_on:
tone_detected = gate_ok and snr >= snr_off
else:
tone_detected = gate_ok and snr >= snr_on
if self._tone_on:
tone_detected = gate_ok and snr >= snr_off
else:
tone_detected = gate_ok and snr >= snr_on
dit_blocks = self._effective_dit_blocks()
self._dah_threshold = 2.2 * dit_blocks
self._dit_min = max(1.0, 0.38 * dit_blocks)
self._char_gap = 2.6 * dit_blocks
self._word_gap = 6.0 * dit_blocks
if self.detect_mode == 'envelope':
self._char_gap = 2.0 * dit_blocks
self._word_gap = 5.0 * dit_blocks
else:
self._char_gap = 2.6 * dit_blocks
self._word_gap = 6.0 * dit_blocks
if tone_detected and not self._tone_on:
# Tone edge up.
@@ -548,10 +647,7 @@ class MorseDecoder:
self._silence_blocks += 1.0
if amplitudes:
snr_mult = max(1.15, self.threshold_multiplier * 0.5)
snr_on = snr_mult * (1.0 + self._hysteresis)
snr_off = snr_mult * (1.0 - self._hysteresis)
events.append({
scope_event: dict[str, Any] = {
'type': 'scope',
'amplitudes': amplitudes,
'threshold': self._threshold,
@@ -561,11 +657,22 @@ class MorseDecoder:
'noise_floor': self._noise_floor,
'wpm': round(self._estimated_wpm, 1),
'dit_ms': round(self._effective_dit_blocks() * self._block_duration * 1000.0, 1),
'snr': round(self._last_level / max(self._noise_floor, 1e-6), 2),
'noise_ref': round(self._noise_floor, 4),
'snr_on': round(snr_on, 2),
'snr_off': round(snr_off, 2),
})
'detect_mode': self.detect_mode,
}
if self.detect_mode == 'envelope':
scope_event['snr'] = 0.0
scope_event['noise_ref'] = 0.0
scope_event['snr_on'] = 0.0
scope_event['snr_off'] = 0.0
else:
snr_mult = max(1.15, self.threshold_multiplier * 0.5)
snr_on = snr_mult * (1.0 + self._hysteresis)
snr_off = snr_mult * (1.0 - self._hysteresis)
scope_event['snr'] = round(self._last_level / max(self._noise_floor, 1e-6), 2)
scope_event['noise_ref'] = round(self._noise_floor, 4)
scope_event['snr_on'] = round(snr_on, 2)
scope_event['snr_off'] = round(snr_off, 2)
events.append(scope_event)
return events
@@ -818,6 +925,7 @@ def morse_decoder_thread(
wpm_mode=_normalize_wpm_mode(cfg.get('wpm_mode', 'auto')),
wpm_lock=_coerce_bool(cfg.get('wpm_lock', False), False),
min_signal_gate=float(cfg.get('min_signal_gate', 0.0) or 0.0),
detect_mode=str(cfg.get('detect_mode', 'goertzel')),
)
last_scope = time.monotonic()
@@ -1101,6 +1209,7 @@ def morse_iq_decoder_thread(
wpm_mode=_normalize_wpm_mode(cfg.get('wpm_mode', 'auto')),
wpm_lock=_coerce_bool(cfg.get('wpm_lock', False), False),
min_signal_gate=float(cfg.get('min_signal_gate', 0.0) or 0.0),
detect_mode=str(cfg.get('detect_mode', 'goertzel')),
)
last_scope = time.monotonic()

View File

@@ -104,13 +104,29 @@ def _signal_handler(signum, frame):
sys.exit(0)
# Only register signal handlers if we're not in a thread
try:
signal.signal(signal.SIGTERM, _signal_handler)
signal.signal(signal.SIGINT, _signal_handler)
except ValueError:
# Can't set signal handlers from a thread
pass
# Only register signal handlers when running standalone (not under gunicorn).
# Gunicorn manages its own SIGINT/SIGTERM handling for graceful shutdown;
# overriding those signals causes KeyboardInterrupt in the wrong context.
def _is_under_gunicorn():
"""Check if we're running inside a gunicorn worker."""
try:
import gunicorn.arbiter # noqa: F401
# If gunicorn is importable AND we were invoked via gunicorn, the
# arbiter will have installed its own signal handlers already.
# Check the current SIGTERM handler — if it's not the default,
# gunicorn (or another manager) owns signals.
current = signal.getsignal(signal.SIGTERM)
return current not in (signal.SIG_DFL, signal.SIG_IGN, None)
except ImportError:
return False
if not _is_under_gunicorn():
try:
signal.signal(signal.SIGTERM, _signal_handler)
signal.signal(signal.SIGINT, _signal_handler)
except ValueError:
# Can't set signal handlers from a thread
pass
def cleanup_stale_processes() -> None:

View File

@@ -347,17 +347,21 @@ def detect_hackrf_devices() -> list[SDRDevice]:
)
# Parse hackrf_info output
# Look for "Serial number:" lines
serial_pattern = r'Serial number:\s*(\S+)'
# Extract board name from "Board ID Number: X (Name)" and serial
from .hackrf import HackRFCommandBuilder
serial_pattern = r'Serial number:\s*(\S+)'
board_pattern = r'Board ID Number:\s*\d+\s*\(([^)]+)\)'
serials_found = re.findall(serial_pattern, result.stdout)
boards_found = re.findall(board_pattern, result.stdout)
for i, serial in enumerate(serials_found):
board_name = boards_found[i] if i < len(boards_found) else 'HackRF'
devices.append(SDRDevice(
sdr_type=SDRType.HACKRF,
index=i,
name=f'HackRF One',
name=board_name,
serial=serial,
driver='hackrf',
capabilities=HackRFCommandBuilder.CAPABILITIES
@@ -365,10 +369,12 @@ def detect_hackrf_devices() -> list[SDRDevice]:
# Fallback: check if any HackRF found without serial
if not devices and 'Found HackRF' in result.stdout:
board_match = re.search(board_pattern, result.stdout)
board_name = board_match.group(1) if board_match else 'HackRF'
devices.append(SDRDevice(
sdr_type=SDRType.HACKRF,
index=0,
name='HackRF One',
name=board_name,
serial='Unknown',
driver='hackrf',
capabilities=HackRFCommandBuilder.CAPABILITIES