mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Merge branch 'smittix:main' into main
This commit is contained in:
38
CHANGELOG.md
38
CHANGELOG.md
@@ -2,6 +2,44 @@
|
|||||||
|
|
||||||
All notable changes to iNTERCEPT will be documented in this file.
|
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
|
## [2.22.3] - 2026-02-23
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
18
CLAUDE.md
18
CLAUDE.md
@@ -28,12 +28,11 @@ docker compose --profile basic up -d --build
|
|||||||
# Initial setup (installs dependencies and configures SDR tools)
|
# Initial setup (installs dependencies and configures SDR tools)
|
||||||
./setup.sh
|
./setup.sh
|
||||||
|
|
||||||
# Run the application (requires sudo for SDR/network access)
|
# Run with production server (gunicorn + gevent, handles concurrent SSE/WebSocket)
|
||||||
sudo -E venv/bin/python intercept.py
|
sudo ./start.sh
|
||||||
|
|
||||||
# Or activate venv first
|
# Or for quick local dev (Flask dev server)
|
||||||
source venv/bin/activate
|
sudo -E venv/bin/python intercept.py
|
||||||
sudo -E python intercept.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
@@ -69,8 +68,9 @@ mypy .
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Entry Points
|
### Entry Points
|
||||||
- `intercept.py` - Main entry point script
|
- `start.sh` - Production startup script (gunicorn + gevent auto-detection, CLI flags, HTTPS, fallback to Flask dev server)
|
||||||
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure
|
- `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/)
|
### Route Blueprints (routes/)
|
||||||
Each signal type has its own Flask blueprint:
|
Each signal type has its own Flask blueprint:
|
||||||
@@ -121,7 +121,7 @@ Each signal type has its own Flask blueprint:
|
|||||||
|
|
||||||
### Key Patterns
|
### 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.
|
**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()`
|
- **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
|
### 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)
|
- `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)
|
- `build-multiarch.sh` - Multi-arch build script for amd64 + arm64 (RPi5)
|
||||||
- Data persisted via `./data:/app/data` volume mount
|
- Data persisted via `./data:/app/data` volume mount
|
||||||
|
|||||||
@@ -274,4 +274,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|||||||
CMD curl -sf http://localhost:5050/health || exit 1
|
CMD curl -sf http://localhost:5050/health || exit 1
|
||||||
|
|
||||||
# Run the application
|
# Run the application
|
||||||
CMD ["python", "intercept.py"]
|
CMD ["/bin/bash", "start.sh"]
|
||||||
|
|||||||
70
README.md
70
README.md
@@ -50,47 +50,49 @@ Support the developer of this open-source project
|
|||||||
- **Meshtastic** - LoRa mesh network integration
|
- **Meshtastic** - LoRa mesh network integration
|
||||||
- **Space Weather** - Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL (no SDR required)
|
- **Space Weather** - Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL (no SDR required)
|
||||||
- **Spy Stations** - Number stations and diplomatic HF network database
|
- **Spy Stations** - Number stations and diplomatic HF network database
|
||||||
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
||||||
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CW / Morse Decoder Notes
|
## CW / Morse Decoder Notes
|
||||||
|
|
||||||
Live backend:
|
Live backend:
|
||||||
- Uses `rtl_fm` piped into `multimon-ng` (`MORSE_CW`) for real-time decode.
|
- Uses `rtl_fm` piped into `multimon-ng` (`MORSE_CW`) for real-time decode.
|
||||||
|
|
||||||
Recommended baseline settings:
|
Recommended baseline settings:
|
||||||
- **Tone**: `700 Hz`
|
- **Tone**: `700 Hz`
|
||||||
- **Bandwidth**: `200 Hz` (use `100 Hz` for crowded bands, `400 Hz` for drifting signals)
|
- **Bandwidth**: `200 Hz` (use `100 Hz` for crowded bands, `400 Hz` for drifting signals)
|
||||||
- **Threshold Mode**: `Auto`
|
- **Threshold Mode**: `Auto`
|
||||||
- **WPM Mode**: `Auto`
|
- **WPM Mode**: `Auto`
|
||||||
|
|
||||||
Auto Tone Track behavior:
|
Auto Tone Track behavior:
|
||||||
- Continuously measures nearby tone energy around the configured CW pitch.
|
- 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.
|
- 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.
|
- Use **Hold Tone Lock** to freeze tracking once the desired signal is centered.
|
||||||
|
|
||||||
Troubleshooting (no decode / noisy decode):
|
Troubleshooting (no decode / noisy decode):
|
||||||
- Confirm demod path is **USB/CW-compatible** and frequency is tuned correctly.
|
- 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.
|
- 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.
|
- Match **tone** and **bandwidth** to the actual sidetone/pitch.
|
||||||
- Try **Threshold Auto** first; if needed, switch to manual threshold and recalibrate.
|
- Try **Threshold Auto** first; if needed, switch to manual threshold and recalibrate.
|
||||||
- Use **Reset/Calibrate** after major frequency or band condition changes.
|
- Use **Reset/Calibrate** after major frequency or band condition changes.
|
||||||
- Raise **Minimum Signal Gate** to suppress random noise keying.
|
- Raise **Minimum Signal Gate** to suppress random noise keying.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation / Debian / Ubuntu / MacOS
|
## Installation / Debian / Ubuntu / MacOS
|
||||||
|
|
||||||
**1. Clone and run:**
|
**1. Clone and run:**
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/smittix/intercept.git
|
git clone https://github.com/smittix/intercept.git
|
||||||
cd intercept
|
cd intercept
|
||||||
./setup.sh
|
./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
|
### Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -174,7 +176,7 @@ Set these as environment variables for either local installs or Docker:
|
|||||||
```bash
|
```bash
|
||||||
INTERCEPT_ADSB_AUTO_START=true \
|
INTERCEPT_ADSB_AUTO_START=true \
|
||||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||||
sudo -E venv/bin/python intercept.py
|
sudo ./start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
**Docker example (.env)**
|
**Docker example (.env)**
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"version": "2026-02-15_ae16bb62",
|
"version": "2026-02-22_17194a71",
|
||||||
"downloaded": "2026-02-20T00:29:06.228007Z"
|
"downloaded": "2026-02-27T10:41:04.872620Z"
|
||||||
}
|
}
|
||||||
177
app.py
177
app.py
@@ -55,9 +55,9 @@ app.secret_key = "signals_intelligence_secret" # Required for flash messages
|
|||||||
|
|
||||||
# Set up rate limiting
|
# Set up rate limiting
|
||||||
limiter = Limiter(
|
limiter = Limiter(
|
||||||
key_func=get_remote_address, # Identifies the user by their IP
|
key_func=get_remote_address,
|
||||||
app=app,
|
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)
|
# 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
|
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:
|
def main() -> None:
|
||||||
"""Main entry point."""
|
"""Main entry point."""
|
||||||
import argparse
|
import argparse
|
||||||
@@ -1009,81 +1105,8 @@ def main() -> None:
|
|||||||
print("Running as root - full capabilities enabled")
|
print("Running as root - full capabilities enabled")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Clean up any stale processes from previous runs
|
# Ensure app is initialized (no-op if already done by module-level call)
|
||||||
cleanup_stale_processes()
|
_init_app()
|
||||||
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}")
|
|
||||||
|
|
||||||
# Configure SSL if HTTPS is enabled
|
# Configure SSL if HTTPS is enabled
|
||||||
ssl_context = None
|
ssl_context = None
|
||||||
|
|||||||
18
config.py
18
config.py
@@ -7,10 +7,26 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "2.22.3"
|
VERSION = "2.23.0"
|
||||||
|
|
||||||
# Changelog - latest release notes (shown on welcome screen)
|
# Changelog - latest release notes (shown on welcome screen)
|
||||||
CHANGELOG = [
|
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",
|
"version": "2.22.3",
|
||||||
"date": "February 2026",
|
"date": "February 2026",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# INTERCEPT - Signal Intelligence Platform
|
# INTERCEPT - Signal Intelligence Platform
|
||||||
# Docker Compose configuration for easy deployment
|
# Docker Compose configuration for easy deployment
|
||||||
#
|
#
|
||||||
|
# Uses gunicorn + gevent production server via start.sh (handles concurrent SSE/WebSocket)
|
||||||
|
#
|
||||||
# Basic usage (build locally):
|
# Basic usage (build locally):
|
||||||
# docker compose --profile basic up -d --build
|
# docker compose --profile basic up -d --build
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -100,11 +100,30 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
|
|||||||
- **CSV/JSON export** - export captured messages for offline analysis
|
- **CSV/JSON export** - export captured messages for offline analysis
|
||||||
- **Integrated with ADS-B dashboard** - VDL2 messages linked to aircraft tracking
|
- **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
|
## Listening Post
|
||||||
|
|
||||||
- **Wideband frequency scanning** via rtl_power sweep with SNR filtering
|
- **Wideband frequency scanning** via rtl_power sweep with SNR filtering
|
||||||
- **Real-time audio monitoring** with FM and SSB demodulation
|
- **Real-time audio monitoring** with FM and SSB demodulation
|
||||||
- **Cross-module frequency routing** from scanner to decoders
|
- **Cross-module frequency routing** from scanner to decoders
|
||||||
|
- **Waterfall spectrum display** for visual signal identification
|
||||||
- **Customizable frequency presets** and band bookmarks
|
- **Customizable frequency presets** and band bookmarks
|
||||||
- **Multi-SDR support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay
|
- **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
|
- **Auto-refresh** - 5-minute polling with manual refresh option
|
||||||
- **No SDR required** - Data fetched from NOAA SWPC, NASA SDO, and HamQSL public APIs
|
- **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
|
## Satellite Tracking
|
||||||
|
|
||||||
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
- **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
|
### Wireless Sweep Features
|
||||||
- **BLE scanning** with manufacturer data detection (AirTags, Tile, SmartTags, ESP32)
|
- **BLE scanning** with manufacturer data detection (AirTags, Tile, SmartTags, ESP32)
|
||||||
- **WiFi scanning** for rogue APs, hidden SSIDs, camera devices
|
- **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
|
- **Cross-protocol correlation** - links devices across BLE/WiFi/RF
|
||||||
- **Baseline comparison** - detect new/unknown devices vs known environment
|
- **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
|
- **Redundancy** - Multiple nodes for reliable coverage
|
||||||
- **Triangulation** - Use multiple GPS-enabled agents for signal location
|
- **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
|
## User Interface
|
||||||
|
|
||||||
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
- **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
|
## General
|
||||||
|
|
||||||
- **Web-based interface** - no desktop app needed
|
- **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)
|
- **Live message streaming** via Server-Sent Events (SSE)
|
||||||
- **Audio alerts** with mute toggle
|
- **Audio alerts** with mute toggle
|
||||||
- **Message export** to CSV/JSON
|
- **Message export** to CSV/JSON
|
||||||
- **Signal activity meter** and waterfall display
|
- **Signal activity meter** and waterfall display
|
||||||
- **Message logging** to file with timestamps
|
- **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
|
- **Automatic device detection** across all supported hardware
|
||||||
- **Hardware-specific validation** - frequency/gain ranges per device type
|
- **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**
|
- **Configurable gain and PPM correction**
|
||||||
- **Device intelligence** dashboard with tracking
|
- **Device intelligence** dashboard with tracking
|
||||||
- **GPS dongle support** - USB GPS receivers for precise observer location
|
- **GPS dongle support** - USB GPS receivers for precise observer location
|
||||||
|
|||||||
@@ -259,10 +259,13 @@ pip install -r requirements.txt
|
|||||||
After installation:
|
After installation:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo -E venv/bin/python intercept.py
|
sudo ./start.sh
|
||||||
|
|
||||||
# Custom port
|
# 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.
|
Open **http://localhost:5050** in your browser.
|
||||||
|
|||||||
@@ -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 -
|
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
|
```bash
|
||||||
export INTERCEPT_HOST=127.0.0.1
|
sudo ./start.sh -H 127.0.0.1
|
||||||
python intercept.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. The application has no authentication mechanism.
|
3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. The application has no authentication mechanism.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ sudo apt install python3-flask python3-requests python3-serial python3-skyfield
|
|||||||
# Then create venv with system packages
|
# Then create venv with system packages
|
||||||
python3 -m venv --system-site-packages venv
|
python3 -m venv --system-site-packages venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
sudo venv/bin/python intercept.py
|
sudo ./start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### "error: externally-managed-environment" (pip blocked)
|
### "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
|
python3.11 -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
sudo venv/bin/python intercept.py
|
sudo ./start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Alternative: Use the setup script
|
### 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:
|
Run INTERCEPT with sudo:
|
||||||
```bash
|
```bash
|
||||||
sudo -E venv/bin/python intercept.py
|
sudo ./start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Interface not found after enabling monitor mode
|
### Interface not found after enabling monitor mode
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ Set the following environment variables (Docker recommended):
|
|||||||
```bash
|
```bash
|
||||||
INTERCEPT_ADSB_AUTO_START=true \
|
INTERCEPT_ADSB_AUTO_START=true \
|
||||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||||
sudo -E venv/bin/python intercept.py
|
sudo ./start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
**Docker example (.env)**
|
**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_LOG_LEVEL` | `WARNING` | Log level (DEBUG, INFO, WARNING, ERROR) |
|
||||||
| `INTERCEPT_DEFAULT_GAIN` | `40` | Default RTL-SDR gain |
|
| `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
|
## 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
|
python3 intercept.py --help
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="hero-stats">
|
<div class="hero-stats">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<span class="stat-value">25+</span>
|
<span class="stat-value">30+</span>
|
||||||
<span class="stat-label">Modes</span>
|
<span class="stat-label">Modes</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
@@ -92,6 +92,11 @@
|
|||||||
<h3>Listening Post</h3>
|
<h3>Listening Post</h3>
|
||||||
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
|
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
|
||||||
</div>
|
</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-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>
|
<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>
|
<h3>WebSDR</h3>
|
||||||
@@ -152,11 +157,21 @@
|
|||||||
<h3>HF SSTV</h3>
|
<h3>HF SSTV</h3>
|
||||||
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
|
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
|
||||||
</div>
|
</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-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>
|
<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>
|
<h3>GPS Tracking</h3>
|
||||||
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
|
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
|
||||||
</div>
|
</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-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>
|
<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>
|
<h3>Space Weather</h3>
|
||||||
@@ -197,6 +212,11 @@
|
|||||||
<h3>Offline Mode</h3>
|
<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>
|
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<button class="carousel-arrow carousel-arrow-right" aria-label="Scroll right">›</button>
|
<button class="carousel-arrow carousel-arrow-right" aria-label="Scroll right">›</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -311,7 +331,7 @@
|
|||||||
<pre><code>git clone https://github.com/smittix/intercept.git
|
<pre><code>git clone https://github.com/smittix/intercept.git
|
||||||
cd intercept
|
cd intercept
|
||||||
./setup.sh
|
./setup.sh
|
||||||
sudo -E venv/bin/python intercept.py</code></pre>
|
sudo ./start.sh</code></pre>
|
||||||
</div>
|
</div>
|
||||||
<p class="install-note">Requires Python 3.9+ and RTL-SDR drivers</p>
|
<p class="install-note">Requires Python 3.9+ and RTL-SDR drivers</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "intercept"
|
name = "intercept"
|
||||||
version = "2.22.3"
|
version = "2.23.0"
|
||||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
@@ -47,3 +47,7 @@ websocket-client>=1.6.0
|
|||||||
|
|
||||||
# System health monitoring (optional - graceful fallback if unavailable)
|
# System health monitoring (optional - graceful fallback if unavailable)
|
||||||
psutil>=5.9.0
|
psutil>=5.9.0
|
||||||
|
|
||||||
|
# Production WSGI server (optional - falls back to Flask dev server)
|
||||||
|
gunicorn>=21.2.0
|
||||||
|
gevent>=23.9.0
|
||||||
|
|||||||
@@ -22,7 +22,13 @@ from flask import Blueprint, jsonify, request, Response
|
|||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import sensor_logger as logger
|
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.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
@@ -1689,6 +1695,10 @@ def start_aprs() -> Response:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
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()
|
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||||
try:
|
try:
|
||||||
sdr_type = SDRType(sdr_type_str)
|
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}.'
|
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
# Reserve SDR device to prevent conflicts with other modes
|
# Reserve SDR device to prevent conflicts (skip for remote rtl_tcp)
|
||||||
error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str)
|
if not rtl_tcp_host:
|
||||||
if error:
|
error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str)
|
||||||
return jsonify({
|
if error:
|
||||||
'status': 'error',
|
return jsonify({
|
||||||
'error_type': 'DEVICE_BUSY',
|
'status': 'error',
|
||||||
'message': error
|
'error_type': 'DEVICE_BUSY',
|
||||||
}), 409
|
'message': error
|
||||||
aprs_active_device = device
|
}), 409
|
||||||
aprs_active_sdr_type = sdr_type_str
|
aprs_active_device = device
|
||||||
|
aprs_active_sdr_type = sdr_type_str
|
||||||
|
|
||||||
# Get frequency for region
|
# Get frequency for region
|
||||||
region = data.get('region', 'north_america')
|
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.
|
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
|
||||||
try:
|
try:
|
||||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
if rtl_tcp_host:
|
||||||
builder = SDRFactory.get_builder(sdr_type)
|
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(
|
rtl_cmd = builder.build_fm_demod_command(
|
||||||
device=sdr_device,
|
device=sdr_device,
|
||||||
frequency_mhz=float(frequency),
|
frequency_mhz=float(frequency),
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ def start_scan():
|
|||||||
# Check if already scanning
|
# Check if already scanning
|
||||||
if scanner.is_scanning:
|
if scanner.is_scanning:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'already_running',
|
'status': 'already_scanning',
|
||||||
'scan_status': scanner.get_status().to_dict()
|
'scan_status': scanner.get_status().to_dict()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,12 @@ from utils.database import (
|
|||||||
from utils.dsc.parser import parse_dsc_message
|
from utils.dsc.parser import parse_dsc_message
|
||||||
from utils.sse import sse_stream_fanout
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
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.sdr import SDRFactory, SDRType
|
||||||
from utils.dependencies import get_tool_path
|
from utils.dependencies import get_tool_path
|
||||||
from utils.process import register_process, unregister_process
|
from utils.process import register_process, unregister_process
|
||||||
@@ -336,19 +341,29 @@ def start_decoding() -> Response:
|
|||||||
# Get SDR type from request
|
# Get SDR type from request
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
|
||||||
# Check if device is available using centralized registry
|
# Check for rtl_tcp (remote SDR) connection
|
||||||
global dsc_active_device, dsc_active_sdr_type
|
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||||
device_int = int(device)
|
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||||
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
|
try:
|
||||||
dsc_active_sdr_type = sdr_type_str
|
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
|
# Clear queue
|
||||||
while not app_module.dsc_queue.empty():
|
while not app_module.dsc_queue.empty():
|
||||||
@@ -357,22 +372,32 @@ def start_decoding() -> Response:
|
|||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Build rtl_fm command
|
# Build rtl_fm command via SDR abstraction layer
|
||||||
rtl_fm_path = tools['rtl_fm']['path']
|
|
||||||
decoder_path = tools['dsc_decoder']['path']
|
decoder_path = tools['dsc_decoder']['path']
|
||||||
|
|
||||||
# rtl_fm command for DSC decoding
|
if rtl_tcp_host:
|
||||||
# DSC uses narrow FM at 156.525 MHz with 48kHz sample rate
|
try:
|
||||||
rtl_cmd = [
|
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||||
rtl_fm_path,
|
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||||
'-f', f'{DSC_VHF_FREQUENCY_MHZ}M',
|
except ValueError as e:
|
||||||
'-s', str(DSC_SAMPLE_RATE),
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
'-d', str(device),
|
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||||
'-g', str(gain),
|
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||||
'-M', 'fm', # FM demodulation
|
else:
|
||||||
'-l', '0', # No squelch for DSC
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=int(device))
|
||||||
'-E', 'dc' # DC blocking filter
|
|
||||||
]
|
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 command
|
||||||
decoder_cmd = [decoder_path]
|
decoder_cmd = [decoder_path]
|
||||||
|
|||||||
113
routes/morse.py
113
routes/morse.py
@@ -28,6 +28,8 @@ from utils.validation import (
|
|||||||
validate_frequency,
|
validate_frequency,
|
||||||
validate_gain,
|
validate_gain,
|
||||||
validate_ppm,
|
validate_ppm,
|
||||||
|
validate_rtl_tcp_host,
|
||||||
|
validate_rtl_tcp_port,
|
||||||
)
|
)
|
||||||
|
|
||||||
morse_bp = Blueprint('morse', __name__)
|
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
|
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]:
|
def _snapshot_live_resources() -> list[str]:
|
||||||
alive: list[str] = []
|
alive: list[str] = []
|
||||||
if morse_decoder_worker and morse_decoder_worker.is_alive():
|
if morse_decoder_worker and morse_decoder_worker.is_alive():
|
||||||
@@ -238,8 +248,15 @@ def start_morse() -> Response:
|
|||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
|
# Validate detect_mode first — it determines frequency limits.
|
||||||
try:
|
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'))
|
gain = validate_gain(data.get('gain', '0'))
|
||||||
ppm = validate_ppm(data.get('ppm', '0'))
|
ppm = validate_ppm(data.get('ppm', '0'))
|
||||||
device = validate_device_index(data.get('device', '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')
|
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:
|
with app_module.morse_lock:
|
||||||
if morse_state in {MORSE_STARTING, MORSE_RUNNING, MORSE_STOPPING}:
|
if morse_state in {MORSE_STARTING, MORSE_RUNNING, MORSE_STOPPING}:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -272,24 +293,34 @@ def start_morse() -> Response:
|
|||||||
'state': morse_state,
|
'state': morse_state,
|
||||||
}), 409
|
}), 409
|
||||||
|
|
||||||
device_int = int(device)
|
# Reserve SDR device (skip for remote rtl_tcp)
|
||||||
error = app_module.claim_sdr_device(device_int, 'morse', sdr_type_str)
|
if not rtl_tcp_host:
|
||||||
if error:
|
device_int = int(device)
|
||||||
return jsonify({
|
error = app_module.claim_sdr_device(device_int, 'morse', sdr_type_str)
|
||||||
'status': 'error',
|
if error:
|
||||||
'error_type': 'DEVICE_BUSY',
|
return jsonify({
|
||||||
'message': error,
|
'status': 'error',
|
||||||
}), 409
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error,
|
||||||
|
}), 409
|
||||||
|
|
||||||
morse_active_device = device_int
|
morse_active_device = device_int
|
||||||
morse_active_sdr_type = sdr_type_str
|
morse_active_sdr_type = sdr_type_str
|
||||||
morse_last_error = ''
|
morse_last_error = ''
|
||||||
morse_session_id += 1
|
morse_session_id += 1
|
||||||
|
|
||||||
_drain_queue(app_module.morse_queue)
|
_drain_queue(app_module.morse_queue)
|
||||||
_set_state(MORSE_STARTING, 'Starting decoder...')
|
_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)
|
bias_t = _bool_value(data.get('bias_t', False), False)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -297,23 +328,35 @@ def start_morse() -> Response:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
sdr_type = SDRType.RTL_SDR
|
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)
|
requested_device_index = int(device)
|
||||||
active_device_index = requested_device_index
|
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]] = {}
|
device_catalog: dict[int, dict[str, str]] = {}
|
||||||
candidate_device_indices: list[int] = [requested_device_index]
|
candidate_device_indices: list[int] = [requested_device_index]
|
||||||
with contextlib.suppress(Exception):
|
if not network_sdr_device:
|
||||||
detected_devices = SDRFactory.detect_devices()
|
with contextlib.suppress(Exception):
|
||||||
same_type_devices = [d for d in detected_devices if d.sdr_type == sdr_type]
|
detected_devices = SDRFactory.detect_devices()
|
||||||
for d in same_type_devices:
|
same_type_devices = [d for d in detected_devices if d.sdr_type == sdr_type]
|
||||||
device_catalog[d.index] = {
|
for d in same_type_devices:
|
||||||
'name': str(d.name or f'SDR {d.index}'),
|
device_catalog[d.index] = {
|
||||||
'serial': str(d.serial or 'Unknown'),
|
'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:
|
for d in sorted(same_type_devices, key=lambda dev: dev.index):
|
||||||
candidate_device_indices.append(d.index)
|
if d.index not in candidate_device_indices:
|
||||||
|
candidate_device_indices.append(d.index)
|
||||||
|
|
||||||
def _device_label(device_index: int) -> str:
|
def _device_label(device_index: int) -> str:
|
||||||
meta = device_catalog.get(device_index, {})
|
meta = device_catalog.get(device_index, {})
|
||||||
@@ -322,15 +365,19 @@ def start_morse() -> Response:
|
|||||||
return f'device {device_index} ({name}, SN: {serial})'
|
return f'device {device_index} ({name}, SN: {serial})'
|
||||||
|
|
||||||
def _build_rtl_cmd(device_index: int, direct_sampling_mode: int | None) -> list[str]:
|
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))
|
# Envelope mode tunes directly to center freq (no tone offset).
|
||||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_index)
|
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] = {
|
fm_kwargs: dict[str, Any] = {
|
||||||
'device': sdr_device,
|
'device': sdr_device,
|
||||||
'frequency_mhz': tuned_frequency_mhz,
|
'frequency_mhz': tuned_frequency_mhz,
|
||||||
'sample_rate': sample_rate,
|
'sample_rate': sample_rate,
|
||||||
'gain': float(gain) if gain and gain != '0' else None,
|
'gain': float(gain) if gain and gain != '0' else None,
|
||||||
'ppm': int(ppm) if ppm and ppm != '0' else None,
|
'ppm': int(ppm) if ppm and ppm != '0' else None,
|
||||||
'modulation': 'usb',
|
'modulation': modulation,
|
||||||
'bias_t': bias_t,
|
'bias_t': bias_t,
|
||||||
}
|
}
|
||||||
if direct_sampling_mode in (1, 2):
|
if direct_sampling_mode in (1, 2):
|
||||||
@@ -342,13 +389,19 @@ def start_morse() -> Response:
|
|||||||
cmd.append('-')
|
cmd.append('-')
|
||||||
return cmd
|
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]
|
direct_sampling_attempts: list[int | None] = [2, 1, None] if can_try_direct_sampling else [None]
|
||||||
|
|
||||||
runtime_config: dict[str, Any] = {
|
runtime_config: dict[str, Any] = {
|
||||||
'sample_rate': sample_rate,
|
'sample_rate': sample_rate,
|
||||||
|
'detect_mode': detect_mode,
|
||||||
|
'modulation': modulation,
|
||||||
'rf_frequency_mhz': float(freq),
|
'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,
|
'tone_freq': tone_freq,
|
||||||
'wpm': wpm,
|
'wpm': wpm,
|
||||||
'bandwidth_hz': bandwidth_hz,
|
'bandwidth_hz': bandwidth_hz,
|
||||||
@@ -663,6 +716,8 @@ def start_morse() -> Response:
|
|||||||
'status': 'started',
|
'status': 'started',
|
||||||
'state': MORSE_RUNNING,
|
'state': MORSE_RUNNING,
|
||||||
'command': full_cmd,
|
'command': full_cmd,
|
||||||
|
'detect_mode': detect_mode,
|
||||||
|
'modulation': modulation,
|
||||||
'tone_freq': tone_freq,
|
'tone_freq': tone_freq,
|
||||||
'wpm': wpm,
|
'wpm': wpm,
|
||||||
'config': runtime_config,
|
'config': runtime_config,
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import os
|
|||||||
import queue
|
import queue
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
import sys
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -29,10 +29,16 @@ from utils.constants import (
|
|||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
SSE_QUEUE_TIMEOUT,
|
SSE_QUEUE_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
from utils.gps import is_gpsd_running
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.sse import sse_stream_fanout
|
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')
|
logger = get_logger('intercept.radiosonde')
|
||||||
|
|
||||||
@@ -83,6 +89,10 @@ def generate_station_cfg(
|
|||||||
ppm: int = 0,
|
ppm: int = 0,
|
||||||
bias_t: bool = False,
|
bias_t: bool = False,
|
||||||
udp_port: int = RADIOSONDE_UDP_PORT,
|
udp_port: int = RADIOSONDE_UDP_PORT,
|
||||||
|
latitude: float = 0.0,
|
||||||
|
longitude: float = 0.0,
|
||||||
|
station_alt: float = 0.0,
|
||||||
|
gpsd_enabled: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate a station.cfg for radiosonde_auto_rx and return the file path."""
|
"""Generate a station.cfg for radiosonde_auto_rx and return the file path."""
|
||||||
cfg_dir = os.path.abspath(os.path.join('data', 'radiosonde'))
|
cfg_dir = os.path.abspath(os.path.join('data', 'radiosonde'))
|
||||||
@@ -116,10 +126,10 @@ always_scan = []
|
|||||||
always_decode = []
|
always_decode = []
|
||||||
|
|
||||||
[location]
|
[location]
|
||||||
station_lat = 0.0
|
station_lat = {latitude}
|
||||||
station_lon = 0.0
|
station_lon = {longitude}
|
||||||
station_alt = 0.0
|
station_alt = {station_alt}
|
||||||
gpsd_enabled = False
|
gpsd_enabled = {str(gpsd_enabled)}
|
||||||
gpsd_host = localhost
|
gpsd_host = localhost
|
||||||
gpsd_port = 2947
|
gpsd_port = 2947
|
||||||
|
|
||||||
@@ -471,6 +481,20 @@ def start_radiosonde():
|
|||||||
bias_t = data.get('bias_t', False)
|
bias_t = data.get('bias_t', False)
|
||||||
ppm = int(data.get('ppm', 0))
|
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
|
# Find auto_rx
|
||||||
auto_rx_path = find_auto_rx()
|
auto_rx_path = find_auto_rx()
|
||||||
if not auto_rx_path:
|
if not auto_rx_path:
|
||||||
@@ -515,6 +539,9 @@ def start_radiosonde():
|
|||||||
device_index=device_int,
|
device_index=device_int,
|
||||||
ppm=ppm,
|
ppm=ppm,
|
||||||
bias_t=bias_t,
|
bias_t=bias_t,
|
||||||
|
latitude=latitude,
|
||||||
|
longitude=longitude,
|
||||||
|
gpsd_enabled=gpsd_enabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build command - auto_rx -c expects a file path, not a directory
|
# Build command - auto_rx -c expects a file path, not a directory
|
||||||
|
|||||||
6
setup.sh
6
setup.sh
@@ -337,7 +337,8 @@ install_python_deps() {
|
|||||||
info "Installing optional packages..."
|
info "Installing optional packages..."
|
||||||
for pkg in "numpy>=1.24.0" "scipy>=1.10.0" "Pillow>=9.0.0" "skyfield>=1.45" \
|
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" \
|
"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%%>=*}"
|
pkg_name="${pkg%%>=*}"
|
||||||
if ! $PIP install "$pkg" 2>/dev/null; then
|
if ! $PIP install "$pkg" 2>/dev/null; then
|
||||||
warn "${pkg_name} failed to install (optional - related features may be unavailable)"
|
warn "${pkg_name} failed to install (optional - related features may be unavailable)"
|
||||||
@@ -1590,6 +1591,9 @@ final_summary_and_hard_fail() {
|
|||||||
echo "============================================"
|
echo "============================================"
|
||||||
echo
|
echo
|
||||||
echo "To start INTERCEPT:"
|
echo "To start INTERCEPT:"
|
||||||
|
echo " sudo ./start.sh"
|
||||||
|
echo
|
||||||
|
echo "Or for quick local dev:"
|
||||||
echo " sudo -E venv/bin/python intercept.py"
|
echo " sudo -E venv/bin/python intercept.py"
|
||||||
echo
|
echo
|
||||||
echo "Then open http://localhost:5050 in your browser"
|
echo "Then open http://localhost:5050 in your browser"
|
||||||
|
|||||||
161
start.sh
Executable file
161
start.sh
Executable 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
|
||||||
@@ -1784,6 +1784,13 @@ const BluetoothMode = (function() {
|
|||||||
*/
|
*/
|
||||||
function destroy() {
|
function destroy() {
|
||||||
stopEventStream();
|
stopEventStream();
|
||||||
|
devices.clear();
|
||||||
|
pendingDeviceIds.clear();
|
||||||
|
if (deviceContainer) {
|
||||||
|
deviceContainer.innerHTML = '';
|
||||||
|
}
|
||||||
|
const countEl = document.getElementById('btDeviceListCount');
|
||||||
|
if (countEl) countEl.textContent = '0';
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -96,13 +96,14 @@ var MorseMode = (function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function collectConfig() {
|
function collectConfig() {
|
||||||
return {
|
var config = {
|
||||||
frequency: (el('morseFrequency') && el('morseFrequency').value) || '14.060',
|
frequency: (el('morseFrequency') && el('morseFrequency').value) || '14.060',
|
||||||
gain: (el('morseGain') && el('morseGain').value) || '40',
|
gain: (el('morseGain') && el('morseGain').value) || '40',
|
||||||
ppm: (el('morsePPM') && el('morsePPM').value) || '0',
|
ppm: (el('morsePPM') && el('morsePPM').value) || '0',
|
||||||
device: (el('deviceSelect') && el('deviceSelect').value) || '0',
|
device: (el('deviceSelect') && el('deviceSelect').value) || '0',
|
||||||
sdr_type: (el('sdrTypeSelect') && el('sdrTypeSelect').value) || 'rtlsdr',
|
sdr_type: (el('sdrTypeSelect') && el('sdrTypeSelect').value) || 'rtlsdr',
|
||||||
bias_t: (typeof getBiasTEnabled === 'function') ? getBiasTEnabled() : false,
|
bias_t: (typeof getBiasTEnabled === 'function') ? getBiasTEnabled() : false,
|
||||||
|
detect_mode: (el('morseDetectMode') && el('morseDetectMode').value) || 'goertzel',
|
||||||
tone_freq: (el('morseToneFreq') && el('morseToneFreq').value) || '700',
|
tone_freq: (el('morseToneFreq') && el('morseToneFreq').value) || '700',
|
||||||
bandwidth_hz: (el('morseBandwidth') && el('morseBandwidth').value) || '200',
|
bandwidth_hz: (el('morseBandwidth') && el('morseBandwidth').value) || '200',
|
||||||
auto_tone_track: !!(el('morseAutoToneTrack') && el('morseAutoToneTrack').checked),
|
auto_tone_track: !!(el('morseAutoToneTrack') && el('morseAutoToneTrack').checked),
|
||||||
@@ -116,6 +117,17 @@ var MorseMode = (function () {
|
|||||||
wpm: (el('morseWpm') && el('morseWpm').value) || '15',
|
wpm: (el('morseWpm') && el('morseWpm').value) || '15',
|
||||||
wpm_lock: !!(el('morseWpmLock') && el('morseWpmLock').checked),
|
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() {
|
function persistSettings() {
|
||||||
@@ -124,6 +136,7 @@ var MorseMode = (function () {
|
|||||||
frequency: (el('morseFrequency') && el('morseFrequency').value) || '14.060',
|
frequency: (el('morseFrequency') && el('morseFrequency').value) || '14.060',
|
||||||
gain: (el('morseGain') && el('morseGain').value) || '40',
|
gain: (el('morseGain') && el('morseGain').value) || '40',
|
||||||
ppm: (el('morsePPM') && el('morsePPM').value) || '0',
|
ppm: (el('morsePPM') && el('morsePPM').value) || '0',
|
||||||
|
detect_mode: (el('morseDetectMode') && el('morseDetectMode').value) || 'goertzel',
|
||||||
tone_freq: (el('morseToneFreq') && el('morseToneFreq').value) || '700',
|
tone_freq: (el('morseToneFreq') && el('morseToneFreq').value) || '700',
|
||||||
bandwidth_hz: (el('morseBandwidth') && el('morseBandwidth').value) || '200',
|
bandwidth_hz: (el('morseBandwidth') && el('morseBandwidth').value) || '200',
|
||||||
auto_tone_track: !!(el('morseAutoToneTrack') && el('morseAutoToneTrack').checked),
|
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('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 (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');
|
updateToneLabel((el('morseToneFreq') && el('morseToneFreq').value) || '700');
|
||||||
updateWpmLabel((el('morseWpm') && el('morseWpm').value) || '15');
|
updateWpmLabel((el('morseWpm') && el('morseWpm').value) || '15');
|
||||||
onThresholdModeChange();
|
onThresholdModeChange();
|
||||||
@@ -198,10 +214,11 @@ var MorseMode = (function () {
|
|||||||
state.controlsBound = true;
|
state.controlsBound = true;
|
||||||
|
|
||||||
var ids = [
|
var ids = [
|
||||||
'morseFrequency', 'morseGain', 'morsePPM', 'morseToneFreq', 'morseBandwidth',
|
'morseFrequency', 'morseGain', 'morsePPM', 'morseDetectMode', 'morseToneFreq',
|
||||||
'morseAutoToneTrack', 'morseToneLock', 'morseThresholdMode', 'morseManualThreshold',
|
'morseBandwidth', 'morseAutoToneTrack', 'morseToneLock', 'morseThresholdMode',
|
||||||
'morseThresholdMultiplier', 'morseThresholdOffset', 'morseSignalGate',
|
'morseManualThreshold', 'morseThresholdMultiplier', 'morseThresholdOffset',
|
||||||
'morseWpmMode', 'morseWpm', 'morseWpmLock', 'morseShowRaw', 'morseShowDiag'
|
'morseSignalGate', 'morseWpmMode', 'morseWpm', 'morseWpmLock',
|
||||||
|
'morseShowRaw', 'morseShowDiag'
|
||||||
];
|
];
|
||||||
|
|
||||||
ids.forEach(function (id) {
|
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 {
|
return {
|
||||||
init: init,
|
init: init,
|
||||||
destroy: destroy,
|
destroy: destroy,
|
||||||
start: start,
|
start: start,
|
||||||
stop: stop,
|
stop: stop,
|
||||||
setFreq: setFreq,
|
setFreq: setFreq,
|
||||||
|
setDetectMode: setDetectMode,
|
||||||
exportTxt: exportTxt,
|
exportTxt: exportTxt,
|
||||||
exportCsv: exportCsv,
|
exportCsv: exportCsv,
|
||||||
copyToClipboard: copyToClipboard,
|
copyToClipboard: copyToClipboard,
|
||||||
|
|||||||
@@ -1179,6 +1179,19 @@
|
|||||||
const isAgentMode = typeof aisCurrentAgent !== 'undefined' && aisCurrentAgent !== 'local';
|
const isAgentMode = typeof aisCurrentAgent !== 'undefined' && aisCurrentAgent !== 'local';
|
||||||
dscCurrentAgent = isAgentMode ? aisCurrentAgent : null;
|
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
|
// Determine endpoint based on agent mode
|
||||||
const endpoint = isAgentMode
|
const endpoint = isAgentMode
|
||||||
? `/controller/agents/${aisCurrentAgent}/dsc/start`
|
? `/controller/agents/${aisCurrentAgent}/dsc/start`
|
||||||
@@ -1187,7 +1200,7 @@
|
|||||||
fetch(endpoint, {
|
fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ device, gain })
|
body: JSON.stringify(requestBody)
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
|||||||
@@ -4296,6 +4296,10 @@
|
|||||||
const sensorTimelineContainer = document.getElementById('sensorTimelineContainer');
|
const sensorTimelineContainer = document.getElementById('sensorTimelineContainer');
|
||||||
if (pagerTimelineContainer) pagerTimelineContainer.style.display = mode === 'pager' ? 'block' : 'none';
|
if (pagerTimelineContainer) pagerTimelineContainer.style.display = mode === 'pager' ? 'block' : 'none';
|
||||||
if (sensorTimelineContainer) sensorTimelineContainer.style.display = mode === 'sensor' ? '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 morseScopePanel = document.getElementById('morseScopePanel');
|
||||||
const morseOutputPanel = document.getElementById('morseOutputPanel');
|
const morseOutputPanel = document.getElementById('morseOutputPanel');
|
||||||
if (morseScopePanel && mode !== 'morse') morseScopePanel.style.display = 'none';
|
if (morseScopePanel && mode !== 'morse') morseScopePanel.style.display = 'none';
|
||||||
@@ -9793,6 +9797,10 @@
|
|||||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
aprsCurrentAgent = isAgentMode ? currentAgent : null;
|
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
|
// Build request body
|
||||||
const requestBody = {
|
const requestBody = {
|
||||||
region,
|
region,
|
||||||
@@ -9801,6 +9809,12 @@
|
|||||||
sdr_type: sdrType
|
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
|
// Add custom frequency if selected
|
||||||
if (region === 'custom') {
|
if (region === 'custom') {
|
||||||
const customFreq = document.getElementById('aprsStripCustomFreq').value;
|
const customFreq = document.getElementById('aprsStripCustomFreq').value;
|
||||||
|
|||||||
@@ -3,21 +3,39 @@
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<h3>CW/Morse Decoder</h3>
|
<h3>CW/Morse Decoder</h3>
|
||||||
<p class="info-text morse-mode-help">
|
<p class="info-text morse-mode-help">
|
||||||
Decode CW (continuous wave) Morse with USB demod + Goertzel tone detection.
|
Decode CW (continuous wave) Morse code. Supports HF amateur bands (USB + Goertzel tone
|
||||||
Start with 700 Hz tone and 200 Hz bandwidth.
|
detection) and ISM/UHF OOK signals (AM + envelope detection).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div class="section">
|
||||||
<h3>Frequency</h3>
|
<h3>Frequency</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Frequency (MHz)</label>
|
<label>Frequency (MHz)</label>
|
||||||
<input type="number" id="morseFrequency" value="14.060" step="0.001" min="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>
|
<span class="help-text morse-help-text">Enter CW center frequency in MHz (e.g., 7.030 for 40m).</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Band Presets</label>
|
<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(3.560)">80m</button>
|
||||||
<button class="preset-btn" onclick="MorseMode.setFreq(7.030)">40m</button>
|
<button class="preset-btn" onclick="MorseMode.setFreq(7.030)">40m</button>
|
||||||
<button class="preset-btn" onclick="MorseMode.setFreq(10.116)">30m</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(24.910)">12m</button>
|
||||||
<button class="preset-btn" onclick="MorseMode.setFreq(28.060)">10m</button>
|
<button class="preset-btn" onclick="MorseMode.setFreq(28.060)">10m</button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -42,7 +67,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section" id="morseToneFreqGroup">
|
||||||
<h3>CW Detector</h3>
|
<h3>CW Detector</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Tone Frequency: <span id="morseToneFreqLabel">700</span> Hz</label>
|
<label>Tone Frequency: <span id="morseToneFreqLabel">700</span> Hz</label>
|
||||||
@@ -154,12 +179,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section" id="morseHFNote">
|
||||||
<p class="info-text morse-hf-note">
|
<p class="info-text morse-hf-note">
|
||||||
CW on HF (1-30 MHz) requires an HF-capable SDR path (direct sampling or upconverter)
|
CW on HF (1-30 MHz) requires an HF-capable SDR path (direct sampling or upconverter)
|
||||||
and an appropriate antenna.
|
and an appropriate antenna.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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="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>
|
<button class="stop-btn" id="morseStopBtn" onclick="MorseMode.stop()" style="display: none;">Stop Decoder</button>
|
||||||
|
|||||||
@@ -148,6 +148,8 @@
|
|||||||
freq_min: freqMin,
|
freq_min: freqMin,
|
||||||
freq_max: freqMax,
|
freq_max: freqMax,
|
||||||
bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false,
|
bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false,
|
||||||
|
latitude: radiosondeStationLocation.lat,
|
||||||
|
longitude: radiosondeStationLocation.lon,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
@@ -230,15 +232,23 @@
|
|||||||
let radiosondeMarkers = new Map();
|
let radiosondeMarkers = new Map();
|
||||||
let radiosondeTracks = new Map();
|
let radiosondeTracks = new Map();
|
||||||
let radiosondeTrackPoints = new Map();
|
let radiosondeTrackPoints = new Map();
|
||||||
|
let radiosondeStationLocation = { lat: 0, lon: 0 };
|
||||||
|
let radiosondeStationMarker = null;
|
||||||
|
|
||||||
function initRadiosondeMap() {
|
function initRadiosondeMap() {
|
||||||
if (radiosondeMap) return;
|
if (radiosondeMap) return;
|
||||||
const container = document.getElementById('radiosondeMapContainer');
|
const container = document.getElementById('radiosondeMapContainer');
|
||||||
if (!container) return;
|
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', {
|
radiosondeMap = L.map('radiosondeMapContainer', {
|
||||||
center: [40, -95],
|
center: hasLocation ? [radiosondeStationLocation.lat, radiosondeStationLocation.lon] : [40, -95],
|
||||||
zoom: 4,
|
zoom: hasLocation ? 7 : 4,
|
||||||
zoomControl: true,
|
zoomControl: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -246,6 +256,50 @@
|
|||||||
attribution: '© OpenStreetMap © CARTO',
|
attribution: '© OpenStreetMap © CARTO',
|
||||||
maxZoom: 18,
|
maxZoom: 18,
|
||||||
}).addTo(radiosondeMap);
|
}).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) {
|
function updateRadiosondeMap(balloon) {
|
||||||
@@ -284,12 +338,19 @@
|
|||||||
const tempStr = balloon.temp != null ? `${balloon.temp.toFixed(1)} °C` : '--';
|
const tempStr = balloon.temp != null ? `${balloon.temp.toFixed(1)} °C` : '--';
|
||||||
const humStr = balloon.humidity != null ? `${balloon.humidity.toFixed(0)}%` : '--';
|
const humStr = balloon.humidity != null ? `${balloon.humidity.toFixed(0)}%` : '--';
|
||||||
const velStr = balloon.vel_v != null ? `${balloon.vel_v.toFixed(1)} m/s` : '--';
|
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(
|
radiosondeMarkers.get(id).bindPopup(
|
||||||
`<strong>${id}</strong><br>` +
|
`<strong>${id}</strong><br>` +
|
||||||
`Type: ${balloon.sonde_type || '--'}<br>` +
|
`Type: ${balloon.sonde_type || '--'}<br>` +
|
||||||
`Alt: ${altStr}<br>` +
|
`Alt: ${altStr}<br>` +
|
||||||
`Temp: ${tempStr} | Hum: ${humStr}<br>` +
|
`Temp: ${tempStr} | Hum: ${humStr}<br>` +
|
||||||
`Vert: ${velStr}<br>` +
|
`Vert: ${velStr}<br>` +
|
||||||
|
distStr +
|
||||||
(balloon.freq ? `Freq: ${balloon.freq.toFixed(3)} MHz` : '')
|
(balloon.freq ? `Freq: ${balloon.freq.toFixed(3)} MHz` : '')
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -322,6 +383,7 @@
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const sorted = Object.values(radiosondeBalloons).sort((a, b) => (b.alt || 0) - (a.alt || 0));
|
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 => {
|
container.innerHTML = sorted.map(b => {
|
||||||
const alt = b.alt ? `${Math.round(b.alt).toLocaleString()} m` : '--';
|
const alt = b.alt ? `${Math.round(b.alt).toLocaleString()} m` : '--';
|
||||||
const temp = b.temp != null ? `${b.temp.toFixed(1)}°C` : '--';
|
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 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 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` : '--';
|
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 `
|
return `
|
||||||
<div class="radiosonde-card" onclick="radiosondeMap && radiosondeMap.setView([${b.lat || 0}, ${b.lon || 0}], 10)">
|
<div class="radiosonde-card" onclick="radiosondeMap && radiosondeMap.setView([${b.lat || 0}, ${b.lon || 0}], 10)">
|
||||||
<div class="radiosonde-card-header">
|
<div class="radiosonde-card-header">
|
||||||
@@ -360,6 +429,10 @@
|
|||||||
<span class="radiosonde-stat-value">${freq}</span>
|
<span class="radiosonde-stat-value">${freq}</span>
|
||||||
<span class="radiosonde-stat-label">FREQ</span>
|
<span class="radiosonde-stat-label">FREQ</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="radiosonde-stat">
|
||||||
|
<span class="radiosonde-stat-value">${dist}</span>
|
||||||
|
<span class="radiosonde-stat-label">DIST</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -12,20 +12,18 @@ import time
|
|||||||
import wave
|
import wave
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
import routes.morse as morse_routes
|
import routes.morse as morse_routes
|
||||||
from utils.morse import (
|
from utils.morse import (
|
||||||
CHAR_TO_MORSE,
|
CHAR_TO_MORSE,
|
||||||
MORSE_TABLE,
|
MORSE_TABLE,
|
||||||
|
EnvelopeDetector,
|
||||||
GoertzelFilter,
|
GoertzelFilter,
|
||||||
MorseDecoder,
|
MorseDecoder,
|
||||||
decode_morse_wav_file,
|
decode_morse_wav_file,
|
||||||
morse_decoder_thread,
|
morse_decoder_thread,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -133,6 +131,93 @@ class TestToneDetector:
|
|||||||
assert gf.magnitude(on_tone) > gf.magnitude(off_tone) * 3.0
|
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:
|
class TestTimingAndWpmEstimator:
|
||||||
def test_timing_classifier_distinguishes_dit_and_dah(self):
|
def test_timing_classifier_distinguishes_dit_and_dah(self):
|
||||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
|
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
|
||||||
|
|||||||
325
utils/morse.py
325
utils/morse.py
@@ -1,10 +1,12 @@
|
|||||||
"""Morse code (CW) decoding helpers.
|
"""Morse code (CW) decoding helpers with dual detection modes.
|
||||||
|
|
||||||
Signal chain:
|
Supports two signal chains:
|
||||||
- SDR audio from `rtl_fm -M usb` (16-bit LE PCM)
|
goertzel: rtl_fm -M usb -> raw PCM -> Goertzel tone filter -> timing state machine -> characters
|
||||||
- Goertzel tone detection with optional auto-tone tracking
|
envelope: rtl_fm -M am -> raw PCM -> RMS envelope -> timing state machine -> characters
|
||||||
- Adaptive threshold + hysteresis + minimum signal gate
|
|
||||||
- Timing estimator (auto/manual WPM) and Morse symbol decoding
|
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
|
from __future__ import annotations
|
||||||
@@ -80,6 +82,25 @@ class GoertzelFilter:
|
|||||||
return math.sqrt(max(power, 0.0))
|
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:
|
def _goertzel_mag(samples: np.ndarray, target_freq: float, sample_rate: int) -> float:
|
||||||
"""Compute Goertzel magnitude, preferring shared DSP helper."""
|
"""Compute Goertzel magnitude, preferring shared DSP helper."""
|
||||||
if _shared_goertzel_mag is not None:
|
if _shared_goertzel_mag is not None:
|
||||||
@@ -137,10 +158,12 @@ class MorseDecoder:
|
|||||||
wpm_mode: str = 'auto',
|
wpm_mode: str = 'auto',
|
||||||
wpm_lock: bool = False,
|
wpm_lock: bool = False,
|
||||||
min_signal_gate: float = 0.0,
|
min_signal_gate: float = 0.0,
|
||||||
|
detect_mode: str = 'goertzel',
|
||||||
):
|
):
|
||||||
self.sample_rate = int(sample_rate)
|
self.sample_rate = int(sample_rate)
|
||||||
self.tone_freq = float(tone_freq)
|
self.tone_freq = float(tone_freq)
|
||||||
self.wpm = int(wpm)
|
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.bandwidth_hz = int(_clamp(float(bandwidth_hz), 50, 400))
|
||||||
self.auto_tone_track = bool(auto_tone_track)
|
self.auto_tone_track = bool(auto_tone_track)
|
||||||
@@ -163,17 +186,22 @@ class MorseDecoder:
|
|||||||
self._tone_scan_step_hz = 10.0
|
self._tone_scan_step_hz = 10.0
|
||||||
self._tone_scan_interval_blocks = 8
|
self._tone_scan_interval_blocks = 8
|
||||||
|
|
||||||
self._detector = GoertzelFilter(self._active_tone_freq, self.sample_rate, self._block_size)
|
if self.detect_mode == 'envelope':
|
||||||
self._noise_detector_low = GoertzelFilter(
|
self._detector = EnvelopeDetector(self._block_size)
|
||||||
_clamp(self._active_tone_freq - max(150.0, self.bandwidth_hz), 150.0, 2000.0),
|
self._noise_detector_low = None
|
||||||
self.sample_rate,
|
self._noise_detector_high = None
|
||||||
self._block_size,
|
else:
|
||||||
)
|
self._detector = GoertzelFilter(self._active_tone_freq, self.sample_rate, self._block_size)
|
||||||
self._noise_detector_high = GoertzelFilter(
|
self._noise_detector_low = GoertzelFilter(
|
||||||
_clamp(self._active_tone_freq + max(150.0, self.bandwidth_hz), 150.0, 2000.0),
|
_clamp(self._active_tone_freq - max(150.0, self.bandwidth_hz), 150.0, 2000.0),
|
||||||
self.sample_rate,
|
self.sample_rate,
|
||||||
self._block_size,
|
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.
|
# AGC for weak HF/direct-sampling signals.
|
||||||
self._agc_target = 0.22
|
self._agc_target = 0.22
|
||||||
@@ -181,8 +209,14 @@ class MorseDecoder:
|
|||||||
self._agc_alpha = 0.06
|
self._agc_alpha = 0.06
|
||||||
|
|
||||||
# Envelope smoothing.
|
# Envelope smoothing.
|
||||||
self._attack_alpha = 0.55
|
# OOK has clean binary transitions; use symmetric fast alpha.
|
||||||
self._release_alpha = 0.45
|
# 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
|
self._envelope = 0.0
|
||||||
|
|
||||||
# Adaptive threshold model.
|
# Adaptive threshold model.
|
||||||
@@ -203,8 +237,13 @@ class MorseDecoder:
|
|||||||
dit_blocks = max(1.0, dit_sec / self._block_duration)
|
dit_blocks = max(1.0, dit_sec / self._block_duration)
|
||||||
self._dah_threshold = 2.2 * dit_blocks
|
self._dah_threshold = 2.2 * dit_blocks
|
||||||
self._dit_min = 0.38 * dit_blocks
|
self._dit_min = 0.38 * dit_blocks
|
||||||
self._char_gap = 2.6 * dit_blocks
|
if self.detect_mode == 'envelope':
|
||||||
self._word_gap = 6.0 * dit_blocks
|
# 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._dit_observations: deque[float] = deque(maxlen=32)
|
||||||
self._estimated_wpm = float(self.wpm)
|
self._estimated_wpm = float(self.wpm)
|
||||||
|
|
||||||
@@ -236,10 +275,7 @@ class MorseDecoder:
|
|||||||
|
|
||||||
def get_metrics(self) -> dict[str, float | bool]:
|
def get_metrics(self) -> dict[str, float | bool]:
|
||||||
"""Return latest decoder metrics for UI/status messages."""
|
"""Return latest decoder metrics for UI/status messages."""
|
||||||
snr_mult = max(1.15, self.threshold_multiplier * 0.5)
|
metrics: dict[str, Any] = {
|
||||||
snr_on = snr_mult * (1.0 + self._hysteresis)
|
|
||||||
snr_off = snr_mult * (1.0 - self._hysteresis)
|
|
||||||
return {
|
|
||||||
'wpm': float(self._estimated_wpm),
|
'wpm': float(self._estimated_wpm),
|
||||||
'tone_freq': float(self._active_tone_freq),
|
'tone_freq': float(self._active_tone_freq),
|
||||||
'level': float(self._last_level),
|
'level': float(self._last_level),
|
||||||
@@ -247,14 +283,27 @@ class MorseDecoder:
|
|||||||
'threshold': float(self._threshold),
|
'threshold': float(self._threshold),
|
||||||
'tone_on': bool(self._tone_on),
|
'tone_on': bool(self._tone_on),
|
||||||
'dit_ms': float((self._effective_dit_blocks() * self._block_duration) * 1000.0),
|
'dit_ms': float((self._effective_dit_blocks() * self._block_duration) * 1000.0),
|
||||||
'snr': float(self._last_level / max(self._noise_floor, 1e-6)),
|
'detect_mode': self.detect_mode,
|
||||||
'noise_ref': float(self._noise_floor),
|
|
||||||
'snr_on': float(snr_on),
|
|
||||||
'snr_off': float(snr_off),
|
|
||||||
}
|
}
|
||||||
|
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:
|
def _rebuild_detectors(self) -> None:
|
||||||
"""Rebuild target/noise Goertzel filters after tone updates."""
|
"""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)
|
self._detector = GoertzelFilter(self._active_tone_freq, self.sample_rate, self._block_size)
|
||||||
ref_offset = max(150.0, self.bandwidth_hz)
|
ref_offset = max(150.0, self.bandwidth_hz)
|
||||||
self._noise_detector_low = GoertzelFilter(
|
self._noise_detector_low = GoertzelFilter(
|
||||||
@@ -391,93 +440,143 @@ class MorseDecoder:
|
|||||||
self._blocks_processed += 1
|
self._blocks_processed += 1
|
||||||
|
|
||||||
mag = self._detector.magnitude(normalized)
|
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 (
|
if self.detect_mode == 'envelope':
|
||||||
self.auto_tone_track
|
# Envelope mode: direct magnitude threshold, no noise detectors
|
||||||
and not self.tone_lock
|
noise_ref = 0.0
|
||||||
and self._blocks_processed > self._WARMUP_BLOCKS
|
level = float(mag)
|
||||||
and (self._blocks_processed % self._tone_scan_interval_blocks == 0)
|
alpha = self._attack_alpha if level >= self._envelope else self._release_alpha
|
||||||
and self._estimate_tone_frequency(normalized, mag, noise_ref)
|
self._envelope += alpha * (level - self._envelope)
|
||||||
):
|
self._last_level = self._envelope
|
||||||
# Detector changed; refresh magnitudes for this window.
|
self._last_noise_ref = 0.0
|
||||||
mag = self._detector.magnitude(normalized)
|
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_low = self._noise_detector_low.magnitude(normalized)
|
||||||
noise_high = self._noise_detector_high.magnitude(normalized)
|
noise_high = self._noise_detector_high.magnitude(normalized)
|
||||||
noise_ref = max(1e-9, (noise_low + noise_high) * 0.5)
|
noise_ref = max(1e-9, (noise_low + noise_high) * 0.5)
|
||||||
|
|
||||||
level = float(mag)
|
if (
|
||||||
alpha = self._attack_alpha if level >= self._envelope else self._release_alpha
|
self.auto_tone_track
|
||||||
self._envelope += alpha * (level - self._envelope)
|
and not self.tone_lock
|
||||||
self._last_level = self._envelope
|
and self._blocks_processed > self._WARMUP_BLOCKS
|
||||||
self._last_noise_ref = noise_ref
|
and (self._blocks_processed % self._tone_scan_interval_blocks == 0)
|
||||||
amplitudes.append(level)
|
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:
|
level = float(mag)
|
||||||
self._mag_min = min(self._mag_min, level)
|
alpha = self._attack_alpha if level >= self._envelope else self._release_alpha
|
||||||
self._mag_max = max(self._mag_max, level)
|
self._envelope += alpha * (level - self._envelope)
|
||||||
if self._blocks_processed == self._WARMUP_BLOCKS:
|
self._last_level = self._envelope
|
||||||
self._noise_floor = self._mag_min if math.isfinite(self._mag_min) else 0.0
|
self._last_noise_ref = noise_ref
|
||||||
if self._mag_max <= (self._noise_floor * 1.2):
|
amplitudes.append(level)
|
||||||
self._signal_peak = max(self._noise_floor + 0.5, self._noise_floor * 2.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)
|
||||||
|
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:
|
else:
|
||||||
self._signal_peak = max(self._mag_max, self._noise_floor * 1.8)
|
self._signal_peak += settle_alpha * (detector_level - self._signal_peak)
|
||||||
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
|
self._signal_peak = max(self._signal_peak, self._noise_floor * 1.05)
|
||||||
|
|
||||||
if detector_level <= self._threshold:
|
# Blend adjacent-band noise reference into noise floor.
|
||||||
self._noise_floor += settle_alpha * (detector_level - self._noise_floor)
|
self._noise_floor += (settle_alpha * 0.25) * (noise_ref - self._noise_floor)
|
||||||
else:
|
|
||||||
self._signal_peak += settle_alpha * (detector_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)
|
||||||
|
|
||||||
# Always blend adjacent-band noise reference into noise floor.
|
dynamic_span = max(0.0, self._signal_peak - self._noise_floor)
|
||||||
# Adjacent bands track the same AGC gain but exclude the tone,
|
gate_level = self._noise_floor + (self.min_signal_gate * dynamic_span)
|
||||||
# so this prevents noise floor from staying stuck at warmup-era
|
gate_ok = self.min_signal_gate <= 0.0 or detector_level >= gate_level
|
||||||
# low values after AGC converges.
|
|
||||||
self._noise_floor += (settle_alpha * 0.25) * (noise_ref - self._noise_floor)
|
|
||||||
|
|
||||||
if self.threshold_mode == 'manual':
|
# SNR-based tone detection (gain-invariant).
|
||||||
self._threshold = max(0.0, self.manual_threshold)
|
snr = level / max(noise_ref, 1e-6)
|
||||||
else:
|
snr_mult = max(1.15, self.threshold_multiplier * 0.5)
|
||||||
self._threshold = (
|
snr_on = snr_mult * (1.0 + self._hysteresis)
|
||||||
max(0.0, self._noise_floor * self.threshold_multiplier)
|
snr_off = snr_mult * (1.0 - self._hysteresis)
|
||||||
+ self.threshold_offset
|
|
||||||
)
|
|
||||||
self._threshold = max(self._threshold, self._noise_floor + 0.35)
|
|
||||||
|
|
||||||
dynamic_span = max(0.0, self._signal_peak - self._noise_floor)
|
if self._tone_on:
|
||||||
gate_level = self._noise_floor + (self.min_signal_gate * dynamic_span)
|
tone_detected = gate_ok and snr >= snr_off
|
||||||
gate_ok = self.min_signal_gate <= 0.0 or detector_level >= gate_level
|
else:
|
||||||
|
tone_detected = gate_ok and snr >= snr_on
|
||||||
# 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
|
|
||||||
|
|
||||||
dit_blocks = self._effective_dit_blocks()
|
dit_blocks = self._effective_dit_blocks()
|
||||||
self._dah_threshold = 2.2 * dit_blocks
|
self._dah_threshold = 2.2 * dit_blocks
|
||||||
self._dit_min = max(1.0, 0.38 * dit_blocks)
|
self._dit_min = max(1.0, 0.38 * dit_blocks)
|
||||||
self._char_gap = 2.6 * dit_blocks
|
if self.detect_mode == 'envelope':
|
||||||
self._word_gap = 6.0 * dit_blocks
|
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:
|
if tone_detected and not self._tone_on:
|
||||||
# Tone edge up.
|
# Tone edge up.
|
||||||
@@ -548,10 +647,7 @@ class MorseDecoder:
|
|||||||
self._silence_blocks += 1.0
|
self._silence_blocks += 1.0
|
||||||
|
|
||||||
if amplitudes:
|
if amplitudes:
|
||||||
snr_mult = max(1.15, self.threshold_multiplier * 0.5)
|
scope_event: dict[str, Any] = {
|
||||||
snr_on = snr_mult * (1.0 + self._hysteresis)
|
|
||||||
snr_off = snr_mult * (1.0 - self._hysteresis)
|
|
||||||
events.append({
|
|
||||||
'type': 'scope',
|
'type': 'scope',
|
||||||
'amplitudes': amplitudes,
|
'amplitudes': amplitudes,
|
||||||
'threshold': self._threshold,
|
'threshold': self._threshold,
|
||||||
@@ -561,11 +657,22 @@ class MorseDecoder:
|
|||||||
'noise_floor': self._noise_floor,
|
'noise_floor': self._noise_floor,
|
||||||
'wpm': round(self._estimated_wpm, 1),
|
'wpm': round(self._estimated_wpm, 1),
|
||||||
'dit_ms': round(self._effective_dit_blocks() * self._block_duration * 1000.0, 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),
|
'detect_mode': self.detect_mode,
|
||||||
'noise_ref': round(self._noise_floor, 4),
|
}
|
||||||
'snr_on': round(snr_on, 2),
|
if self.detect_mode == 'envelope':
|
||||||
'snr_off': round(snr_off, 2),
|
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
|
return events
|
||||||
|
|
||||||
@@ -818,6 +925,7 @@ def morse_decoder_thread(
|
|||||||
wpm_mode=_normalize_wpm_mode(cfg.get('wpm_mode', 'auto')),
|
wpm_mode=_normalize_wpm_mode(cfg.get('wpm_mode', 'auto')),
|
||||||
wpm_lock=_coerce_bool(cfg.get('wpm_lock', False), False),
|
wpm_lock=_coerce_bool(cfg.get('wpm_lock', False), False),
|
||||||
min_signal_gate=float(cfg.get('min_signal_gate', 0.0) or 0.0),
|
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()
|
last_scope = time.monotonic()
|
||||||
@@ -1101,6 +1209,7 @@ def morse_iq_decoder_thread(
|
|||||||
wpm_mode=_normalize_wpm_mode(cfg.get('wpm_mode', 'auto')),
|
wpm_mode=_normalize_wpm_mode(cfg.get('wpm_mode', 'auto')),
|
||||||
wpm_lock=_coerce_bool(cfg.get('wpm_lock', False), False),
|
wpm_lock=_coerce_bool(cfg.get('wpm_lock', False), False),
|
||||||
min_signal_gate=float(cfg.get('min_signal_gate', 0.0) or 0.0),
|
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()
|
last_scope = time.monotonic()
|
||||||
|
|||||||
@@ -104,13 +104,29 @@ def _signal_handler(signum, frame):
|
|||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
# Only register signal handlers if we're not in a thread
|
# Only register signal handlers when running standalone (not under gunicorn).
|
||||||
try:
|
# Gunicorn manages its own SIGINT/SIGTERM handling for graceful shutdown;
|
||||||
signal.signal(signal.SIGTERM, _signal_handler)
|
# overriding those signals causes KeyboardInterrupt in the wrong context.
|
||||||
signal.signal(signal.SIGINT, _signal_handler)
|
def _is_under_gunicorn():
|
||||||
except ValueError:
|
"""Check if we're running inside a gunicorn worker."""
|
||||||
# Can't set signal handlers from a thread
|
try:
|
||||||
pass
|
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:
|
def cleanup_stale_processes() -> None:
|
||||||
|
|||||||
@@ -347,17 +347,21 @@ def detect_hackrf_devices() -> list[SDRDevice]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Parse hackrf_info output
|
# Parse hackrf_info output
|
||||||
# Look for "Serial number:" lines
|
# Extract board name from "Board ID Number: X (Name)" and serial
|
||||||
serial_pattern = r'Serial number:\s*(\S+)'
|
|
||||||
from .hackrf import HackRFCommandBuilder
|
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)
|
serials_found = re.findall(serial_pattern, result.stdout)
|
||||||
|
boards_found = re.findall(board_pattern, result.stdout)
|
||||||
|
|
||||||
for i, serial in enumerate(serials_found):
|
for i, serial in enumerate(serials_found):
|
||||||
|
board_name = boards_found[i] if i < len(boards_found) else 'HackRF'
|
||||||
devices.append(SDRDevice(
|
devices.append(SDRDevice(
|
||||||
sdr_type=SDRType.HACKRF,
|
sdr_type=SDRType.HACKRF,
|
||||||
index=i,
|
index=i,
|
||||||
name=f'HackRF One',
|
name=board_name,
|
||||||
serial=serial,
|
serial=serial,
|
||||||
driver='hackrf',
|
driver='hackrf',
|
||||||
capabilities=HackRFCommandBuilder.CAPABILITIES
|
capabilities=HackRFCommandBuilder.CAPABILITIES
|
||||||
@@ -365,10 +369,12 @@ def detect_hackrf_devices() -> list[SDRDevice]:
|
|||||||
|
|
||||||
# Fallback: check if any HackRF found without serial
|
# Fallback: check if any HackRF found without serial
|
||||||
if not devices and 'Found HackRF' in result.stdout:
|
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(
|
devices.append(SDRDevice(
|
||||||
sdr_type=SDRType.HACKRF,
|
sdr_type=SDRType.HACKRF,
|
||||||
index=0,
|
index=0,
|
||||||
name='HackRF One',
|
name=board_name,
|
||||||
serial='Unknown',
|
serial='Unknown',
|
||||||
driver='hackrf',
|
driver='hackrf',
|
||||||
capabilities=HackRFCommandBuilder.CAPABILITIES
|
capabilities=HackRFCommandBuilder.CAPABILITIES
|
||||||
|
|||||||
Reference in New Issue
Block a user