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.
|
||||
|
||||
## [2.23.0] - 2026-02-27
|
||||
|
||||
### Added
|
||||
- **Radiosonde Weather Balloon Tracking** - 400-406 MHz reception via radiosonde_auto_rx with telemetry, map, and station distance tracking
|
||||
- **CW/Morse Code Decoder** - Custom Goertzel tone detection with OOK/AM envelope detection mode for ISM bands
|
||||
- **WeFax (Weather Fax) Decoder** - HF weather fax reception with auto-scheduler, broadcast timeline, and image gallery
|
||||
- **System Health Monitoring** - Telemetry dashboard with process monitoring and system metrics
|
||||
- **HTTPS Support** - TLS via `INTERCEPT_HTTPS` configuration
|
||||
- **ADS-B Voice Alerts** - Text-to-speech notifications for military and emergency aircraft detections
|
||||
- **HackRF TSCM RF Scan** - HackRF support added to TSCM counter-surveillance RF sweep
|
||||
- **Multi-SDR WeFax** - Multiple SDR hardware support for WeFax decoder
|
||||
- **Tool Path Overrides** - `INTERCEPT_*_PATH` environment variables for custom tool locations
|
||||
- **Homebrew Tool Detection** - Native path detection for Apple Silicon Homebrew installations
|
||||
- **Production Server** - `start.sh` with gunicorn + gevent for concurrent SSE/WebSocket handling — eliminates multi-client page load delays
|
||||
|
||||
### Changed
|
||||
- Morse decoder rebuilt with custom Goertzel decoder, replacing multimon-ng dependency
|
||||
- GPS mode upgraded to textured 3D globe visualization
|
||||
- Destroy lifecycle added to all mode modules to prevent resource leaks
|
||||
- Docker container now uses gunicorn + gevent by default via `start.sh`
|
||||
|
||||
### Fixed
|
||||
- ADS-B device release leak and startup performance regression
|
||||
- ADS-B probe incorrectly treating "No devices found" as success
|
||||
- USB claim race condition after SDR probe
|
||||
- SDR device registry collision when multiple SDR types present
|
||||
- APRS 15-minute startup delay caused by pipe buffering
|
||||
- APRS map centering at [0,0] when GPS unavailable
|
||||
- DSC decoder ITU-R M.493 compliance issues
|
||||
- Weather satellite 0dB SNR — increased sample rate for Meteor LRPT
|
||||
- SSE fanout backlog causing delayed updates across all modes
|
||||
- SSE reconnect packet loss during client reconnection
|
||||
- Waterfall monitor tuning race conditions
|
||||
- Mode FOUC (flash of unstyled content) on initial navigation
|
||||
- Various Morse decoder stability and lifecycle fixes
|
||||
|
||||
---
|
||||
|
||||
## [2.22.3] - 2026-02-23
|
||||
|
||||
### Fixed
|
||||
|
||||
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)
|
||||
./setup.sh
|
||||
|
||||
# Run the application (requires sudo for SDR/network access)
|
||||
sudo -E venv/bin/python intercept.py
|
||||
# Run with production server (gunicorn + gevent, handles concurrent SSE/WebSocket)
|
||||
sudo ./start.sh
|
||||
|
||||
# Or activate venv first
|
||||
source venv/bin/activate
|
||||
sudo -E python intercept.py
|
||||
# Or for quick local dev (Flask dev server)
|
||||
sudo -E venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
### Testing
|
||||
@@ -69,8 +68,9 @@ mypy .
|
||||
## Architecture
|
||||
|
||||
### Entry Points
|
||||
- `intercept.py` - Main entry point script
|
||||
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure
|
||||
- `start.sh` - Production startup script (gunicorn + gevent auto-detection, CLI flags, HTTPS, fallback to Flask dev server)
|
||||
- `intercept.py` - Direct Flask dev server entry point (quick local development)
|
||||
- `app.py` - Flask application initialization, global state management, process lifecycle, SSE streaming infrastructure, conditional gevent monkey-patch
|
||||
|
||||
### Route Blueprints (routes/)
|
||||
Each signal type has its own Flask blueprint:
|
||||
@@ -121,7 +121,7 @@ Each signal type has its own Flask blueprint:
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages.
|
||||
**Server-Sent Events (SSE)**: All real-time features stream via SSE endpoints (`/stream_pager`, `/stream_sensor`, etc.). Pattern uses `queue.Queue` with timeout and keepalive messages. Under gunicorn + gevent, each SSE connection is a lightweight greenlet instead of an OS thread.
|
||||
|
||||
**Process Management**: External decoders run as subprocesses with output threads feeding queues. Use `safe_terminate()` for cleanup. Global locks prevent race conditions.
|
||||
|
||||
@@ -152,7 +152,7 @@ Each signal type has its own Flask blueprint:
|
||||
- **Mode Integration**: Each mode needs entries in `index.html` at ~12 points: CSS include, welcome card, partial include, visuals container, JS include, `validModes` set, `modeGroups` map, classList toggle, `modeNames`, visuals display toggle, titles, and init call in `switchMode()`
|
||||
|
||||
### Docker
|
||||
- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.)
|
||||
- `Dockerfile` - Single-stage build with all SDR tools compiled from source (dump1090, AIS-catcher, slowrx, SatDump, etc.). CMD runs `start.sh` (gunicorn + gevent)
|
||||
- `docker-compose.yml` - Two profiles: `basic` (standalone) and `history` (with Postgres for ADS-B)
|
||||
- `build-multiarch.sh` - Multi-arch build script for amd64 + arm64 (RPi5)
|
||||
- Data persisted via `./data:/app/data` volume mount
|
||||
|
||||
@@ -274,4 +274,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -sf http://localhost:5050/health || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "intercept.py"]
|
||||
CMD ["/bin/bash", "start.sh"]
|
||||
|
||||
70
README.md
70
README.md
@@ -50,47 +50,49 @@ Support the developer of this open-source project
|
||||
- **Meshtastic** - LoRa mesh network integration
|
||||
- **Space Weather** - Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL (no SDR required)
|
||||
- **Spy Stations** - Number stations and diplomatic HF network database
|
||||
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
||||
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
||||
|
||||
---
|
||||
|
||||
## CW / Morse Decoder Notes
|
||||
|
||||
Live backend:
|
||||
- Uses `rtl_fm` piped into `multimon-ng` (`MORSE_CW`) for real-time decode.
|
||||
|
||||
Recommended baseline settings:
|
||||
- **Tone**: `700 Hz`
|
||||
- **Bandwidth**: `200 Hz` (use `100 Hz` for crowded bands, `400 Hz` for drifting signals)
|
||||
- **Threshold Mode**: `Auto`
|
||||
- **WPM Mode**: `Auto`
|
||||
|
||||
Auto Tone Track behavior:
|
||||
- Continuously measures nearby tone energy around the configured CW pitch.
|
||||
- Steers the detector toward the strongest valid CW tone when signal-to-noise is sufficient.
|
||||
- Use **Hold Tone Lock** to freeze tracking once the desired signal is centered.
|
||||
|
||||
Troubleshooting (no decode / noisy decode):
|
||||
- Confirm demod path is **USB/CW-compatible** and frequency is tuned correctly.
|
||||
- If multiple SDRs are connected and the selected one has no PCM output, Morse startup now auto-tries other detected SDR devices and reports the active device/serial in status logs.
|
||||
- Match **tone** and **bandwidth** to the actual sidetone/pitch.
|
||||
- Try **Threshold Auto** first; if needed, switch to manual threshold and recalibrate.
|
||||
- Use **Reset/Calibrate** after major frequency or band condition changes.
|
||||
- Raise **Minimum Signal Gate** to suppress random noise keying.
|
||||
|
||||
---
|
||||
|
||||
## Installation / Debian / Ubuntu / MacOS
|
||||
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
||||
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
||||
|
||||
---
|
||||
|
||||
## CW / Morse Decoder Notes
|
||||
|
||||
Live backend:
|
||||
- Uses `rtl_fm` piped into `multimon-ng` (`MORSE_CW`) for real-time decode.
|
||||
|
||||
Recommended baseline settings:
|
||||
- **Tone**: `700 Hz`
|
||||
- **Bandwidth**: `200 Hz` (use `100 Hz` for crowded bands, `400 Hz` for drifting signals)
|
||||
- **Threshold Mode**: `Auto`
|
||||
- **WPM Mode**: `Auto`
|
||||
|
||||
Auto Tone Track behavior:
|
||||
- Continuously measures nearby tone energy around the configured CW pitch.
|
||||
- Steers the detector toward the strongest valid CW tone when signal-to-noise is sufficient.
|
||||
- Use **Hold Tone Lock** to freeze tracking once the desired signal is centered.
|
||||
|
||||
Troubleshooting (no decode / noisy decode):
|
||||
- Confirm demod path is **USB/CW-compatible** and frequency is tuned correctly.
|
||||
- If multiple SDRs are connected and the selected one has no PCM output, Morse startup now auto-tries other detected SDR devices and reports the active device/serial in status logs.
|
||||
- Match **tone** and **bandwidth** to the actual sidetone/pitch.
|
||||
- Try **Threshold Auto** first; if needed, switch to manual threshold and recalibrate.
|
||||
- Use **Reset/Calibrate** after major frequency or band condition changes.
|
||||
- Raise **Minimum Signal Gate** to suppress random noise keying.
|
||||
|
||||
---
|
||||
|
||||
## Installation / Debian / Ubuntu / MacOS
|
||||
|
||||
**1. Clone and run:**
|
||||
```bash
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
./setup.sh
|
||||
sudo -E venv/bin/python intercept.py
|
||||
sudo ./start.sh
|
||||
```
|
||||
|
||||
> **Production vs Dev server:** `start.sh` auto-detects gunicorn + gevent and runs a production server with cooperative greenlets — handles multiple SSE/WebSocket clients without blocking. Falls back to Flask dev server if gunicorn is not installed. For quick local development, you can still use `sudo -E venv/bin/python intercept.py` directly.
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
@@ -174,7 +176,7 @@ Set these as environment variables for either local installs or Docker:
|
||||
```bash
|
||||
INTERCEPT_ADSB_AUTO_START=true \
|
||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||
sudo -E venv/bin/python intercept.py
|
||||
sudo ./start.sh
|
||||
```
|
||||
|
||||
**Docker example (.env)**
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2026-02-15_ae16bb62",
|
||||
"downloaded": "2026-02-20T00:29:06.228007Z"
|
||||
"version": "2026-02-22_17194a71",
|
||||
"downloaded": "2026-02-27T10:41:04.872620Z"
|
||||
}
|
||||
177
app.py
177
app.py
@@ -55,9 +55,9 @@ app.secret_key = "signals_intelligence_secret" # Required for flash messages
|
||||
|
||||
# Set up rate limiting
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address, # Identifies the user by their IP
|
||||
key_func=get_remote_address,
|
||||
app=app,
|
||||
storage_uri="memory://", # Use RAM memory (change to redis:// etc. for distributed setups)
|
||||
storage_uri="memory://",
|
||||
)
|
||||
|
||||
# Disable Werkzeug debugger PIN (not needed for local development tool)
|
||||
@@ -928,6 +928,102 @@ def _ensure_self_signed_cert(cert_dir: str) -> tuple:
|
||||
return cert_path, key_path
|
||||
|
||||
|
||||
_app_initialized = False
|
||||
|
||||
|
||||
def _init_app() -> None:
|
||||
"""Initialize blueprints, database, and websockets.
|
||||
|
||||
Safe to call multiple times — subsequent calls are no-ops.
|
||||
Called automatically at module level for gunicorn, and also
|
||||
from main() for the Flask dev server path.
|
||||
|
||||
Heavy/network operations (TLE updates, process cleanup) are
|
||||
deferred to a background thread so the worker can serve
|
||||
requests immediately.
|
||||
"""
|
||||
global _app_initialized
|
||||
if _app_initialized:
|
||||
return
|
||||
_app_initialized = True
|
||||
|
||||
import os
|
||||
|
||||
# Initialize database for settings storage
|
||||
from utils.database import init_db
|
||||
init_db()
|
||||
|
||||
# Register blueprints (essential — without these, all routes 404)
|
||||
from routes import register_blueprints
|
||||
register_blueprints(app)
|
||||
|
||||
# Initialize WebSocket for audio streaming
|
||||
try:
|
||||
from routes.audio_websocket import init_audio_websocket
|
||||
init_audio_websocket(app)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Initialize KiwiSDR WebSocket audio proxy
|
||||
try:
|
||||
from routes.websdr import init_websdr_audio
|
||||
init_websdr_audio(app)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Initialize WebSocket for waterfall streaming
|
||||
try:
|
||||
from routes.waterfall_websocket import init_waterfall_websocket
|
||||
init_waterfall_websocket(app)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Defer heavy/network operations so the worker can serve requests immediately
|
||||
import threading
|
||||
|
||||
def _deferred_init():
|
||||
"""Run heavy initialization after a short delay."""
|
||||
import time
|
||||
time.sleep(1) # Let the worker start serving first
|
||||
|
||||
# Clean up stale processes from previous runs
|
||||
try:
|
||||
cleanup_stale_processes()
|
||||
cleanup_stale_dump1090()
|
||||
except Exception as e:
|
||||
logger.warning(f"Stale process cleanup failed: {e}")
|
||||
|
||||
# Register and start database cleanup
|
||||
try:
|
||||
from utils.database import (
|
||||
cleanup_old_signal_history,
|
||||
cleanup_old_timeline_entries,
|
||||
cleanup_old_dsc_alerts,
|
||||
cleanup_old_payloads
|
||||
)
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440)
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_timeline_entries, interval_multiplier=1440)
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_dsc_alerts, interval_multiplier=1440)
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_payloads, interval_multiplier=1440)
|
||||
cleanup_manager.start()
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup manager init failed: {e}")
|
||||
|
||||
# Initialize TLE auto-refresh (must be after blueprint registration)
|
||||
try:
|
||||
from routes.satellite import init_tle_auto_refresh
|
||||
if not os.environ.get('TESTING'):
|
||||
init_tle_auto_refresh()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize TLE auto-refresh: {e}")
|
||||
|
||||
threading.Thread(target=_deferred_init, daemon=True).start()
|
||||
|
||||
|
||||
# Auto-initialize when imported (e.g. by gunicorn)
|
||||
_init_app()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point."""
|
||||
import argparse
|
||||
@@ -1009,81 +1105,8 @@ def main() -> None:
|
||||
print("Running as root - full capabilities enabled")
|
||||
print()
|
||||
|
||||
# Clean up any stale processes from previous runs
|
||||
cleanup_stale_processes()
|
||||
cleanup_stale_dump1090()
|
||||
|
||||
# Initialize database for settings storage
|
||||
from utils.database import init_db
|
||||
init_db()
|
||||
|
||||
# Register database cleanup functions
|
||||
from utils.database import (
|
||||
cleanup_old_signal_history,
|
||||
cleanup_old_timeline_entries,
|
||||
cleanup_old_dsc_alerts,
|
||||
cleanup_old_payloads
|
||||
)
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_signal_history, interval_multiplier=1440) # Every 24 hours
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_timeline_entries, interval_multiplier=1440) # Every 24 hours
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_dsc_alerts, interval_multiplier=1440) # Every 24 hours
|
||||
cleanup_manager.register_db_cleanup(cleanup_old_payloads, interval_multiplier=1440) # Every 24 hours
|
||||
|
||||
# Start automatic cleanup of stale data entries
|
||||
cleanup_manager.start()
|
||||
|
||||
# Register blueprints
|
||||
from routes import register_blueprints
|
||||
register_blueprints(app)
|
||||
|
||||
# Initialize TLE auto-refresh (must be after blueprint registration)
|
||||
try:
|
||||
from routes.satellite import init_tle_auto_refresh
|
||||
import os
|
||||
if not os.environ.get('TESTING'):
|
||||
init_tle_auto_refresh()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize TLE auto-refresh: {e}")
|
||||
|
||||
# Update TLE data in background thread (non-blocking)
|
||||
def update_tle_background():
|
||||
try:
|
||||
from routes.satellite import refresh_tle_data
|
||||
print("Updating satellite TLE data from CelesTrak...")
|
||||
updated = refresh_tle_data()
|
||||
if updated:
|
||||
print(f"TLE data updated for: {', '.join(updated)}")
|
||||
else:
|
||||
print("TLE update: No satellites updated (may be offline)")
|
||||
except Exception as e:
|
||||
print(f"TLE update failed (will use cached data): {e}")
|
||||
|
||||
tle_thread = threading.Thread(target=update_tle_background, daemon=True)
|
||||
tle_thread.start()
|
||||
|
||||
# Initialize WebSocket for audio streaming
|
||||
try:
|
||||
from routes.audio_websocket import init_audio_websocket
|
||||
init_audio_websocket(app)
|
||||
print("WebSocket audio streaming enabled")
|
||||
except ImportError as e:
|
||||
print(f"WebSocket audio disabled (install flask-sock): {e}")
|
||||
|
||||
# Initialize KiwiSDR WebSocket audio proxy
|
||||
try:
|
||||
from routes.websdr import init_websdr_audio
|
||||
init_websdr_audio(app)
|
||||
print("KiwiSDR audio proxy enabled")
|
||||
except ImportError as e:
|
||||
print(f"KiwiSDR audio proxy disabled: {e}")
|
||||
|
||||
# Initialize WebSocket for waterfall streaming
|
||||
try:
|
||||
from routes.waterfall_websocket import init_waterfall_websocket
|
||||
init_waterfall_websocket(app)
|
||||
print("WebSocket waterfall streaming enabled")
|
||||
except ImportError as e:
|
||||
print(f"WebSocket waterfall disabled: {e}")
|
||||
# Ensure app is initialized (no-op if already done by module-level call)
|
||||
_init_app()
|
||||
|
||||
# Configure SSL if HTTPS is enabled
|
||||
ssl_context = None
|
||||
|
||||
18
config.py
18
config.py
@@ -7,10 +7,26 @@ import os
|
||||
import sys
|
||||
|
||||
# Application version
|
||||
VERSION = "2.22.3"
|
||||
VERSION = "2.23.0"
|
||||
|
||||
# Changelog - latest release notes (shown on welcome screen)
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "2.23.0",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"Radiosonde weather balloon tracking mode with telemetry, map, and station distance",
|
||||
"CW/Morse code decoder with Goertzel tone detection and OOK envelope mode",
|
||||
"WeFax (Weather Fax) decoder with auto-scheduler and broadcast timeline",
|
||||
"System Health monitoring mode with telemetry dashboard",
|
||||
"HTTPS support, HackRF TSCM RF scan, ADS-B voice alerts",
|
||||
"Production server (start.sh) with gunicorn + gevent for concurrent multi-client support",
|
||||
"Multi-SDR support for WeFax, tool path overrides, native Homebrew detection",
|
||||
"GPS mode upgraded to textured 3D globe",
|
||||
"Destroy lifecycle added to all mode modules to prevent resource leaks",
|
||||
"Dozens of bug fixes across ADS-B, APRS, SSE, Morse, waterfall, and more",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.22.3",
|
||||
"date": "February 2026",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# INTERCEPT - Signal Intelligence Platform
|
||||
# Docker Compose configuration for easy deployment
|
||||
#
|
||||
# Uses gunicorn + gevent production server via start.sh (handles concurrent SSE/WebSocket)
|
||||
#
|
||||
# Basic usage (build locally):
|
||||
# docker compose --profile basic up -d --build
|
||||
#
|
||||
|
||||
@@ -100,11 +100,30 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
|
||||
- **CSV/JSON export** - export captured messages for offline analysis
|
||||
- **Integrated with ADS-B dashboard** - VDL2 messages linked to aircraft tracking
|
||||
|
||||
## CW/Morse Code Decoder
|
||||
|
||||
- **Custom Goertzel tone detection** for CW (continuous wave) Morse decoding
|
||||
- **OOK/AM envelope detection** mode for on-off keying signals in ISM bands
|
||||
- **HF frequency presets** for amateur CW bands (160m-10m)
|
||||
- **ISM band presets** for OOK envelope mode (315 MHz, 433 MHz, 868 MHz, 915 MHz)
|
||||
- **Real-time character and word output** with WPM estimation
|
||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||
|
||||
## WeFax (Weather Fax)
|
||||
|
||||
- **HF weather fax reception** from marine and meteorological broadcast stations
|
||||
- **Broadcast timeline** with scheduled transmission times by station
|
||||
- **Auto-scheduler** for unattended capture of scheduled broadcasts
|
||||
- **Image gallery** with timestamped decoded weather charts
|
||||
- **Station presets** for major WeFax broadcasters worldwide
|
||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||
|
||||
## Listening Post
|
||||
|
||||
- **Wideband frequency scanning** via rtl_power sweep with SNR filtering
|
||||
- **Real-time audio monitoring** with FM and SSB demodulation
|
||||
- **Cross-module frequency routing** from scanner to decoders
|
||||
- **Waterfall spectrum display** for visual signal identification
|
||||
- **Customizable frequency presets** and band bookmarks
|
||||
- **Multi-SDR support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay
|
||||
|
||||
@@ -170,6 +189,16 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
|
||||
- **Auto-refresh** - 5-minute polling with manual refresh option
|
||||
- **No SDR required** - Data fetched from NOAA SWPC, NASA SDO, and HamQSL public APIs
|
||||
|
||||
## Radiosonde Weather Balloon Tracking
|
||||
|
||||
- **400-406 MHz reception** via radiosonde_auto_rx for weather balloon telemetry
|
||||
- **Frequency presets** for common radiosonde bands
|
||||
- **Real-time telemetry** - altitude, temperature, humidity, pressure, GPS position
|
||||
- **Interactive map** with balloon trajectory and burst point prediction
|
||||
- **Station location** with configurable observer position
|
||||
- **Distance tracking** - real-time distance-to-balloon calculation
|
||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||
|
||||
## Satellite Tracking
|
||||
|
||||
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
||||
@@ -270,7 +299,7 @@ Technical Surveillance Countermeasures (TSCM) screening for detecting wireless s
|
||||
### Wireless Sweep Features
|
||||
- **BLE scanning** with manufacturer data detection (AirTags, Tile, SmartTags, ESP32)
|
||||
- **WiFi scanning** for rogue APs, hidden SSIDs, camera devices
|
||||
- **RF spectrum analysis** (requires RTL-SDR) - FM bugs, ISM bands, video transmitters
|
||||
- **RF spectrum analysis** (RTL-SDR or HackRF) - FM bugs, ISM bands, video transmitters
|
||||
- **Cross-protocol correlation** - links devices across BLE/WiFi/RF
|
||||
- **Baseline comparison** - detect new/unknown devices vs known environment
|
||||
|
||||
@@ -369,6 +398,14 @@ Deploy lightweight sensor nodes across multiple locations and aggregate data to
|
||||
- **Redundancy** - Multiple nodes for reliable coverage
|
||||
- **Triangulation** - Use multiple GPS-enabled agents for signal location
|
||||
|
||||
## System Health
|
||||
|
||||
- **Telemetry dashboard** with real-time system metrics
|
||||
- **Process monitoring** for all running SDR tools and decoders
|
||||
- **CPU, memory, and disk usage** tracking
|
||||
- **SDR device status** overview
|
||||
- **No SDR required** - monitors system health independently
|
||||
|
||||
## User Interface
|
||||
|
||||
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
||||
@@ -429,14 +466,19 @@ The settings modal shows availability status for each bundled asset:
|
||||
## General
|
||||
|
||||
- **Web-based interface** - no desktop app needed
|
||||
- **Production server** - gunicorn + gevent via `start.sh` for concurrent SSE/WebSocket handling (falls back to Flask dev server)
|
||||
- **Live message streaming** via Server-Sent Events (SSE)
|
||||
- **Audio alerts** with mute toggle
|
||||
- **Message export** to CSV/JSON
|
||||
- **Signal activity meter** and waterfall display
|
||||
- **Message logging** to file with timestamps
|
||||
- **Multi-SDR hardware support** - RTL-SDR, LimeSDR, HackRF
|
||||
- **HTTPS support** via `INTERCEPT_HTTPS` configuration for secure deployments
|
||||
- **Voice alerts** for configurable event notifications across modes
|
||||
- **Multi-SDR hardware support** - RTL-SDR, LimeSDR, HackRF, Airspy, SDRplay
|
||||
- **Automatic device detection** across all supported hardware
|
||||
- **Hardware-specific validation** - frequency/gain ranges per device type
|
||||
- **Tool path overrides** via `INTERCEPT_*_PATH` environment variables
|
||||
- **Native Homebrew detection** for Apple Silicon tool paths
|
||||
- **Configurable gain and PPM correction**
|
||||
- **Device intelligence** dashboard with tracking
|
||||
- **GPS dongle support** - USB GPS receivers for precise observer location
|
||||
|
||||
@@ -259,10 +259,13 @@ pip install -r requirements.txt
|
||||
After installation:
|
||||
|
||||
```bash
|
||||
sudo -E venv/bin/python intercept.py
|
||||
sudo ./start.sh
|
||||
|
||||
# Custom port
|
||||
INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py
|
||||
sudo ./start.sh -p 8080
|
||||
|
||||
# HTTPS
|
||||
sudo ./start.sh --https
|
||||
```
|
||||
|
||||
Open **http://localhost:5050** in your browser.
|
||||
|
||||
@@ -18,10 +18,9 @@ By default, INTERCEPT binds to `0.0.0.0:5050`, making it accessible from any net
|
||||
echo "block in on en0 proto tcp from any to any port 5050" | sudo pfctl -ef -
|
||||
```
|
||||
|
||||
2. **Bind to Localhost**: For local-only access, set the host environment variable:
|
||||
2. **Bind to Localhost**: For local-only access, set the host or use the CLI flag:
|
||||
```bash
|
||||
export INTERCEPT_HOST=127.0.0.1
|
||||
python intercept.py
|
||||
sudo ./start.sh -H 127.0.0.1
|
||||
```
|
||||
|
||||
3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. The application has no authentication mechanism.
|
||||
|
||||
@@ -25,7 +25,7 @@ sudo apt install python3-flask python3-requests python3-serial python3-skyfield
|
||||
# Then create venv with system packages
|
||||
python3 -m venv --system-site-packages venv
|
||||
source venv/bin/activate
|
||||
sudo venv/bin/python intercept.py
|
||||
sudo ./start.sh
|
||||
```
|
||||
|
||||
### "error: externally-managed-environment" (pip blocked)
|
||||
@@ -61,7 +61,7 @@ sudo apt install python3.11 python3.11-venv python3-pip
|
||||
python3.11 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
sudo venv/bin/python intercept.py
|
||||
sudo ./start.sh
|
||||
```
|
||||
|
||||
### Alternative: Use the setup script
|
||||
@@ -336,7 +336,7 @@ rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>/dev/null | \
|
||||
|
||||
Run INTERCEPT with sudo:
|
||||
```bash
|
||||
sudo -E venv/bin/python intercept.py
|
||||
sudo ./start.sh
|
||||
```
|
||||
|
||||
### Interface not found after enabling monitor mode
|
||||
|
||||
@@ -172,7 +172,7 @@ Set the following environment variables (Docker recommended):
|
||||
```bash
|
||||
INTERCEPT_ADSB_AUTO_START=true \
|
||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||
sudo -E venv/bin/python intercept.py
|
||||
sudo ./start.sh
|
||||
```
|
||||
|
||||
**Docker example (.env)**
|
||||
@@ -518,10 +518,28 @@ INTERCEPT can be configured via environment variables:
|
||||
| `INTERCEPT_LOG_LEVEL` | `WARNING` | Log level (DEBUG, INFO, WARNING, ERROR) |
|
||||
| `INTERCEPT_DEFAULT_GAIN` | `40` | Default RTL-SDR gain |
|
||||
|
||||
Example: `INTERCEPT_PORT=8080 sudo -E venv/bin/python intercept.py`
|
||||
Example: `INTERCEPT_PORT=8080 sudo ./start.sh`
|
||||
|
||||
## Command-line Options
|
||||
|
||||
### Production server (recommended)
|
||||
|
||||
```
|
||||
sudo ./start.sh --help
|
||||
|
||||
-p, --port PORT Port to listen on (default: 5050)
|
||||
-H, --host HOST Host to bind to (default: 0.0.0.0)
|
||||
-d, --debug Run in debug mode (Flask dev server)
|
||||
--https Enable HTTPS with self-signed certificate
|
||||
--check-deps Check dependencies and exit
|
||||
```
|
||||
|
||||
> **Note:** `sudo` is required for SDR hardware access, WiFi monitor mode, and Bluetooth low-level operations.
|
||||
|
||||
`start.sh` auto-detects gunicorn + gevent and runs a production WSGI server with cooperative greenlets — this handles multiple SSE streams and WebSocket connections concurrently without blocking. Falls back to the Flask dev server if gunicorn is not installed.
|
||||
|
||||
### Development server
|
||||
|
||||
```
|
||||
python3 intercept.py --help
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
<div class="hero-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">25+</span>
|
||||
<span class="stat-value">30+</span>
|
||||
<span class="stat-label">Modes</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
@@ -92,6 +92,11 @@
|
||||
<h3>Listening Post</h3>
|
||||
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="signals">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h2"/><path d="M8 12h1"/><path d="M11 12h2"/><path d="M15 12h1"/><path d="M18 12h2"/><circle cx="6" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="18" cy="12" r="1"/><path d="M4 8h16"/><path d="M4 16h16"/></svg></div>
|
||||
<h3>CW/Morse Decoder</h3>
|
||||
<p>Morse code decoding with custom Goertzel tone detection for CW and OOK/AM envelope detection for ISM band signals.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="intel">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
|
||||
<h3>WebSDR</h3>
|
||||
@@ -152,11 +157,21 @@
|
||||
<h3>HF SSTV</h3>
|
||||
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="space">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 15h18"/><path d="M3 9h18"/><path d="M6 3v18"/><path d="M18 3v18"/><path d="M9 6h6"/></svg></div>
|
||||
<h3>WeFax</h3>
|
||||
<p>HF weather fax decoder with broadcast timeline, auto-scheduler, and image gallery for marine weather charts.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="tracking">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/><path d="M12 8l4 4-4 4"/></svg></div>
|
||||
<h3>GPS Tracking</h3>
|
||||
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="tracking">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6"/><path d="M12 22v-6"/><circle cx="12" cy="12" r="4"/><path d="M8 12H2"/><path d="M22 12h-6"/><path d="M12 8a20 20 0 0 1 0 8"/><path d="M7 4l2 3"/><path d="M17 20l-2-3"/></svg></div>
|
||||
<h3>Radiosonde</h3>
|
||||
<p>Weather balloon tracking on 400-406 MHz via radiosonde_auto_rx. Real-time telemetry, trajectory map, and station distance.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="space">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></div>
|
||||
<h3>Space Weather</h3>
|
||||
@@ -197,6 +212,11 @@
|
||||
<h3>Offline Mode</h3>
|
||||
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="platform">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></div>
|
||||
<h3>System Health</h3>
|
||||
<p>Real-time telemetry dashboard with process monitoring, system metrics, and SDR device status overview.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="carousel-arrow carousel-arrow-right" aria-label="Scroll right">›</button>
|
||||
</div>
|
||||
@@ -311,7 +331,7 @@
|
||||
<pre><code>git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
./setup.sh
|
||||
sudo -E venv/bin/python intercept.py</code></pre>
|
||||
sudo ./start.sh</code></pre>
|
||||
</div>
|
||||
<p class="install-note">Requires Python 3.9+ and RTL-SDR drivers</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "intercept"
|
||||
version = "2.22.3"
|
||||
version = "2.23.0"
|
||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -47,3 +47,7 @@ websocket-client>=1.6.0
|
||||
|
||||
# System health monitoring (optional - graceful fallback if unavailable)
|
||||
psutil>=5.9.0
|
||||
|
||||
# Production WSGI server (optional - falls back to Flask dev server)
|
||||
gunicorn>=21.2.0
|
||||
gevent>=23.9.0
|
||||
|
||||
@@ -22,7 +22,13 @@ from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.validation import (
|
||||
validate_device_index,
|
||||
validate_gain,
|
||||
validate_ppm,
|
||||
validate_rtl_tcp_host,
|
||||
validate_rtl_tcp_port,
|
||||
)
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
@@ -1689,6 +1695,10 @@ def start_aprs() -> Response:
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Check for rtl_tcp (remote SDR) connection
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||
|
||||
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
@@ -1708,16 +1718,17 @@ def start_aprs() -> Response:
|
||||
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
|
||||
}), 400
|
||||
|
||||
# Reserve SDR device to prevent conflicts with other modes
|
||||
error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
aprs_active_device = device
|
||||
aprs_active_sdr_type = sdr_type_str
|
||||
# Reserve SDR device to prevent conflicts (skip for remote rtl_tcp)
|
||||
if not rtl_tcp_host:
|
||||
error = app_module.claim_sdr_device(device, 'aprs', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
aprs_active_device = device
|
||||
aprs_active_sdr_type = sdr_type_str
|
||||
|
||||
# Get frequency for region
|
||||
region = data.get('region', 'north_america')
|
||||
@@ -1741,8 +1752,17 @@ def start_aprs() -> Response:
|
||||
|
||||
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
|
||||
try:
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
if rtl_tcp_host:
|
||||
try:
|
||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||
else:
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
rtl_cmd = builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=float(frequency),
|
||||
|
||||
@@ -261,7 +261,7 @@ def start_scan():
|
||||
# Check if already scanning
|
||||
if scanner.is_scanning:
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'status': 'already_scanning',
|
||||
'scan_status': scanner.get_status().to_dict()
|
||||
})
|
||||
|
||||
|
||||
@@ -37,7 +37,12 @@ from utils.database import (
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.validation import validate_device_index, validate_gain
|
||||
from utils.validation import (
|
||||
validate_device_index,
|
||||
validate_gain,
|
||||
validate_rtl_tcp_host,
|
||||
validate_rtl_tcp_port,
|
||||
)
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.dependencies import get_tool_path
|
||||
from utils.process import register_process, unregister_process
|
||||
@@ -336,19 +341,29 @@ def start_decoding() -> Response:
|
||||
# Get SDR type from request
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
# Check if device is available using centralized registry
|
||||
global dsc_active_device, dsc_active_sdr_type
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'dsc', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
# Check for rtl_tcp (remote SDR) connection
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||
|
||||
dsc_active_device = device_int
|
||||
dsc_active_sdr_type = sdr_type_str
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
# Check if device is available using centralized registry (skip for remote rtl_tcp)
|
||||
global dsc_active_device, dsc_active_sdr_type
|
||||
if not rtl_tcp_host:
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'dsc', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
|
||||
dsc_active_device = device_int
|
||||
dsc_active_sdr_type = sdr_type_str
|
||||
|
||||
# Clear queue
|
||||
while not app_module.dsc_queue.empty():
|
||||
@@ -357,22 +372,32 @@ def start_decoding() -> Response:
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Build rtl_fm command
|
||||
rtl_fm_path = tools['rtl_fm']['path']
|
||||
# Build rtl_fm command via SDR abstraction layer
|
||||
decoder_path = tools['dsc_decoder']['path']
|
||||
|
||||
# rtl_fm command for DSC decoding
|
||||
# DSC uses narrow FM at 156.525 MHz with 48kHz sample rate
|
||||
rtl_cmd = [
|
||||
rtl_fm_path,
|
||||
'-f', f'{DSC_VHF_FREQUENCY_MHZ}M',
|
||||
'-s', str(DSC_SAMPLE_RATE),
|
||||
'-d', str(device),
|
||||
'-g', str(gain),
|
||||
'-M', 'fm', # FM demodulation
|
||||
'-l', '0', # No squelch for DSC
|
||||
'-E', 'dc' # DC blocking filter
|
||||
]
|
||||
if rtl_tcp_host:
|
||||
try:
|
||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||
else:
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=int(device))
|
||||
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
rtl_cmd = list(builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=DSC_VHF_FREQUENCY_MHZ,
|
||||
sample_rate=DSC_SAMPLE_RATE,
|
||||
gain=float(gain) if gain and str(gain) != '0' else None,
|
||||
modulation='fm',
|
||||
squelch=0,
|
||||
))
|
||||
# Ensure trailing '-' for stdin piping and add DC blocking filter
|
||||
if rtl_cmd and rtl_cmd[-1] == '-':
|
||||
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-']
|
||||
|
||||
# Decoder command
|
||||
decoder_cmd = [decoder_path]
|
||||
|
||||
113
routes/morse.py
113
routes/morse.py
@@ -28,6 +28,8 @@ from utils.validation import (
|
||||
validate_frequency,
|
||||
validate_gain,
|
||||
validate_ppm,
|
||||
validate_rtl_tcp_host,
|
||||
validate_rtl_tcp_port,
|
||||
)
|
||||
|
||||
morse_bp = Blueprint('morse', __name__)
|
||||
@@ -219,6 +221,14 @@ def _validate_signal_gate(value: Any) -> float:
|
||||
raise ValueError(f'Invalid signal gate: {value}') from e
|
||||
|
||||
|
||||
def _validate_detect_mode(value: Any) -> str:
|
||||
"""Validate detection mode ('goertzel' or 'envelope')."""
|
||||
mode = str(value or 'goertzel').lower().strip()
|
||||
if mode not in ('goertzel', 'envelope'):
|
||||
raise ValueError("detect_mode must be 'goertzel' or 'envelope'")
|
||||
return mode
|
||||
|
||||
|
||||
def _snapshot_live_resources() -> list[str]:
|
||||
alive: list[str] = []
|
||||
if morse_decoder_worker and morse_decoder_worker.is_alive():
|
||||
@@ -238,8 +248,15 @@ def start_morse() -> Response:
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate detect_mode first — it determines frequency limits.
|
||||
try:
|
||||
freq = validate_frequency(data.get('frequency', '14.060'), min_mhz=0.5, max_mhz=30.0)
|
||||
detect_mode = _validate_detect_mode(data.get('detect_mode', 'goertzel'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
freq_max = 1766.0 if detect_mode == 'envelope' else 30.0
|
||||
try:
|
||||
freq = validate_frequency(data.get('frequency', '14.060'), min_mhz=0.5, max_mhz=freq_max)
|
||||
gain = validate_gain(data.get('gain', '0'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
@@ -264,6 +281,10 @@ def start_morse() -> Response:
|
||||
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
|
||||
# Check for rtl_tcp (remote SDR) connection
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||
|
||||
with app_module.morse_lock:
|
||||
if morse_state in {MORSE_STARTING, MORSE_RUNNING, MORSE_STOPPING}:
|
||||
return jsonify({
|
||||
@@ -272,24 +293,34 @@ def start_morse() -> Response:
|
||||
'state': morse_state,
|
||||
}), 409
|
||||
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'morse', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error,
|
||||
}), 409
|
||||
# Reserve SDR device (skip for remote rtl_tcp)
|
||||
if not rtl_tcp_host:
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'morse', sdr_type_str)
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error,
|
||||
}), 409
|
||||
|
||||
morse_active_device = device_int
|
||||
morse_active_sdr_type = sdr_type_str
|
||||
morse_active_device = device_int
|
||||
morse_active_sdr_type = sdr_type_str
|
||||
morse_last_error = ''
|
||||
morse_session_id += 1
|
||||
|
||||
_drain_queue(app_module.morse_queue)
|
||||
_set_state(MORSE_STARTING, 'Starting decoder...')
|
||||
|
||||
sample_rate = 22050
|
||||
# Envelope mode (OOK/AM): use AM demod, higher sample rate for better
|
||||
# envelope resolution. Goertzel mode (HF CW): use USB demod.
|
||||
if detect_mode == 'envelope':
|
||||
sample_rate = 48000
|
||||
modulation = 'am'
|
||||
else:
|
||||
sample_rate = 22050
|
||||
modulation = 'usb'
|
||||
|
||||
bias_t = _bool_value(data.get('bias_t', False), False)
|
||||
|
||||
try:
|
||||
@@ -297,23 +328,35 @@ def start_morse() -> Response:
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
# Create network or local SDR device
|
||||
network_sdr_device = None
|
||||
if rtl_tcp_host:
|
||||
try:
|
||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
network_sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||
|
||||
requested_device_index = int(device)
|
||||
active_device_index = requested_device_index
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
builder = SDRFactory.get_builder(network_sdr_device.sdr_type if network_sdr_device else sdr_type)
|
||||
|
||||
device_catalog: dict[int, dict[str, str]] = {}
|
||||
candidate_device_indices: list[int] = [requested_device_index]
|
||||
with contextlib.suppress(Exception):
|
||||
detected_devices = SDRFactory.detect_devices()
|
||||
same_type_devices = [d for d in detected_devices if d.sdr_type == sdr_type]
|
||||
for d in same_type_devices:
|
||||
device_catalog[d.index] = {
|
||||
'name': str(d.name or f'SDR {d.index}'),
|
||||
'serial': str(d.serial or 'Unknown'),
|
||||
}
|
||||
for d in sorted(same_type_devices, key=lambda dev: dev.index):
|
||||
if d.index not in candidate_device_indices:
|
||||
candidate_device_indices.append(d.index)
|
||||
if not network_sdr_device:
|
||||
with contextlib.suppress(Exception):
|
||||
detected_devices = SDRFactory.detect_devices()
|
||||
same_type_devices = [d for d in detected_devices if d.sdr_type == sdr_type]
|
||||
for d in same_type_devices:
|
||||
device_catalog[d.index] = {
|
||||
'name': str(d.name or f'SDR {d.index}'),
|
||||
'serial': str(d.serial or 'Unknown'),
|
||||
}
|
||||
for d in sorted(same_type_devices, key=lambda dev: dev.index):
|
||||
if d.index not in candidate_device_indices:
|
||||
candidate_device_indices.append(d.index)
|
||||
|
||||
def _device_label(device_index: int) -> str:
|
||||
meta = device_catalog.get(device_index, {})
|
||||
@@ -322,15 +365,19 @@ def start_morse() -> Response:
|
||||
return f'device {device_index} ({name}, SN: {serial})'
|
||||
|
||||
def _build_rtl_cmd(device_index: int, direct_sampling_mode: int | None) -> list[str]:
|
||||
tuned_frequency_mhz = max(0.5, float(freq) - (float(tone_freq) / 1_000_000.0))
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_index)
|
||||
# Envelope mode tunes directly to center freq (no tone offset).
|
||||
if detect_mode == 'envelope':
|
||||
tuned_frequency_mhz = max(0.5, float(freq))
|
||||
else:
|
||||
tuned_frequency_mhz = max(0.5, float(freq) - (float(tone_freq) / 1_000_000.0))
|
||||
sdr_device = network_sdr_device or SDRFactory.create_default_device(sdr_type, index=device_index)
|
||||
fm_kwargs: dict[str, Any] = {
|
||||
'device': sdr_device,
|
||||
'frequency_mhz': tuned_frequency_mhz,
|
||||
'sample_rate': sample_rate,
|
||||
'gain': float(gain) if gain and gain != '0' else None,
|
||||
'ppm': int(ppm) if ppm and ppm != '0' else None,
|
||||
'modulation': 'usb',
|
||||
'modulation': modulation,
|
||||
'bias_t': bias_t,
|
||||
}
|
||||
if direct_sampling_mode in (1, 2):
|
||||
@@ -342,13 +389,19 @@ def start_morse() -> Response:
|
||||
cmd.append('-')
|
||||
return cmd
|
||||
|
||||
can_try_direct_sampling = bool(sdr_type == SDRType.RTL_SDR and float(freq) < 24.0)
|
||||
can_try_direct_sampling = bool(
|
||||
sdr_type == SDRType.RTL_SDR
|
||||
and detect_mode != 'envelope' # direct sampling is HF-only
|
||||
and float(freq) < 24.0
|
||||
)
|
||||
direct_sampling_attempts: list[int | None] = [2, 1, None] if can_try_direct_sampling else [None]
|
||||
|
||||
runtime_config: dict[str, Any] = {
|
||||
'sample_rate': sample_rate,
|
||||
'detect_mode': detect_mode,
|
||||
'modulation': modulation,
|
||||
'rf_frequency_mhz': float(freq),
|
||||
'tuned_frequency_mhz': max(0.5, float(freq) - (float(tone_freq) / 1_000_000.0)),
|
||||
'tuned_frequency_mhz': max(0.5, float(freq)) if detect_mode == 'envelope' else max(0.5, float(freq) - (float(tone_freq) / 1_000_000.0)),
|
||||
'tone_freq': tone_freq,
|
||||
'wpm': wpm,
|
||||
'bandwidth_hz': bandwidth_hz,
|
||||
@@ -663,6 +716,8 @@ def start_morse() -> Response:
|
||||
'status': 'started',
|
||||
'state': MORSE_RUNNING,
|
||||
'command': full_cmd,
|
||||
'detect_mode': detect_mode,
|
||||
'modulation': modulation,
|
||||
'tone_freq': tone_freq,
|
||||
'wpm': wpm,
|
||||
'config': runtime_config,
|
||||
|
||||
@@ -12,8 +12,8 @@ import os
|
||||
import queue
|
||||
import shutil
|
||||
import socket
|
||||
import sys
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
@@ -29,10 +29,16 @@ from utils.constants import (
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
)
|
||||
from utils.gps import is_gpsd_running
|
||||
from utils.logging import get_logger
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.validation import validate_device_index, validate_gain
|
||||
from utils.validation import (
|
||||
validate_device_index,
|
||||
validate_gain,
|
||||
validate_latitude,
|
||||
validate_longitude,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.radiosonde')
|
||||
|
||||
@@ -83,6 +89,10 @@ def generate_station_cfg(
|
||||
ppm: int = 0,
|
||||
bias_t: bool = False,
|
||||
udp_port: int = RADIOSONDE_UDP_PORT,
|
||||
latitude: float = 0.0,
|
||||
longitude: float = 0.0,
|
||||
station_alt: float = 0.0,
|
||||
gpsd_enabled: bool = False,
|
||||
) -> str:
|
||||
"""Generate a station.cfg for radiosonde_auto_rx and return the file path."""
|
||||
cfg_dir = os.path.abspath(os.path.join('data', 'radiosonde'))
|
||||
@@ -116,10 +126,10 @@ always_scan = []
|
||||
always_decode = []
|
||||
|
||||
[location]
|
||||
station_lat = 0.0
|
||||
station_lon = 0.0
|
||||
station_alt = 0.0
|
||||
gpsd_enabled = False
|
||||
station_lat = {latitude}
|
||||
station_lon = {longitude}
|
||||
station_alt = {station_alt}
|
||||
gpsd_enabled = {str(gpsd_enabled)}
|
||||
gpsd_host = localhost
|
||||
gpsd_port = 2947
|
||||
|
||||
@@ -471,6 +481,20 @@ def start_radiosonde():
|
||||
bias_t = data.get('bias_t', False)
|
||||
ppm = int(data.get('ppm', 0))
|
||||
|
||||
# Validate optional location
|
||||
latitude = 0.0
|
||||
longitude = 0.0
|
||||
if data.get('latitude') is not None and data.get('longitude') is not None:
|
||||
try:
|
||||
latitude = validate_latitude(data['latitude'])
|
||||
longitude = validate_longitude(data['longitude'])
|
||||
except ValueError:
|
||||
latitude = 0.0
|
||||
longitude = 0.0
|
||||
|
||||
# Check if gpsd is available for live position updates
|
||||
gpsd_enabled = is_gpsd_running()
|
||||
|
||||
# Find auto_rx
|
||||
auto_rx_path = find_auto_rx()
|
||||
if not auto_rx_path:
|
||||
@@ -515,6 +539,9 @@ def start_radiosonde():
|
||||
device_index=device_int,
|
||||
ppm=ppm,
|
||||
bias_t=bias_t,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
gpsd_enabled=gpsd_enabled,
|
||||
)
|
||||
|
||||
# Build command - auto_rx -c expects a file path, not a directory
|
||||
|
||||
6
setup.sh
6
setup.sh
@@ -337,7 +337,8 @@ install_python_deps() {
|
||||
info "Installing optional packages..."
|
||||
for pkg in "numpy>=1.24.0" "scipy>=1.10.0" "Pillow>=9.0.0" "skyfield>=1.45" \
|
||||
"bleak>=0.21.0" "psycopg2-binary>=2.9.9" "meshtastic>=2.0.0" \
|
||||
"scapy>=2.4.5" "qrcode[pil]>=7.4" "cryptography>=41.0.0"; do
|
||||
"scapy>=2.4.5" "qrcode[pil]>=7.4" "cryptography>=41.0.0" \
|
||||
"gunicorn>=21.2.0" "gevent>=23.9.0" "psutil>=5.9.0"; do
|
||||
pkg_name="${pkg%%>=*}"
|
||||
if ! $PIP install "$pkg" 2>/dev/null; then
|
||||
warn "${pkg_name} failed to install (optional - related features may be unavailable)"
|
||||
@@ -1590,6 +1591,9 @@ final_summary_and_hard_fail() {
|
||||
echo "============================================"
|
||||
echo
|
||||
echo "To start INTERCEPT:"
|
||||
echo " sudo ./start.sh"
|
||||
echo
|
||||
echo "Or for quick local dev:"
|
||||
echo " sudo -E venv/bin/python intercept.py"
|
||||
echo
|
||||
echo "Then open http://localhost:5050 in your browser"
|
||||
|
||||
161
start.sh
Executable file
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() {
|
||||
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() {
|
||||
return {
|
||||
var config = {
|
||||
frequency: (el('morseFrequency') && el('morseFrequency').value) || '14.060',
|
||||
gain: (el('morseGain') && el('morseGain').value) || '40',
|
||||
ppm: (el('morsePPM') && el('morsePPM').value) || '0',
|
||||
device: (el('deviceSelect') && el('deviceSelect').value) || '0',
|
||||
sdr_type: (el('sdrTypeSelect') && el('sdrTypeSelect').value) || 'rtlsdr',
|
||||
bias_t: (typeof getBiasTEnabled === 'function') ? getBiasTEnabled() : false,
|
||||
detect_mode: (el('morseDetectMode') && el('morseDetectMode').value) || 'goertzel',
|
||||
tone_freq: (el('morseToneFreq') && el('morseToneFreq').value) || '700',
|
||||
bandwidth_hz: (el('morseBandwidth') && el('morseBandwidth').value) || '200',
|
||||
auto_tone_track: !!(el('morseAutoToneTrack') && el('morseAutoToneTrack').checked),
|
||||
@@ -116,6 +117,17 @@ var MorseMode = (function () {
|
||||
wpm: (el('morseWpm') && el('morseWpm').value) || '15',
|
||||
wpm_lock: !!(el('morseWpmLock') && el('morseWpmLock').checked),
|
||||
};
|
||||
|
||||
// Add rtl_tcp params if using remote SDR
|
||||
if (typeof getRemoteSDRConfig === 'function') {
|
||||
var remoteConfig = getRemoteSDRConfig();
|
||||
if (remoteConfig) {
|
||||
config.rtl_tcp_host = remoteConfig.host;
|
||||
config.rtl_tcp_port = remoteConfig.port;
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function persistSettings() {
|
||||
@@ -124,6 +136,7 @@ var MorseMode = (function () {
|
||||
frequency: (el('morseFrequency') && el('morseFrequency').value) || '14.060',
|
||||
gain: (el('morseGain') && el('morseGain').value) || '40',
|
||||
ppm: (el('morsePPM') && el('morsePPM').value) || '0',
|
||||
detect_mode: (el('morseDetectMode') && el('morseDetectMode').value) || 'goertzel',
|
||||
tone_freq: (el('morseToneFreq') && el('morseToneFreq').value) || '700',
|
||||
bandwidth_hz: (el('morseBandwidth') && el('morseBandwidth').value) || '200',
|
||||
auto_tone_track: !!(el('morseAutoToneTrack') && el('morseAutoToneTrack').checked),
|
||||
@@ -167,6 +180,9 @@ var MorseMode = (function () {
|
||||
if (el('morseShowRaw') && settings.show_raw !== undefined) el('morseShowRaw').checked = !!settings.show_raw;
|
||||
if (el('morseShowDiag') && settings.show_diag !== undefined) el('morseShowDiag').checked = !!settings.show_diag;
|
||||
|
||||
if (settings.detect_mode) {
|
||||
setDetectMode(settings.detect_mode);
|
||||
}
|
||||
updateToneLabel((el('morseToneFreq') && el('morseToneFreq').value) || '700');
|
||||
updateWpmLabel((el('morseWpm') && el('morseWpm').value) || '15');
|
||||
onThresholdModeChange();
|
||||
@@ -198,10 +214,11 @@ var MorseMode = (function () {
|
||||
state.controlsBound = true;
|
||||
|
||||
var ids = [
|
||||
'morseFrequency', 'morseGain', 'morsePPM', 'morseToneFreq', 'morseBandwidth',
|
||||
'morseAutoToneTrack', 'morseToneLock', 'morseThresholdMode', 'morseManualThreshold',
|
||||
'morseThresholdMultiplier', 'morseThresholdOffset', 'morseSignalGate',
|
||||
'morseWpmMode', 'morseWpm', 'morseWpmLock', 'morseShowRaw', 'morseShowDiag'
|
||||
'morseFrequency', 'morseGain', 'morsePPM', 'morseDetectMode', 'morseToneFreq',
|
||||
'morseBandwidth', 'morseAutoToneTrack', 'morseToneLock', 'morseThresholdMode',
|
||||
'morseManualThreshold', 'morseThresholdMultiplier', 'morseThresholdOffset',
|
||||
'morseSignalGate', 'morseWpmMode', 'morseWpm', 'morseWpmLock',
|
||||
'morseShowRaw', 'morseShowDiag'
|
||||
];
|
||||
|
||||
ids.forEach(function (id) {
|
||||
@@ -1199,12 +1216,80 @@ var MorseMode = (function () {
|
||||
});
|
||||
}
|
||||
|
||||
function setDetectMode(mode) {
|
||||
var hidden = el('morseDetectMode');
|
||||
if (hidden) hidden.value = mode;
|
||||
|
||||
// Update toggle button styles
|
||||
var btnGoertzel = el('morseDetectGoertzel');
|
||||
var btnEnvelope = el('morseDetectEnvelope');
|
||||
if (btnGoertzel && btnEnvelope) {
|
||||
if (mode === 'envelope') {
|
||||
btnEnvelope.style.background = 'var(--accent)';
|
||||
btnEnvelope.style.color = '#000';
|
||||
btnGoertzel.style.background = '';
|
||||
btnGoertzel.style.color = '';
|
||||
} else {
|
||||
btnGoertzel.style.background = 'var(--accent)';
|
||||
btnGoertzel.style.color = '#000';
|
||||
btnEnvelope.style.background = '';
|
||||
btnEnvelope.style.color = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle preset groups
|
||||
var hfPresets = el('morseHFPresets');
|
||||
var ismPresets = el('morseISMPresets');
|
||||
if (hfPresets) hfPresets.style.display = mode === 'envelope' ? 'none' : 'flex';
|
||||
if (ismPresets) ismPresets.style.display = mode === 'envelope' ? 'flex' : 'none';
|
||||
|
||||
// Toggle CW detector section (tone freq, bandwidth, tone track -- not needed for envelope)
|
||||
var toneGroup = el('morseToneFreqGroup');
|
||||
if (toneGroup) toneGroup.style.display = mode === 'envelope' ? 'none' : '';
|
||||
|
||||
// Toggle antenna notes
|
||||
var hfNote = el('morseHFNote');
|
||||
var envNote = el('morseEnvelopeNote');
|
||||
if (hfNote) hfNote.style.display = mode === 'envelope' ? 'none' : '';
|
||||
if (envNote) envNote.style.display = mode === 'envelope' ? '' : 'none';
|
||||
|
||||
// Update hint text
|
||||
var hint = el('morseDetectHint');
|
||||
if (hint) {
|
||||
hint.textContent = mode === 'envelope'
|
||||
? 'OOK Envelope: AM demod, RMS detection. For ISM-band OOK/CW.'
|
||||
: 'CW Tone: HF bands, USB demod, Goertzel filter. For amateur CW.';
|
||||
}
|
||||
|
||||
// Set sensible default frequency when switching modes
|
||||
var freqEl = el('morseFrequency');
|
||||
if (freqEl) {
|
||||
var curFreq = parseFloat(freqEl.value);
|
||||
if (mode === 'envelope' && curFreq < 30) {
|
||||
freqEl.value = '433.300';
|
||||
} else if (mode === 'goertzel' && curFreq > 30) {
|
||||
freqEl.value = '14.060';
|
||||
}
|
||||
}
|
||||
|
||||
// Set WPM default for envelope mode (OOK transmitters tend to be slower)
|
||||
var wpmEl = el('morseWpm');
|
||||
var wpmLabel = el('morseWpmLabel');
|
||||
if (mode === 'envelope' && wpmEl) {
|
||||
wpmEl.value = '12';
|
||||
if (wpmLabel) wpmLabel.textContent = '12';
|
||||
}
|
||||
|
||||
persistSettings();
|
||||
}
|
||||
|
||||
return {
|
||||
init: init,
|
||||
destroy: destroy,
|
||||
start: start,
|
||||
stop: stop,
|
||||
setFreq: setFreq,
|
||||
setDetectMode: setDetectMode,
|
||||
exportTxt: exportTxt,
|
||||
exportCsv: exportCsv,
|
||||
copyToClipboard: copyToClipboard,
|
||||
|
||||
@@ -1179,6 +1179,19 @@
|
||||
const isAgentMode = typeof aisCurrentAgent !== 'undefined' && aisCurrentAgent !== 'local';
|
||||
dscCurrentAgent = isAgentMode ? aisCurrentAgent : null;
|
||||
|
||||
// Check for remote SDR (only for local mode)
|
||||
const remoteConfig = (!isAgentMode && typeof getRemoteSDRConfig === 'function')
|
||||
? getRemoteSDRConfig() : null;
|
||||
if (remoteConfig === false) return; // Validation failed
|
||||
|
||||
const requestBody = { device, gain };
|
||||
|
||||
// Add rtl_tcp params if using remote SDR
|
||||
if (remoteConfig) {
|
||||
requestBody.rtl_tcp_host = remoteConfig.host;
|
||||
requestBody.rtl_tcp_port = remoteConfig.port;
|
||||
}
|
||||
|
||||
// Determine endpoint based on agent mode
|
||||
const endpoint = isAgentMode
|
||||
? `/controller/agents/${aisCurrentAgent}/dsc/start`
|
||||
@@ -1187,7 +1200,7 @@
|
||||
fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ device, gain })
|
||||
body: JSON.stringify(requestBody)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
|
||||
@@ -4296,6 +4296,10 @@
|
||||
const sensorTimelineContainer = document.getElementById('sensorTimelineContainer');
|
||||
if (pagerTimelineContainer) pagerTimelineContainer.style.display = mode === 'pager' ? 'block' : 'none';
|
||||
if (sensorTimelineContainer) sensorTimelineContainer.style.display = mode === 'sensor' ? 'block' : 'none';
|
||||
const pagerScopePanel = document.getElementById('pagerScopePanel');
|
||||
if (pagerScopePanel && mode !== 'pager') pagerScopePanel.style.display = 'none';
|
||||
const sensorScopePanel = document.getElementById('sensorScopePanel');
|
||||
if (sensorScopePanel && mode !== 'sensor') sensorScopePanel.style.display = 'none';
|
||||
const morseScopePanel = document.getElementById('morseScopePanel');
|
||||
const morseOutputPanel = document.getElementById('morseOutputPanel');
|
||||
if (morseScopePanel && mode !== 'morse') morseScopePanel.style.display = 'none';
|
||||
@@ -9793,6 +9797,10 @@
|
||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||
aprsCurrentAgent = isAgentMode ? currentAgent : null;
|
||||
|
||||
// Check for remote SDR (only for local mode)
|
||||
const remoteConfig = isAgentMode ? null : getRemoteSDRConfig();
|
||||
if (remoteConfig === false) return; // Validation failed
|
||||
|
||||
// Build request body
|
||||
const requestBody = {
|
||||
region,
|
||||
@@ -9801,6 +9809,12 @@
|
||||
sdr_type: sdrType
|
||||
};
|
||||
|
||||
// Add rtl_tcp params if using remote SDR
|
||||
if (remoteConfig) {
|
||||
requestBody.rtl_tcp_host = remoteConfig.host;
|
||||
requestBody.rtl_tcp_port = remoteConfig.port;
|
||||
}
|
||||
|
||||
// Add custom frequency if selected
|
||||
if (region === 'custom') {
|
||||
const customFreq = document.getElementById('aprsStripCustomFreq').value;
|
||||
|
||||
@@ -3,21 +3,39 @@
|
||||
<div class="section">
|
||||
<h3>CW/Morse Decoder</h3>
|
||||
<p class="info-text morse-mode-help">
|
||||
Decode CW (continuous wave) Morse with USB demod + Goertzel tone detection.
|
||||
Start with 700 Hz tone and 200 Hz bandwidth.
|
||||
Decode CW (continuous wave) Morse code. Supports HF amateur bands (USB + Goertzel tone
|
||||
detection) and ISM/UHF OOK signals (AM + envelope detection).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Detection Mode</h3>
|
||||
<div class="form-group">
|
||||
<div style="display: flex; gap: 4px;">
|
||||
<button class="preset-btn morseDetectBtn" id="morseDetectGoertzel"
|
||||
onclick="MorseMode.setDetectMode('goertzel')"
|
||||
style="flex: 1; background: var(--accent); color: #000;">CW Tone</button>
|
||||
<button class="preset-btn morseDetectBtn" id="morseDetectEnvelope"
|
||||
onclick="MorseMode.setDetectMode('envelope')"
|
||||
style="flex: 1;">OOK Envelope</button>
|
||||
</div>
|
||||
<input type="hidden" id="morseDetectMode" value="goertzel">
|
||||
<p id="morseDetectHint" class="info-text" style="font-size: 10px; color: var(--text-dim); margin-top: 4px;">
|
||||
CW Tone: HF bands, USB demod, Goertzel filter. For amateur CW.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Frequency</h3>
|
||||
<div class="form-group">
|
||||
<label>Frequency (MHz)</label>
|
||||
<input type="number" id="morseFrequency" value="14.060" step="0.001" min="0.5" max="30" placeholder="e.g., 14.060">
|
||||
<input type="number" id="morseFrequency" value="14.060" step="0.001" min="0.5" max="1766" placeholder="e.g., 14.060">
|
||||
<span class="help-text morse-help-text">Enter CW center frequency in MHz (e.g., 7.030 for 40m).</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Band Presets</label>
|
||||
<div class="morse-presets">
|
||||
<div class="morse-presets" id="morseHFPresets">
|
||||
<button class="preset-btn" onclick="MorseMode.setFreq(3.560)">80m</button>
|
||||
<button class="preset-btn" onclick="MorseMode.setFreq(7.030)">40m</button>
|
||||
<button class="preset-btn" onclick="MorseMode.setFreq(10.116)">30m</button>
|
||||
@@ -27,6 +45,13 @@
|
||||
<button class="preset-btn" onclick="MorseMode.setFreq(24.910)">12m</button>
|
||||
<button class="preset-btn" onclick="MorseMode.setFreq(28.060)">10m</button>
|
||||
</div>
|
||||
<div class="morse-presets" id="morseISMPresets" style="display: none; flex-wrap: wrap; gap: 4px;">
|
||||
<button class="preset-btn" onclick="MorseMode.setFreq(315.000)">315</button>
|
||||
<button class="preset-btn" onclick="MorseMode.setFreq(433.300)">433.3</button>
|
||||
<button class="preset-btn" onclick="MorseMode.setFreq(433.920)">433.9</button>
|
||||
<button class="preset-btn" onclick="MorseMode.setFreq(868.000)">868</button>
|
||||
<button class="preset-btn" onclick="MorseMode.setFreq(915.000)">915</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +67,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section" id="morseToneFreqGroup">
|
||||
<h3>CW Detector</h3>
|
||||
<div class="form-group">
|
||||
<label>Tone Frequency: <span id="morseToneFreqLabel">700</span> Hz</label>
|
||||
@@ -154,12 +179,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section" id="morseHFNote">
|
||||
<p class="info-text morse-hf-note">
|
||||
CW on HF (1-30 MHz) requires an HF-capable SDR path (direct sampling or upconverter)
|
||||
and an appropriate antenna.
|
||||
</p>
|
||||
</div>
|
||||
<div class="section" id="morseEnvelopeNote" style="display: none;">
|
||||
<p class="info-text" style="font-size: 11px; color: #ffaa00; line-height: 1.5;">
|
||||
OOK Envelope mode uses AM demodulation to detect carrier on/off keying.
|
||||
Suitable for ISM-band (315/433/868/915 MHz) Morse transmitters.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="run-btn" id="morseStartBtn" onclick="MorseMode.start()">Start Decoder</button>
|
||||
<button class="stop-btn" id="morseStopBtn" onclick="MorseMode.stop()" style="display: none;">Stop Decoder</button>
|
||||
|
||||
@@ -148,6 +148,8 @@
|
||||
freq_min: freqMin,
|
||||
freq_max: freqMax,
|
||||
bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false,
|
||||
latitude: radiosondeStationLocation.lat,
|
||||
longitude: radiosondeStationLocation.lon,
|
||||
})
|
||||
})
|
||||
.then(r => r.json())
|
||||
@@ -230,15 +232,23 @@
|
||||
let radiosondeMarkers = new Map();
|
||||
let radiosondeTracks = new Map();
|
||||
let radiosondeTrackPoints = new Map();
|
||||
let radiosondeStationLocation = { lat: 0, lon: 0 };
|
||||
let radiosondeStationMarker = null;
|
||||
|
||||
function initRadiosondeMap() {
|
||||
if (radiosondeMap) return;
|
||||
const container = document.getElementById('radiosondeMapContainer');
|
||||
if (!container) return;
|
||||
|
||||
// Resolve observer location
|
||||
if (window.ObserverLocation && ObserverLocation.getForModule) {
|
||||
radiosondeStationLocation = ObserverLocation.getForModule('radiosonde_observerLocation');
|
||||
}
|
||||
const hasLocation = radiosondeStationLocation.lat !== 0 || radiosondeStationLocation.lon !== 0;
|
||||
|
||||
radiosondeMap = L.map('radiosondeMapContainer', {
|
||||
center: [40, -95],
|
||||
zoom: 4,
|
||||
center: hasLocation ? [radiosondeStationLocation.lat, radiosondeStationLocation.lon] : [40, -95],
|
||||
zoom: hasLocation ? 7 : 4,
|
||||
zoomControl: true,
|
||||
});
|
||||
|
||||
@@ -246,6 +256,50 @@
|
||||
attribution: '© OpenStreetMap © CARTO',
|
||||
maxZoom: 18,
|
||||
}).addTo(radiosondeMap);
|
||||
|
||||
// Add station marker if we have a location
|
||||
if (hasLocation) {
|
||||
radiosondeStationMarker = L.circleMarker(
|
||||
[radiosondeStationLocation.lat, radiosondeStationLocation.lon], {
|
||||
radius: 8,
|
||||
fillColor: '#00e5ff',
|
||||
color: '#00e5ff',
|
||||
weight: 2,
|
||||
opacity: 1,
|
||||
fillOpacity: 0.5,
|
||||
}).addTo(radiosondeMap);
|
||||
radiosondeStationMarker.bindTooltip('Station', { permanent: false, direction: 'top' });
|
||||
}
|
||||
|
||||
// Try GPS for live position updates
|
||||
fetch('/gps/position')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'ok' && data.position && data.position.latitude != null) {
|
||||
radiosondeStationLocation = { lat: data.position.latitude, lon: data.position.longitude };
|
||||
const ll = [data.position.latitude, data.position.longitude];
|
||||
if (radiosondeStationMarker) {
|
||||
radiosondeStationMarker.setLatLng(ll);
|
||||
} else {
|
||||
radiosondeStationMarker = L.circleMarker(ll, {
|
||||
radius: 8,
|
||||
fillColor: '#00e5ff',
|
||||
color: '#00e5ff',
|
||||
weight: 2,
|
||||
opacity: 1,
|
||||
fillOpacity: 0.5,
|
||||
}).addTo(radiosondeMap);
|
||||
radiosondeStationMarker.bindTooltip('Station (GPS)', { permanent: false, direction: 'top' });
|
||||
}
|
||||
if (!radiosondeMap._gpsInitialized) {
|
||||
radiosondeMap.setView(ll, 7);
|
||||
radiosondeMap._gpsInitialized = true;
|
||||
}
|
||||
// Re-render cards with updated distances
|
||||
updateRadiosondeCards();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function updateRadiosondeMap(balloon) {
|
||||
@@ -284,12 +338,19 @@
|
||||
const tempStr = balloon.temp != null ? `${balloon.temp.toFixed(1)} °C` : '--';
|
||||
const humStr = balloon.humidity != null ? `${balloon.humidity.toFixed(0)}%` : '--';
|
||||
const velStr = balloon.vel_v != null ? `${balloon.vel_v.toFixed(1)} m/s` : '--';
|
||||
let distStr = '';
|
||||
if ((radiosondeStationLocation.lat !== 0 || radiosondeStationLocation.lon !== 0) && balloon.lat && balloon.lon) {
|
||||
const distM = radiosondeMap.distance(
|
||||
[radiosondeStationLocation.lat, radiosondeStationLocation.lon], latlng);
|
||||
distStr = `Dist: ${(distM / 1000).toFixed(1)} km<br>`;
|
||||
}
|
||||
radiosondeMarkers.get(id).bindPopup(
|
||||
`<strong>${id}</strong><br>` +
|
||||
`Type: ${balloon.sonde_type || '--'}<br>` +
|
||||
`Alt: ${altStr}<br>` +
|
||||
`Temp: ${tempStr} | Hum: ${humStr}<br>` +
|
||||
`Vert: ${velStr}<br>` +
|
||||
distStr +
|
||||
(balloon.freq ? `Freq: ${balloon.freq.toFixed(3)} MHz` : '')
|
||||
);
|
||||
|
||||
@@ -322,6 +383,7 @@
|
||||
if (!container) return;
|
||||
|
||||
const sorted = Object.values(radiosondeBalloons).sort((a, b) => (b.alt || 0) - (a.alt || 0));
|
||||
const hasStation = radiosondeStationLocation.lat !== 0 || radiosondeStationLocation.lon !== 0;
|
||||
container.innerHTML = sorted.map(b => {
|
||||
const alt = b.alt ? `${Math.round(b.alt).toLocaleString()} m` : '--';
|
||||
const temp = b.temp != null ? `${b.temp.toFixed(1)}°C` : '--';
|
||||
@@ -329,6 +391,13 @@
|
||||
const press = b.pressure != null ? `${b.pressure.toFixed(1)} hPa` : '--';
|
||||
const vel = b.vel_v != null ? `${b.vel_v > 0 ? '+' : ''}${b.vel_v.toFixed(1)} m/s` : '--';
|
||||
const freq = b.freq ? `${b.freq.toFixed(3)} MHz` : '--';
|
||||
let dist = '--';
|
||||
if (hasStation && b.lat && b.lon && radiosondeMap) {
|
||||
const distM = radiosondeMap.distance(
|
||||
[radiosondeStationLocation.lat, radiosondeStationLocation.lon],
|
||||
[b.lat, b.lon]);
|
||||
dist = `${(distM / 1000).toFixed(1)} km`;
|
||||
}
|
||||
return `
|
||||
<div class="radiosonde-card" onclick="radiosondeMap && radiosondeMap.setView([${b.lat || 0}, ${b.lon || 0}], 10)">
|
||||
<div class="radiosonde-card-header">
|
||||
@@ -360,6 +429,10 @@
|
||||
<span class="radiosonde-stat-value">${freq}</span>
|
||||
<span class="radiosonde-stat-label">FREQ</span>
|
||||
</div>
|
||||
<div class="radiosonde-stat">
|
||||
<span class="radiosonde-stat-value">${dist}</span>
|
||||
<span class="radiosonde-stat-label">DIST</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -12,20 +12,18 @@ import time
|
||||
import wave
|
||||
from collections import Counter
|
||||
|
||||
import pytest
|
||||
|
||||
import app as app_module
|
||||
import routes.morse as morse_routes
|
||||
from utils.morse import (
|
||||
CHAR_TO_MORSE,
|
||||
MORSE_TABLE,
|
||||
EnvelopeDetector,
|
||||
GoertzelFilter,
|
||||
MorseDecoder,
|
||||
decode_morse_wav_file,
|
||||
morse_decoder_thread,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -133,6 +131,93 @@ class TestToneDetector:
|
||||
assert gf.magnitude(on_tone) > gf.magnitude(off_tone) * 3.0
|
||||
|
||||
|
||||
class TestEnvelopeDetector:
|
||||
def test_magnitude_of_silence_is_near_zero(self):
|
||||
det = EnvelopeDetector(block_size=160)
|
||||
silence = [0.0] * 160
|
||||
assert det.magnitude(silence) < 1e-6
|
||||
|
||||
def test_magnitude_of_constant_amplitude(self):
|
||||
det = EnvelopeDetector(block_size=160)
|
||||
loud = [0.8] * 160
|
||||
mag = det.magnitude(loud)
|
||||
assert abs(mag - 0.8) < 0.01
|
||||
|
||||
def test_magnitude_of_sine_wave(self):
|
||||
det = EnvelopeDetector(block_size=160)
|
||||
samples = [0.5 * math.sin(2 * math.pi * 700 * i / 8000.0) for i in range(160)]
|
||||
mag = det.magnitude(samples)
|
||||
# RMS of a sine at amplitude 0.5 is 0.5/sqrt(2) ~ 0.354
|
||||
assert 0.30 < mag < 0.40
|
||||
|
||||
def test_magnitude_with_numpy_array(self):
|
||||
import numpy as np
|
||||
det = EnvelopeDetector(block_size=100)
|
||||
arr = np.ones(100, dtype=np.float64) * 0.6
|
||||
assert abs(det.magnitude(arr) - 0.6) < 0.01
|
||||
|
||||
def test_empty_samples_returns_zero(self):
|
||||
det = EnvelopeDetector(block_size=0)
|
||||
assert det.magnitude([]) == 0.0
|
||||
|
||||
|
||||
class TestEnvelopeMorseDecoder:
|
||||
def test_envelope_decoder_detects_ook_elements(self):
|
||||
"""Verify envelope mode can distinguish on/off keying."""
|
||||
sample_rate = 48000
|
||||
wpm = 15
|
||||
dit_dur = 1.2 / wpm
|
||||
|
||||
def ook_on(duration):
|
||||
n = int(sample_rate * duration)
|
||||
return struct.pack(f'<{n}h', *([int(0.7 * 32767)] * n))
|
||||
|
||||
def ook_off(duration):
|
||||
n = int(sample_rate * duration)
|
||||
return b'\x00\x00' * n
|
||||
|
||||
# Generate dit-dah (A = .-)
|
||||
audio = (
|
||||
ook_off(0.3)
|
||||
+ ook_on(dit_dur)
|
||||
+ ook_off(dit_dur)
|
||||
+ ook_on(3 * dit_dur)
|
||||
+ ook_off(0.5)
|
||||
)
|
||||
|
||||
decoder = MorseDecoder(
|
||||
sample_rate=sample_rate,
|
||||
tone_freq=700.0,
|
||||
wpm=wpm,
|
||||
detect_mode='envelope',
|
||||
)
|
||||
events = decoder.process_block(audio)
|
||||
events.extend(decoder.flush())
|
||||
elements = [e['element'] for e in events if e.get('type') == 'morse_element']
|
||||
|
||||
assert '.' in elements
|
||||
assert '-' in elements
|
||||
|
||||
def test_envelope_metrics_have_zero_snr(self):
|
||||
"""Envelope mode metrics should report zero SNR fields."""
|
||||
decoder = MorseDecoder(
|
||||
sample_rate=8000,
|
||||
detect_mode='envelope',
|
||||
)
|
||||
metrics = decoder.get_metrics()
|
||||
assert metrics['detect_mode'] == 'envelope'
|
||||
assert metrics['snr'] == 0.0
|
||||
assert metrics['noise_ref'] == 0.0
|
||||
|
||||
def test_goertzel_mode_unchanged(self):
|
||||
"""Default goertzel mode still works as before."""
|
||||
decoder = MorseDecoder(sample_rate=8000, wpm=15)
|
||||
assert decoder.detect_mode == 'goertzel'
|
||||
metrics = decoder.get_metrics()
|
||||
assert 'detect_mode' in metrics
|
||||
assert metrics['detect_mode'] == 'goertzel'
|
||||
|
||||
|
||||
class TestTimingAndWpmEstimator:
|
||||
def test_timing_classifier_distinguishes_dit_and_dah(self):
|
||||
decoder = MorseDecoder(sample_rate=8000, tone_freq=700.0, wpm=15)
|
||||
|
||||
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:
|
||||
- SDR audio from `rtl_fm -M usb` (16-bit LE PCM)
|
||||
- Goertzel tone detection with optional auto-tone tracking
|
||||
- Adaptive threshold + hysteresis + minimum signal gate
|
||||
- Timing estimator (auto/manual WPM) and Morse symbol decoding
|
||||
Supports two signal chains:
|
||||
goertzel: rtl_fm -M usb -> raw PCM -> Goertzel tone filter -> timing state machine -> characters
|
||||
envelope: rtl_fm -M am -> raw PCM -> RMS envelope -> timing state machine -> characters
|
||||
|
||||
Goertzel mode is the original path for HF CW (beat note detection).
|
||||
Envelope mode adds support for OOK/AM signals (e.g. 433 MHz carrier keying)
|
||||
where AM demod already produces a baseband envelope -- no tone to detect.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -80,6 +82,25 @@ class GoertzelFilter:
|
||||
return math.sqrt(max(power, 0.0))
|
||||
|
||||
|
||||
class EnvelopeDetector:
|
||||
"""RMS envelope detector for AM-demodulated OOK signals.
|
||||
|
||||
When rtl_fm uses -M am, carrier-on produces a high amplitude envelope
|
||||
and carrier-off produces near-silence. RMS over a short block gives
|
||||
a clean on/off metric without needing a specific tone frequency.
|
||||
"""
|
||||
|
||||
def __init__(self, block_size: int):
|
||||
self.block_size = block_size
|
||||
|
||||
def magnitude(self, samples: list[float] | tuple[float, ...] | np.ndarray) -> float:
|
||||
"""Compute RMS magnitude of the sample block."""
|
||||
arr = np.asarray(samples, dtype=np.float64)
|
||||
if arr.size == 0:
|
||||
return 0.0
|
||||
return float(np.sqrt(np.mean(np.square(arr))))
|
||||
|
||||
|
||||
def _goertzel_mag(samples: np.ndarray, target_freq: float, sample_rate: int) -> float:
|
||||
"""Compute Goertzel magnitude, preferring shared DSP helper."""
|
||||
if _shared_goertzel_mag is not None:
|
||||
@@ -137,10 +158,12 @@ class MorseDecoder:
|
||||
wpm_mode: str = 'auto',
|
||||
wpm_lock: bool = False,
|
||||
min_signal_gate: float = 0.0,
|
||||
detect_mode: str = 'goertzel',
|
||||
):
|
||||
self.sample_rate = int(sample_rate)
|
||||
self.tone_freq = float(tone_freq)
|
||||
self.wpm = int(wpm)
|
||||
self.detect_mode = detect_mode if detect_mode in ('goertzel', 'envelope') else 'goertzel'
|
||||
|
||||
self.bandwidth_hz = int(_clamp(float(bandwidth_hz), 50, 400))
|
||||
self.auto_tone_track = bool(auto_tone_track)
|
||||
@@ -163,17 +186,22 @@ class MorseDecoder:
|
||||
self._tone_scan_step_hz = 10.0
|
||||
self._tone_scan_interval_blocks = 8
|
||||
|
||||
self._detector = GoertzelFilter(self._active_tone_freq, self.sample_rate, self._block_size)
|
||||
self._noise_detector_low = GoertzelFilter(
|
||||
_clamp(self._active_tone_freq - max(150.0, self.bandwidth_hz), 150.0, 2000.0),
|
||||
self.sample_rate,
|
||||
self._block_size,
|
||||
)
|
||||
self._noise_detector_high = GoertzelFilter(
|
||||
_clamp(self._active_tone_freq + max(150.0, self.bandwidth_hz), 150.0, 2000.0),
|
||||
self.sample_rate,
|
||||
self._block_size,
|
||||
)
|
||||
if self.detect_mode == 'envelope':
|
||||
self._detector = EnvelopeDetector(self._block_size)
|
||||
self._noise_detector_low = None
|
||||
self._noise_detector_high = None
|
||||
else:
|
||||
self._detector = GoertzelFilter(self._active_tone_freq, self.sample_rate, self._block_size)
|
||||
self._noise_detector_low = GoertzelFilter(
|
||||
_clamp(self._active_tone_freq - max(150.0, self.bandwidth_hz), 150.0, 2000.0),
|
||||
self.sample_rate,
|
||||
self._block_size,
|
||||
)
|
||||
self._noise_detector_high = GoertzelFilter(
|
||||
_clamp(self._active_tone_freq + max(150.0, self.bandwidth_hz), 150.0, 2000.0),
|
||||
self.sample_rate,
|
||||
self._block_size,
|
||||
)
|
||||
|
||||
# AGC for weak HF/direct-sampling signals.
|
||||
self._agc_target = 0.22
|
||||
@@ -181,8 +209,14 @@ class MorseDecoder:
|
||||
self._agc_alpha = 0.06
|
||||
|
||||
# Envelope smoothing.
|
||||
self._attack_alpha = 0.55
|
||||
self._release_alpha = 0.45
|
||||
# OOK has clean binary transitions; use symmetric fast alpha.
|
||||
# HF CW has gradual fading (QSB); use asymmetric slower release.
|
||||
if self.detect_mode == 'envelope':
|
||||
self._attack_alpha = 0.55
|
||||
self._release_alpha = 0.55
|
||||
else:
|
||||
self._attack_alpha = 0.55
|
||||
self._release_alpha = 0.45
|
||||
self._envelope = 0.0
|
||||
|
||||
# Adaptive threshold model.
|
||||
@@ -203,8 +237,13 @@ class MorseDecoder:
|
||||
dit_blocks = max(1.0, dit_sec / self._block_duration)
|
||||
self._dah_threshold = 2.2 * dit_blocks
|
||||
self._dit_min = 0.38 * dit_blocks
|
||||
self._char_gap = 2.6 * dit_blocks
|
||||
self._word_gap = 6.0 * dit_blocks
|
||||
if self.detect_mode == 'envelope':
|
||||
# Tighter gaps for OOK — clean binary transitions tolerate this.
|
||||
self._char_gap = 2.0 * dit_blocks
|
||||
self._word_gap = 5.0 * dit_blocks
|
||||
else:
|
||||
self._char_gap = 2.6 * dit_blocks
|
||||
self._word_gap = 6.0 * dit_blocks
|
||||
self._dit_observations: deque[float] = deque(maxlen=32)
|
||||
self._estimated_wpm = float(self.wpm)
|
||||
|
||||
@@ -236,10 +275,7 @@ class MorseDecoder:
|
||||
|
||||
def get_metrics(self) -> dict[str, float | bool]:
|
||||
"""Return latest decoder metrics for UI/status messages."""
|
||||
snr_mult = max(1.15, self.threshold_multiplier * 0.5)
|
||||
snr_on = snr_mult * (1.0 + self._hysteresis)
|
||||
snr_off = snr_mult * (1.0 - self._hysteresis)
|
||||
return {
|
||||
metrics: dict[str, Any] = {
|
||||
'wpm': float(self._estimated_wpm),
|
||||
'tone_freq': float(self._active_tone_freq),
|
||||
'level': float(self._last_level),
|
||||
@@ -247,14 +283,27 @@ class MorseDecoder:
|
||||
'threshold': float(self._threshold),
|
||||
'tone_on': bool(self._tone_on),
|
||||
'dit_ms': float((self._effective_dit_blocks() * self._block_duration) * 1000.0),
|
||||
'snr': float(self._last_level / max(self._noise_floor, 1e-6)),
|
||||
'noise_ref': float(self._noise_floor),
|
||||
'snr_on': float(snr_on),
|
||||
'snr_off': float(snr_off),
|
||||
'detect_mode': self.detect_mode,
|
||||
}
|
||||
if self.detect_mode == 'envelope':
|
||||
metrics['snr'] = 0.0
|
||||
metrics['noise_ref'] = 0.0
|
||||
metrics['snr_on'] = 0.0
|
||||
metrics['snr_off'] = 0.0
|
||||
else:
|
||||
snr_mult = max(1.15, self.threshold_multiplier * 0.5)
|
||||
snr_on = snr_mult * (1.0 + self._hysteresis)
|
||||
snr_off = snr_mult * (1.0 - self._hysteresis)
|
||||
metrics['snr'] = float(self._last_level / max(self._noise_floor, 1e-6))
|
||||
metrics['noise_ref'] = float(self._noise_floor)
|
||||
metrics['snr_on'] = float(snr_on)
|
||||
metrics['snr_off'] = float(snr_off)
|
||||
return metrics
|
||||
|
||||
def _rebuild_detectors(self) -> None:
|
||||
"""Rebuild target/noise Goertzel filters after tone updates."""
|
||||
if self.detect_mode == 'envelope':
|
||||
return # Envelope detector is frequency-agnostic
|
||||
self._detector = GoertzelFilter(self._active_tone_freq, self.sample_rate, self._block_size)
|
||||
ref_offset = max(150.0, self.bandwidth_hz)
|
||||
self._noise_detector_low = GoertzelFilter(
|
||||
@@ -391,93 +440,143 @@ class MorseDecoder:
|
||||
self._blocks_processed += 1
|
||||
|
||||
mag = self._detector.magnitude(normalized)
|
||||
noise_low = self._noise_detector_low.magnitude(normalized)
|
||||
noise_high = self._noise_detector_high.magnitude(normalized)
|
||||
noise_ref = max(1e-9, (noise_low + noise_high) * 0.5)
|
||||
|
||||
if (
|
||||
self.auto_tone_track
|
||||
and not self.tone_lock
|
||||
and self._blocks_processed > self._WARMUP_BLOCKS
|
||||
and (self._blocks_processed % self._tone_scan_interval_blocks == 0)
|
||||
and self._estimate_tone_frequency(normalized, mag, noise_ref)
|
||||
):
|
||||
# Detector changed; refresh magnitudes for this window.
|
||||
mag = self._detector.magnitude(normalized)
|
||||
if self.detect_mode == 'envelope':
|
||||
# Envelope mode: direct magnitude threshold, no noise detectors
|
||||
noise_ref = 0.0
|
||||
level = float(mag)
|
||||
alpha = self._attack_alpha if level >= self._envelope else self._release_alpha
|
||||
self._envelope += alpha * (level - self._envelope)
|
||||
self._last_level = self._envelope
|
||||
self._last_noise_ref = 0.0
|
||||
amplitudes.append(level)
|
||||
|
||||
if self._blocks_processed <= self._WARMUP_BLOCKS:
|
||||
self._mag_min = min(self._mag_min, level)
|
||||
self._mag_max = max(self._mag_max, level)
|
||||
if self._blocks_processed == self._WARMUP_BLOCKS:
|
||||
self._noise_floor = self._mag_min if math.isfinite(self._mag_min) else 0.0
|
||||
if self._mag_max <= (self._noise_floor * 1.2):
|
||||
self._signal_peak = max(self._noise_floor + 0.5, self._noise_floor * 2.5)
|
||||
else:
|
||||
self._signal_peak = max(self._mag_max, self._noise_floor * 1.8)
|
||||
self._threshold = self._noise_floor + 0.22 * (
|
||||
self._signal_peak - self._noise_floor
|
||||
)
|
||||
tone_detected = False
|
||||
else:
|
||||
settle_alpha = 0.30 if self._blocks_processed < (self._WARMUP_BLOCKS + self._SETTLE_BLOCKS) else 0.06
|
||||
if level <= self._threshold:
|
||||
self._noise_floor += settle_alpha * (level - self._noise_floor)
|
||||
else:
|
||||
self._signal_peak += settle_alpha * (level - self._signal_peak)
|
||||
self._signal_peak = max(self._signal_peak, self._noise_floor * 1.05)
|
||||
|
||||
if self.threshold_mode == 'manual':
|
||||
self._threshold = max(0.0, self.manual_threshold)
|
||||
else:
|
||||
self._threshold = (
|
||||
max(0.0, self._noise_floor * self.threshold_multiplier)
|
||||
+ self.threshold_offset
|
||||
)
|
||||
self._threshold = max(self._threshold, self._noise_floor + 0.35)
|
||||
|
||||
dynamic_span = max(0.0, self._signal_peak - self._noise_floor)
|
||||
gate_level = self._noise_floor + (self.min_signal_gate * dynamic_span)
|
||||
gate_ok = self.min_signal_gate <= 0.0 or level >= gate_level
|
||||
|
||||
# Direct magnitude threshold with hysteresis (no SNR)
|
||||
if self._tone_on:
|
||||
tone_detected = gate_ok and level >= (self._threshold * (1.0 - self._hysteresis))
|
||||
else:
|
||||
tone_detected = gate_ok and level >= (self._threshold * (1.0 + self._hysteresis))
|
||||
else:
|
||||
# Goertzel mode: SNR-based tone detection with noise reference
|
||||
noise_low = self._noise_detector_low.magnitude(normalized)
|
||||
noise_high = self._noise_detector_high.magnitude(normalized)
|
||||
noise_ref = max(1e-9, (noise_low + noise_high) * 0.5)
|
||||
|
||||
level = float(mag)
|
||||
alpha = self._attack_alpha if level >= self._envelope else self._release_alpha
|
||||
self._envelope += alpha * (level - self._envelope)
|
||||
self._last_level = self._envelope
|
||||
self._last_noise_ref = noise_ref
|
||||
amplitudes.append(level)
|
||||
if (
|
||||
self.auto_tone_track
|
||||
and not self.tone_lock
|
||||
and self._blocks_processed > self._WARMUP_BLOCKS
|
||||
and (self._blocks_processed % self._tone_scan_interval_blocks == 0)
|
||||
and self._estimate_tone_frequency(normalized, mag, noise_ref)
|
||||
):
|
||||
# Detector changed; refresh magnitudes for this window.
|
||||
mag = self._detector.magnitude(normalized)
|
||||
noise_low = self._noise_detector_low.magnitude(normalized)
|
||||
noise_high = self._noise_detector_high.magnitude(normalized)
|
||||
noise_ref = max(1e-9, (noise_low + noise_high) * 0.5)
|
||||
|
||||
if self._blocks_processed <= self._WARMUP_BLOCKS:
|
||||
self._mag_min = min(self._mag_min, level)
|
||||
self._mag_max = max(self._mag_max, level)
|
||||
if self._blocks_processed == self._WARMUP_BLOCKS:
|
||||
self._noise_floor = self._mag_min if math.isfinite(self._mag_min) else 0.0
|
||||
if self._mag_max <= (self._noise_floor * 1.2):
|
||||
self._signal_peak = max(self._noise_floor + 0.5, self._noise_floor * 2.5)
|
||||
level = float(mag)
|
||||
alpha = self._attack_alpha if level >= self._envelope else self._release_alpha
|
||||
self._envelope += alpha * (level - self._envelope)
|
||||
self._last_level = self._envelope
|
||||
self._last_noise_ref = noise_ref
|
||||
amplitudes.append(level)
|
||||
|
||||
if self._blocks_processed <= self._WARMUP_BLOCKS:
|
||||
self._mag_min = min(self._mag_min, level)
|
||||
self._mag_max = max(self._mag_max, level)
|
||||
if self._blocks_processed == self._WARMUP_BLOCKS:
|
||||
self._noise_floor = self._mag_min if math.isfinite(self._mag_min) else 0.0
|
||||
if self._mag_max <= (self._noise_floor * 1.2):
|
||||
self._signal_peak = max(self._noise_floor + 0.5, self._noise_floor * 2.5)
|
||||
else:
|
||||
self._signal_peak = max(self._mag_max, self._noise_floor * 1.8)
|
||||
self._threshold = self._noise_floor + 0.22 * (
|
||||
self._signal_peak - self._noise_floor
|
||||
)
|
||||
tone_detected = False
|
||||
else:
|
||||
settle_alpha = 0.30 if self._blocks_processed < (self._WARMUP_BLOCKS + self._SETTLE_BLOCKS) else 0.06
|
||||
|
||||
detector_level = level
|
||||
|
||||
if detector_level <= self._threshold:
|
||||
self._noise_floor += settle_alpha * (detector_level - self._noise_floor)
|
||||
else:
|
||||
self._signal_peak = max(self._mag_max, self._noise_floor * 1.8)
|
||||
self._threshold = self._noise_floor + 0.22 * (
|
||||
self._signal_peak - self._noise_floor
|
||||
)
|
||||
tone_detected = False
|
||||
else:
|
||||
settle_alpha = 0.30 if self._blocks_processed < (self._WARMUP_BLOCKS + self._SETTLE_BLOCKS) else 0.06
|
||||
self._signal_peak += settle_alpha * (detector_level - self._signal_peak)
|
||||
|
||||
detector_level = level
|
||||
self._signal_peak = max(self._signal_peak, self._noise_floor * 1.05)
|
||||
|
||||
if detector_level <= self._threshold:
|
||||
self._noise_floor += settle_alpha * (detector_level - self._noise_floor)
|
||||
else:
|
||||
self._signal_peak += settle_alpha * (detector_level - self._signal_peak)
|
||||
# Blend adjacent-band noise reference into noise floor.
|
||||
self._noise_floor += (settle_alpha * 0.25) * (noise_ref - self._noise_floor)
|
||||
|
||||
self._signal_peak = max(self._signal_peak, self._noise_floor * 1.05)
|
||||
if self.threshold_mode == 'manual':
|
||||
self._threshold = max(0.0, self.manual_threshold)
|
||||
else:
|
||||
self._threshold = (
|
||||
max(0.0, self._noise_floor * self.threshold_multiplier)
|
||||
+ self.threshold_offset
|
||||
)
|
||||
self._threshold = max(self._threshold, self._noise_floor + 0.35)
|
||||
|
||||
# Always blend adjacent-band noise reference into noise floor.
|
||||
# Adjacent bands track the same AGC gain but exclude the tone,
|
||||
# so this prevents noise floor from staying stuck at warmup-era
|
||||
# low values after AGC converges.
|
||||
self._noise_floor += (settle_alpha * 0.25) * (noise_ref - self._noise_floor)
|
||||
dynamic_span = max(0.0, self._signal_peak - self._noise_floor)
|
||||
gate_level = self._noise_floor + (self.min_signal_gate * dynamic_span)
|
||||
gate_ok = self.min_signal_gate <= 0.0 or detector_level >= gate_level
|
||||
|
||||
if self.threshold_mode == 'manual':
|
||||
self._threshold = max(0.0, self.manual_threshold)
|
||||
else:
|
||||
self._threshold = (
|
||||
max(0.0, self._noise_floor * self.threshold_multiplier)
|
||||
+ self.threshold_offset
|
||||
)
|
||||
self._threshold = max(self._threshold, self._noise_floor + 0.35)
|
||||
# SNR-based tone detection (gain-invariant).
|
||||
snr = level / max(noise_ref, 1e-6)
|
||||
snr_mult = max(1.15, self.threshold_multiplier * 0.5)
|
||||
snr_on = snr_mult * (1.0 + self._hysteresis)
|
||||
snr_off = snr_mult * (1.0 - self._hysteresis)
|
||||
|
||||
dynamic_span = max(0.0, self._signal_peak - self._noise_floor)
|
||||
gate_level = self._noise_floor + (self.min_signal_gate * dynamic_span)
|
||||
gate_ok = self.min_signal_gate <= 0.0 or detector_level >= gate_level
|
||||
|
||||
# Use SNR (tone mag / adjacent-band noise) for tone detection.
|
||||
# Both bands are equally amplified by AGC, so the ratio is
|
||||
# gain-invariant — fixes stuck-ON tone when AGC amplifies
|
||||
# inter-element silence above the raw magnitude threshold.
|
||||
snr = level / max(noise_ref, 1e-6)
|
||||
snr_mult = max(1.15, self.threshold_multiplier * 0.5)
|
||||
snr_on = snr_mult * (1.0 + self._hysteresis)
|
||||
snr_off = snr_mult * (1.0 - self._hysteresis)
|
||||
|
||||
if self._tone_on:
|
||||
tone_detected = gate_ok and snr >= snr_off
|
||||
else:
|
||||
tone_detected = gate_ok and snr >= snr_on
|
||||
if self._tone_on:
|
||||
tone_detected = gate_ok and snr >= snr_off
|
||||
else:
|
||||
tone_detected = gate_ok and snr >= snr_on
|
||||
|
||||
dit_blocks = self._effective_dit_blocks()
|
||||
self._dah_threshold = 2.2 * dit_blocks
|
||||
self._dit_min = max(1.0, 0.38 * dit_blocks)
|
||||
self._char_gap = 2.6 * dit_blocks
|
||||
self._word_gap = 6.0 * dit_blocks
|
||||
if self.detect_mode == 'envelope':
|
||||
self._char_gap = 2.0 * dit_blocks
|
||||
self._word_gap = 5.0 * dit_blocks
|
||||
else:
|
||||
self._char_gap = 2.6 * dit_blocks
|
||||
self._word_gap = 6.0 * dit_blocks
|
||||
|
||||
if tone_detected and not self._tone_on:
|
||||
# Tone edge up.
|
||||
@@ -548,10 +647,7 @@ class MorseDecoder:
|
||||
self._silence_blocks += 1.0
|
||||
|
||||
if amplitudes:
|
||||
snr_mult = max(1.15, self.threshold_multiplier * 0.5)
|
||||
snr_on = snr_mult * (1.0 + self._hysteresis)
|
||||
snr_off = snr_mult * (1.0 - self._hysteresis)
|
||||
events.append({
|
||||
scope_event: dict[str, Any] = {
|
||||
'type': 'scope',
|
||||
'amplitudes': amplitudes,
|
||||
'threshold': self._threshold,
|
||||
@@ -561,11 +657,22 @@ class MorseDecoder:
|
||||
'noise_floor': self._noise_floor,
|
||||
'wpm': round(self._estimated_wpm, 1),
|
||||
'dit_ms': round(self._effective_dit_blocks() * self._block_duration * 1000.0, 1),
|
||||
'snr': round(self._last_level / max(self._noise_floor, 1e-6), 2),
|
||||
'noise_ref': round(self._noise_floor, 4),
|
||||
'snr_on': round(snr_on, 2),
|
||||
'snr_off': round(snr_off, 2),
|
||||
})
|
||||
'detect_mode': self.detect_mode,
|
||||
}
|
||||
if self.detect_mode == 'envelope':
|
||||
scope_event['snr'] = 0.0
|
||||
scope_event['noise_ref'] = 0.0
|
||||
scope_event['snr_on'] = 0.0
|
||||
scope_event['snr_off'] = 0.0
|
||||
else:
|
||||
snr_mult = max(1.15, self.threshold_multiplier * 0.5)
|
||||
snr_on = snr_mult * (1.0 + self._hysteresis)
|
||||
snr_off = snr_mult * (1.0 - self._hysteresis)
|
||||
scope_event['snr'] = round(self._last_level / max(self._noise_floor, 1e-6), 2)
|
||||
scope_event['noise_ref'] = round(self._noise_floor, 4)
|
||||
scope_event['snr_on'] = round(snr_on, 2)
|
||||
scope_event['snr_off'] = round(snr_off, 2)
|
||||
events.append(scope_event)
|
||||
|
||||
return events
|
||||
|
||||
@@ -818,6 +925,7 @@ def morse_decoder_thread(
|
||||
wpm_mode=_normalize_wpm_mode(cfg.get('wpm_mode', 'auto')),
|
||||
wpm_lock=_coerce_bool(cfg.get('wpm_lock', False), False),
|
||||
min_signal_gate=float(cfg.get('min_signal_gate', 0.0) or 0.0),
|
||||
detect_mode=str(cfg.get('detect_mode', 'goertzel')),
|
||||
)
|
||||
|
||||
last_scope = time.monotonic()
|
||||
@@ -1101,6 +1209,7 @@ def morse_iq_decoder_thread(
|
||||
wpm_mode=_normalize_wpm_mode(cfg.get('wpm_mode', 'auto')),
|
||||
wpm_lock=_coerce_bool(cfg.get('wpm_lock', False), False),
|
||||
min_signal_gate=float(cfg.get('min_signal_gate', 0.0) or 0.0),
|
||||
detect_mode=str(cfg.get('detect_mode', 'goertzel')),
|
||||
)
|
||||
|
||||
last_scope = time.monotonic()
|
||||
|
||||
@@ -104,13 +104,29 @@ def _signal_handler(signum, frame):
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
# Only register signal handlers if we're not in a thread
|
||||
try:
|
||||
signal.signal(signal.SIGTERM, _signal_handler)
|
||||
signal.signal(signal.SIGINT, _signal_handler)
|
||||
except ValueError:
|
||||
# Can't set signal handlers from a thread
|
||||
pass
|
||||
# Only register signal handlers when running standalone (not under gunicorn).
|
||||
# Gunicorn manages its own SIGINT/SIGTERM handling for graceful shutdown;
|
||||
# overriding those signals causes KeyboardInterrupt in the wrong context.
|
||||
def _is_under_gunicorn():
|
||||
"""Check if we're running inside a gunicorn worker."""
|
||||
try:
|
||||
import gunicorn.arbiter # noqa: F401
|
||||
# If gunicorn is importable AND we were invoked via gunicorn, the
|
||||
# arbiter will have installed its own signal handlers already.
|
||||
# Check the current SIGTERM handler — if it's not the default,
|
||||
# gunicorn (or another manager) owns signals.
|
||||
current = signal.getsignal(signal.SIGTERM)
|
||||
return current not in (signal.SIG_DFL, signal.SIG_IGN, None)
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
if not _is_under_gunicorn():
|
||||
try:
|
||||
signal.signal(signal.SIGTERM, _signal_handler)
|
||||
signal.signal(signal.SIGINT, _signal_handler)
|
||||
except ValueError:
|
||||
# Can't set signal handlers from a thread
|
||||
pass
|
||||
|
||||
|
||||
def cleanup_stale_processes() -> None:
|
||||
|
||||
@@ -347,17 +347,21 @@ def detect_hackrf_devices() -> list[SDRDevice]:
|
||||
)
|
||||
|
||||
# Parse hackrf_info output
|
||||
# Look for "Serial number:" lines
|
||||
serial_pattern = r'Serial number:\s*(\S+)'
|
||||
# Extract board name from "Board ID Number: X (Name)" and serial
|
||||
from .hackrf import HackRFCommandBuilder
|
||||
|
||||
serial_pattern = r'Serial number:\s*(\S+)'
|
||||
board_pattern = r'Board ID Number:\s*\d+\s*\(([^)]+)\)'
|
||||
|
||||
serials_found = re.findall(serial_pattern, result.stdout)
|
||||
boards_found = re.findall(board_pattern, result.stdout)
|
||||
|
||||
for i, serial in enumerate(serials_found):
|
||||
board_name = boards_found[i] if i < len(boards_found) else 'HackRF'
|
||||
devices.append(SDRDevice(
|
||||
sdr_type=SDRType.HACKRF,
|
||||
index=i,
|
||||
name=f'HackRF One',
|
||||
name=board_name,
|
||||
serial=serial,
|
||||
driver='hackrf',
|
||||
capabilities=HackRFCommandBuilder.CAPABILITIES
|
||||
@@ -365,10 +369,12 @@ def detect_hackrf_devices() -> list[SDRDevice]:
|
||||
|
||||
# Fallback: check if any HackRF found without serial
|
||||
if not devices and 'Found HackRF' in result.stdout:
|
||||
board_match = re.search(board_pattern, result.stdout)
|
||||
board_name = board_match.group(1) if board_match else 'HackRF'
|
||||
devices.append(SDRDevice(
|
||||
sdr_type=SDRType.HACKRF,
|
||||
index=0,
|
||||
name='HackRF One',
|
||||
name=board_name,
|
||||
serial='Unknown',
|
||||
driver='hackrf',
|
||||
capabilities=HackRFCommandBuilder.CAPABILITIES
|
||||
|
||||
Reference in New Issue
Block a user