mirror of
https://github.com/smittix/intercept.git
synced 2026-07-13 03:58:10 -07:00
Merge upstream/main and resolve adsb_dashboard.html conflict
Take upstream's crosshair animation system and updated selectAircraft(icao, source) signature. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,66 @@
|
|||||||
|
|
||||||
All notable changes to iNTERCEPT will be documented in this file.
|
All notable changes to iNTERCEPT will be documented in this file.
|
||||||
|
|
||||||
|
## [2.22.3] - 2026-02-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Waterfall control panel rendered as unstyled text for up to 20 seconds on first visit — CSS is now loaded eagerly with the rest of the page assets
|
||||||
|
- WebSDR globe failed to render on first page load — initialization now waits for a layout frame before mounting the WebGL renderer, ensuring the container has non-zero dimensions
|
||||||
|
- Waterfall monitor audio took minutes to start — `_waitForPlayback` now only reports success on actual audio playback (`playing`/`timeupdate`), not from the WAV header alone (`loadeddata`/`canplay`)
|
||||||
|
- Waterfall monitor could not be stopped — `stopMonitor()` now pauses audio and updates the UI immediately instead of waiting for the backend stop request (which blocked for 1+ seconds during SDR process cleanup)
|
||||||
|
- Stopping the waterfall no longer shows a stale "WebSocket closed before ready" message — the `onclose` handler now detects intentional closes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.22.1] - 2026-02-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- PWA install prompt not appearing — manifest now includes required PNG icons (192×192, 512×512)
|
||||||
|
- Apple touch icon updated to PNG for iOS Safari compatibility
|
||||||
|
- Service worker cache bumped to bust stale cached assets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.22.0] - 2026-02-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Waterfall Receiver Overhaul** - WebSocket-based I/Q streaming with server-side FFT, click-to-tune, zoom controls, and auto-scaling
|
||||||
|
- **Voice Alerts** - Configurable text-to-speech event notifications across modes
|
||||||
|
- **Signal Fingerprinting** - RF device identification and pattern analysis mode
|
||||||
|
- **SignalID** - Automatic signal classification via SigIDWiki API integration
|
||||||
|
- **PWA Support** - Installable web app with service worker caching and manifest
|
||||||
|
- **Real-time Signal Scope** - Live signal visualization for pager, sensor, and SSTV modes
|
||||||
|
- **ADS-B MSG2 Surface Parsing** - Ground vehicle movement tracking from MSG2 frames
|
||||||
|
- **Cheat Sheets** - Quick reference overlays for keyboard shortcuts and mode controls
|
||||||
|
- App icon (SVG) for PWA and browser tab
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **WebSDR overhaul** - Improved receiver management, audio streaming, and UI
|
||||||
|
- **Mode stop responsiveness** - Faster timeout handling and improved WiFi/Bluetooth scanner shutdown
|
||||||
|
- **Mode transitions** - Smoother navigation with performance instrumentation
|
||||||
|
- **BT Locate** - Refactored JS engine with improved trail management and signal smoothing
|
||||||
|
- **Listening Post** - Refactored with cross-module frequency routing
|
||||||
|
- **SSTV decoder** - State machine improvements and partial image streaming
|
||||||
|
- Analytics mode removed; per-mode analytics panels integrated into existing dashboards
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- ADS-B SSE multi-client fanout stability and update flush timing
|
||||||
|
- WiFi scanner robustness and monitor mode teardown reliability
|
||||||
|
- Agent client reliability improvements for remote sensor nodes
|
||||||
|
- SSTV VIS detector state reporting in signal monitor diagnostics
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Complete documentation audit across README, FEATURES, USAGE, help modal, and GitHub Pages
|
||||||
|
- Fixed license badge (MIT → Apache 2.0) to match actual LICENSE file
|
||||||
|
- Fixed tool name `rtl_amr` → `rtlamr` throughout all docs
|
||||||
|
- Fixed incorrect entry point examples (`python app.py` → `sudo -E venv/bin/python intercept.py`)
|
||||||
|
- Removed duplicate AIS Vessel Tracking section from FEATURES.md
|
||||||
|
- Updated SSTV requirements: pure Python decoder, no external `slowrx` needed
|
||||||
|
- Added ACARS and VDL2 mode descriptions to in-app help modal
|
||||||
|
- GitHub Pages site: corrected Docker command, license, and tool name references
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.21.1] - 2026-02-20
|
## [2.21.1] - 2026-02-20
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+12
-1
@@ -57,7 +57,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
soapysdr-module-airspy \
|
soapysdr-module-airspy \
|
||||||
airspy \
|
airspy \
|
||||||
limesuite \
|
limesuite \
|
||||||
hackrf \
|
|
||||||
# Utilities
|
# Utilities
|
||||||
curl \
|
curl \
|
||||||
procps \
|
procps \
|
||||||
@@ -190,6 +189,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
fi \
|
fi \
|
||||||
&& cd /tmp \
|
&& cd /tmp \
|
||||||
&& rm -rf /tmp/SatDump \
|
&& rm -rf /tmp/SatDump \
|
||||||
|
# Build hackrf CLI tools from source — avoids libhackrf0 version conflict
|
||||||
|
# between the 'hackrf' apt package and soapysdr-module-hackrf's newer libhackrf0
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/greatscottgadgets/hackrf.git \
|
||||||
|
&& cd hackrf/host \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make \
|
||||||
|
&& make install \
|
||||||
|
&& ldconfig \
|
||||||
|
&& rm -rf /tmp/hackrf \
|
||||||
# Build rtlamr (utility meter decoder - requires Go)
|
# Build rtlamr (utility meter decoder - requires Go)
|
||||||
&& cd /tmp \
|
&& cd /tmp \
|
||||||
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
|
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
|
||||||
@@ -240,6 +250,7 @@ RUN mkdir -p /app/data /app/data/weather_sat
|
|||||||
|
|
||||||
# Expose web interface port
|
# Expose web interface port
|
||||||
EXPOSE 5050
|
EXPOSE 5050
|
||||||
|
EXPOSE 5443
|
||||||
|
|
||||||
# Environment variables with defaults
|
# Environment variables with defaults
|
||||||
ENV INTERCEPT_HOST=0.0.0.0 \
|
ENV INTERCEPT_HOST=0.0.0.0 \
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="Python 3.9+">
|
<img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="Python 3.9+">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green.svg" alt="MIT License">
|
<img src="https://img.shields.io/badge/license-Apache--2.0-green.svg" alt="Apache 2.0 License">
|
||||||
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg" alt="Platform">
|
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg" alt="Platform">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Support the developer of this open-source project
|
|||||||
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF)
|
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF)
|
||||||
- **APRS** - Amateur packet radio position reports and telemetry via direwolf
|
- **APRS** - Amateur packet radio position reports and telemetry via direwolf
|
||||||
- **Satellite Tracking** - Pass prediction with polar plot and ground track map
|
- **Satellite Tracking** - Pass prediction with polar plot and ground track map
|
||||||
- **Utility Meters** - Electric, gas, and water meter reading via rtl_amr
|
- **Utility Meters** - Electric, gas, and water meter reading via rtlamr
|
||||||
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
||||||
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
||||||
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
||||||
@@ -57,8 +57,6 @@ Support the developer of this open-source project
|
|||||||
|
|
||||||
## Installation / Debian / Ubuntu / MacOS
|
## Installation / Debian / Ubuntu / MacOS
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
**1. Clone and run:**
|
**1. Clone and run:**
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/smittix/intercept.git
|
git clone https://github.com/smittix/intercept.git
|
||||||
@@ -150,7 +148,7 @@ Set these as environment variables for either local installs or Docker:
|
|||||||
```bash
|
```bash
|
||||||
INTERCEPT_ADSB_AUTO_START=true \
|
INTERCEPT_ADSB_AUTO_START=true \
|
||||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||||
python app.py
|
sudo -E venv/bin/python intercept.py
|
||||||
```
|
```
|
||||||
|
|
||||||
**Docker example (.env)**
|
**Docker example (.env)**
|
||||||
@@ -172,7 +170,7 @@ Then open **/adsb/history** for the reporting dashboard.
|
|||||||
|
|
||||||
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
|
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
|
||||||
|
|
||||||
The credentials can be change in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py
|
The credentials can be changed in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -245,7 +243,7 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
|||||||
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
|
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
|
||||||
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
||||||
[direwolf](https://github.com/wb2osz/direwolf) |
|
[direwolf](https://github.com/wb2osz/direwolf) |
|
||||||
[rtl_amr](https://github.com/bemasher/rtlamr) |
|
[rtlamr](https://github.com/bemasher/rtlamr) |
|
||||||
[dumpvdl2](https://github.com/szpajder/dumpvdl2) |
|
[dumpvdl2](https://github.com/szpajder/dumpvdl2) |
|
||||||
[aircrack-ng](https://www.aircrack-ng.org/) |
|
[aircrack-ng](https://www.aircrack-ng.org/) |
|
||||||
[Leaflet.js](https://leafletjs.com/) |
|
[Leaflet.js](https://leafletjs.com/) |
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import subprocess
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session
|
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session, send_from_directory
|
||||||
from werkzeug.security import check_password_hash
|
from werkzeug.security import check_password_hash
|
||||||
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
||||||
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
||||||
@@ -396,6 +396,18 @@ def favicon() -> Response:
|
|||||||
return send_file('favicon.svg', mimetype='image/svg+xml')
|
return send_file('favicon.svg', mimetype='image/svg+xml')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/sw.js')
|
||||||
|
def service_worker() -> Response:
|
||||||
|
resp = send_from_directory('static', 'sw.js', mimetype='application/javascript')
|
||||||
|
resp.headers['Service-Worker-Allowed'] = '/'
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/manifest.json')
|
||||||
|
def pwa_manifest() -> Response:
|
||||||
|
return send_from_directory('static', 'manifest.json', mimetype='application/manifest+json')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/devices')
|
@app.route('/devices')
|
||||||
def get_devices() -> Response:
|
def get_devices() -> Response:
|
||||||
"""Get all detected SDR devices with hardware type info."""
|
"""Get all detected SDR devices with hardware type info."""
|
||||||
@@ -857,6 +869,36 @@ def kill_all() -> Response:
|
|||||||
return jsonify({'status': 'killed', 'processes': killed})
|
return jsonify({'status': 'killed', 'processes': killed})
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_self_signed_cert(cert_dir: str) -> tuple:
|
||||||
|
"""Generate a self-signed certificate if one doesn't already exist.
|
||||||
|
|
||||||
|
Returns (cert_path, key_path) tuple.
|
||||||
|
"""
|
||||||
|
cert_path = os.path.join(cert_dir, 'intercept.crt')
|
||||||
|
key_path = os.path.join(cert_dir, 'intercept.key')
|
||||||
|
|
||||||
|
if os.path.exists(cert_path) and os.path.exists(key_path):
|
||||||
|
print(f"Using existing SSL certificate: {cert_path}")
|
||||||
|
return cert_path, key_path
|
||||||
|
|
||||||
|
os.makedirs(cert_dir, exist_ok=True)
|
||||||
|
print("Generating self-signed SSL certificate...")
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
result = subprocess.run([
|
||||||
|
'openssl', 'req', '-x509', '-newkey', 'rsa:2048',
|
||||||
|
'-keyout', key_path, '-out', cert_path,
|
||||||
|
'-days', '365', '-nodes',
|
||||||
|
'-subj', '/CN=intercept/O=INTERCEPT/C=US',
|
||||||
|
], capture_output=True, text=True)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"Failed to generate SSL certificate: {result.stderr}")
|
||||||
|
|
||||||
|
print(f"SSL certificate generated: {cert_path}")
|
||||||
|
return cert_path, key_path
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Main entry point."""
|
"""Main entry point."""
|
||||||
import argparse
|
import argparse
|
||||||
@@ -883,6 +925,12 @@ def main() -> None:
|
|||||||
default=config.DEBUG,
|
default=config.DEBUG,
|
||||||
help='Enable debug mode'
|
help='Enable debug mode'
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--https',
|
||||||
|
action='store_true',
|
||||||
|
default=config.HTTPS,
|
||||||
|
help='Enable HTTPS with self-signed certificate'
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--check-deps',
|
'--check-deps',
|
||||||
action='store_true',
|
action='store_true',
|
||||||
@@ -1008,7 +1056,18 @@ def main() -> None:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
print(f"WebSocket waterfall disabled: {e}")
|
print(f"WebSocket waterfall disabled: {e}")
|
||||||
|
|
||||||
print(f"Open http://localhost:{args.port} in your browser")
|
# Configure SSL if HTTPS is enabled
|
||||||
|
ssl_context = None
|
||||||
|
if args.https:
|
||||||
|
cert_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data', 'certs')
|
||||||
|
if config.SSL_CERT and config.SSL_KEY:
|
||||||
|
ssl_context = (config.SSL_CERT, config.SSL_KEY)
|
||||||
|
print(f"Using provided SSL certificate: {config.SSL_CERT}")
|
||||||
|
else:
|
||||||
|
ssl_context = _ensure_self_signed_cert(cert_dir)
|
||||||
|
|
||||||
|
protocol = 'https' if ssl_context else 'http'
|
||||||
|
print(f"Open {protocol}://localhost:{args.port} in your browser")
|
||||||
print()
|
print()
|
||||||
print("Press Ctrl+C to stop")
|
print("Press Ctrl+C to stop")
|
||||||
print()
|
print()
|
||||||
@@ -1020,4 +1079,5 @@ def main() -> None:
|
|||||||
debug=args.debug,
|
debug=args.debug,
|
||||||
threaded=True,
|
threaded=True,
|
||||||
load_dotenv=False,
|
load_dotenv=False,
|
||||||
|
ssl_context=ssl_context,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,10 +7,39 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "2.21.1"
|
VERSION = "2.22.3"
|
||||||
|
|
||||||
# Changelog - latest release notes (shown on welcome screen)
|
# Changelog - latest release notes (shown on welcome screen)
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "2.22.3",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Waterfall control panel no longer shows as unstyled text on first visit",
|
||||||
|
"WebSDR globe renders correctly on first page load without requiring a refresh",
|
||||||
|
"Waterfall monitor audio no longer takes minutes to start — playback detection now waits for real audio data instead of just the WAV header",
|
||||||
|
"Waterfall monitor stop is now instant — audio pauses and UI updates immediately instead of waiting for backend cleanup",
|
||||||
|
"Stopping the waterfall no longer shows a stale 'WebSocket closed before ready' message",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.22.1",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Waterfall receiver overhaul: WebSocket I/Q streaming with server-side FFT, click-to-tune, and zoom controls",
|
||||||
|
"Voice alerts for configurable event notifications across modes",
|
||||||
|
"Signal fingerprinting mode for RF device identification and pattern analysis",
|
||||||
|
"SignalID integration via SigIDWiki API for automatic signal classification",
|
||||||
|
"PWA support: installable web app with service worker and manifest",
|
||||||
|
"Mode stop responsiveness improvements with faster timeout handling",
|
||||||
|
"Navigation performance instrumentation and smoother mode transitions",
|
||||||
|
"Pager, sensor, and SSTV real-time signal scope visualization",
|
||||||
|
"ADS-B MSG2 surface movement parsing for ground vehicle tracking",
|
||||||
|
"WebSDR major overhaul with improved receiver management and audio streaming",
|
||||||
|
"Documentation audit: fixed license, tool names, entry points, and SSTV decoder references",
|
||||||
|
"Help modal updated with ACARS and VDL2 mode descriptions",
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "2.21.1",
|
"version": "2.21.1",
|
||||||
"date": "February 2026",
|
"date": "February 2026",
|
||||||
@@ -251,6 +280,11 @@ PORT = _get_env_int('PORT', 5050)
|
|||||||
DEBUG = _get_env_bool('DEBUG', False)
|
DEBUG = _get_env_bool('DEBUG', False)
|
||||||
THREADED = _get_env_bool('THREADED', True)
|
THREADED = _get_env_bool('THREADED', True)
|
||||||
|
|
||||||
|
# HTTPS / SSL settings
|
||||||
|
HTTPS = _get_env_bool('HTTPS', False)
|
||||||
|
SSL_CERT = _get_env('SSL_CERT', '')
|
||||||
|
SSL_KEY = _get_env('SSL_KEY', '')
|
||||||
|
|
||||||
# Default RTL-SDR settings
|
# Default RTL-SDR settings
|
||||||
DEFAULT_GAIN = _get_env('DEFAULT_GAIN', '40')
|
DEFAULT_GAIN = _get_env('DEFAULT_GAIN', '40')
|
||||||
DEFAULT_DEVICE = _get_env('DEFAULT_DEVICE', '0')
|
DEFAULT_DEVICE = _get_env('DEFAULT_DEVICE', '0')
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ services:
|
|||||||
- basic
|
- basic
|
||||||
ports:
|
ports:
|
||||||
- "5050:5050"
|
- "5050:5050"
|
||||||
|
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
|
||||||
|
# - "5443:5443"
|
||||||
# Privileged mode required for USB SDR device access
|
# Privileged mode required for USB SDR device access
|
||||||
privileged: true
|
privileged: true
|
||||||
# USB device mapping for all USB devices
|
# USB device mapping for all USB devices
|
||||||
@@ -35,6 +37,9 @@ services:
|
|||||||
- INTERCEPT_HOST=0.0.0.0
|
- INTERCEPT_HOST=0.0.0.0
|
||||||
- INTERCEPT_PORT=5050
|
- INTERCEPT_PORT=5050
|
||||||
- INTERCEPT_LOG_LEVEL=INFO
|
- INTERCEPT_LOG_LEVEL=INFO
|
||||||
|
# HTTPS support (auto-generates self-signed cert)
|
||||||
|
# - INTERCEPT_HTTPS=true
|
||||||
|
# - INTERCEPT_PORT=5443
|
||||||
# ADS-B history is disabled by default
|
# ADS-B history is disabled by default
|
||||||
# To enable, use: docker compose --profile history up -d
|
# To enable, use: docker compose --profile history up -d
|
||||||
# - INTERCEPT_ADSB_HISTORY_ENABLED=true
|
# - INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||||
@@ -73,6 +78,8 @@ services:
|
|||||||
- adsb_db
|
- adsb_db
|
||||||
ports:
|
ports:
|
||||||
- "5050:5050"
|
- "5050:5050"
|
||||||
|
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
|
||||||
|
# - "5443:5443"
|
||||||
# Privileged mode required for USB SDR device access
|
# Privileged mode required for USB SDR device access
|
||||||
privileged: true
|
privileged: true
|
||||||
# USB device mapping for all USB devices
|
# USB device mapping for all USB devices
|
||||||
@@ -85,6 +92,9 @@ services:
|
|||||||
- INTERCEPT_HOST=0.0.0.0
|
- INTERCEPT_HOST=0.0.0.0
|
||||||
- INTERCEPT_PORT=5050
|
- INTERCEPT_PORT=5050
|
||||||
- INTERCEPT_LOG_LEVEL=INFO
|
- INTERCEPT_LOG_LEVEL=INFO
|
||||||
|
# HTTPS support (auto-generates self-signed cert)
|
||||||
|
# - INTERCEPT_HTTPS=true
|
||||||
|
# - INTERCEPT_PORT=5443
|
||||||
- INTERCEPT_ADSB_HISTORY_ENABLED=true
|
- INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||||
- INTERCEPT_ADSB_DB_HOST=adsb_db
|
- INTERCEPT_ADSB_DB_HOST=adsb_db
|
||||||
- INTERCEPT_ADSB_DB_PORT=5432
|
- INTERCEPT_ADSB_DB_PORT=5432
|
||||||
|
|||||||
@@ -24,17 +24,6 @@ Complete feature list for all modules.
|
|||||||
- **Wideband spectrum analysis** with real-time visualization
|
- **Wideband spectrum analysis** with real-time visualization
|
||||||
- **I/Q capture** - record raw samples for offline analysis
|
- **I/Q capture** - record raw samples for offline analysis
|
||||||
|
|
||||||
## AIS Vessel Tracking
|
|
||||||
|
|
||||||
- **Real-time vessel tracking** via AIS-catcher on 161.975/162.025 MHz
|
|
||||||
- **Full-screen dashboard** - dedicated popout with interactive map
|
|
||||||
- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
|
|
||||||
- **Vessel details popup** - name, MMSI, callsign, destination, ETA
|
|
||||||
- **Navigation data** - speed, course, heading, rate of turn
|
|
||||||
- **Ship type classification** - cargo, tanker, passenger, fishing, etc.
|
|
||||||
- **Vessel dimensions** - length, width, draught
|
|
||||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
|
||||||
|
|
||||||
## Spy Stations (Number Stations)
|
## Spy Stations (Number Stations)
|
||||||
|
|
||||||
- **Comprehensive database** of active number stations and diplomatic networks
|
- **Comprehensive database** of active number stations and diplomatic networks
|
||||||
|
|||||||
+1
-1
@@ -172,7 +172,7 @@ Set the following environment variables (Docker recommended):
|
|||||||
```bash
|
```bash
|
||||||
INTERCEPT_ADSB_AUTO_START=true \
|
INTERCEPT_ADSB_AUTO_START=true \
|
||||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||||
python app.py
|
sudo -E venv/bin/python intercept.py
|
||||||
```
|
```
|
||||||
|
|
||||||
**Docker example (.env)**
|
**Docker example (.env)**
|
||||||
|
|||||||
+3
-3
@@ -110,7 +110,7 @@
|
|||||||
<div class="feature-card" data-category="signals">
|
<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="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></div>
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></div>
|
||||||
<h3>Utility Meters</h3>
|
<h3>Utility Meters</h3>
|
||||||
<p>Smart meter monitoring via rtl_amr. Receive electric, gas, and water meter broadcasts in real time.</p>
|
<p>Smart meter monitoring via rtlamr. Receive electric, gas, and water meter broadcasts in real time.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="tracking">
|
<div class="feature-card" data-category="tracking">
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2L16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></svg></div>
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2L16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></svg></div>
|
||||||
@@ -321,7 +321,7 @@ sudo -E venv/bin/python intercept.py</code></pre>
|
|||||||
<div class="code-block">
|
<div class="code-block">
|
||||||
<pre><code>git clone https://github.com/smittix/intercept.git
|
<pre><code>git clone https://github.com/smittix/intercept.git
|
||||||
cd intercept
|
cd intercept
|
||||||
docker compose up -d</code></pre>
|
docker compose --profile basic up -d --build</code></pre>
|
||||||
</div>
|
</div>
|
||||||
<p class="install-note">Requires privileged mode for USB SDR access</p>
|
<p class="install-note">Requires privileged mode for USB SDR access</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -422,7 +422,7 @@ docker compose up -d</code></pre>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-bottom">
|
<div class="footer-bottom">
|
||||||
<p>Created by <a href="https://github.com/smittix" target="_blank">smittix</a> · MIT License</p>
|
<p>Created by <a href="https://github.com/smittix" target="_blank">smittix</a> · Apache 2.0 License</p>
|
||||||
<p class="disclaimer">For educational and authorized testing purposes only.</p>
|
<p class="disclaimer">For educational and authorized testing purposes only.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "intercept"
|
name = "intercept"
|
||||||
version = "2.21.1"
|
version = "2.22.3"
|
||||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
+31
-31
@@ -2,40 +2,40 @@
|
|||||||
|
|
||||||
def register_blueprints(app):
|
def register_blueprints(app):
|
||||||
"""Register all route blueprints with the Flask app."""
|
"""Register all route blueprints with the Flask app."""
|
||||||
from .pager import pager_bp
|
from .acars import acars_bp
|
||||||
from .sensor import sensor_bp
|
|
||||||
from .rtlamr import rtlamr_bp
|
|
||||||
from .wifi import wifi_bp
|
|
||||||
from .wifi_v2 import wifi_v2_bp
|
|
||||||
from .bluetooth import bluetooth_bp
|
|
||||||
from .bluetooth_v2 import bluetooth_v2_bp
|
|
||||||
from .adsb import adsb_bp
|
from .adsb import adsb_bp
|
||||||
from .ais import ais_bp
|
from .ais import ais_bp
|
||||||
from .dsc import dsc_bp
|
|
||||||
from .acars import acars_bp
|
|
||||||
from .vdl2 import vdl2_bp
|
|
||||||
from .aprs import aprs_bp
|
|
||||||
from .satellite import satellite_bp
|
|
||||||
from .gps import gps_bp
|
|
||||||
from .settings import settings_bp
|
|
||||||
from .correlation import correlation_bp
|
|
||||||
from .listening_post import listening_post_bp
|
|
||||||
from .meshtastic import meshtastic_bp
|
|
||||||
from .tscm import tscm_bp, init_tscm_state
|
|
||||||
from .spy_stations import spy_stations_bp
|
|
||||||
from .controller import controller_bp
|
|
||||||
from .offline import offline_bp
|
|
||||||
from .updater import updater_bp
|
|
||||||
from .sstv import sstv_bp
|
|
||||||
from .weather_sat import weather_sat_bp
|
|
||||||
from .sstv_general import sstv_general_bp
|
|
||||||
from .websdr import websdr_bp
|
|
||||||
from .alerts import alerts_bp
|
from .alerts import alerts_bp
|
||||||
from .recordings import recordings_bp
|
from .aprs import aprs_bp
|
||||||
from .subghz import subghz_bp
|
from .bluetooth import bluetooth_bp
|
||||||
|
from .bluetooth_v2 import bluetooth_v2_bp
|
||||||
from .bt_locate import bt_locate_bp
|
from .bt_locate import bt_locate_bp
|
||||||
from .analytics import analytics_bp
|
from .controller import controller_bp
|
||||||
|
from .correlation import correlation_bp
|
||||||
|
from .dsc import dsc_bp
|
||||||
|
from .gps import gps_bp
|
||||||
|
from .listening_post import receiver_bp
|
||||||
|
from .meshtastic import meshtastic_bp
|
||||||
|
from .offline import offline_bp
|
||||||
|
from .pager import pager_bp
|
||||||
|
from .recordings import recordings_bp
|
||||||
|
from .rtlamr import rtlamr_bp
|
||||||
|
from .satellite import satellite_bp
|
||||||
|
from .sensor import sensor_bp
|
||||||
|
from .settings import settings_bp
|
||||||
|
from .signalid import signalid_bp
|
||||||
from .space_weather import space_weather_bp
|
from .space_weather import space_weather_bp
|
||||||
|
from .spy_stations import spy_stations_bp
|
||||||
|
from .sstv import sstv_bp
|
||||||
|
from .sstv_general import sstv_general_bp
|
||||||
|
from .subghz import subghz_bp
|
||||||
|
from .tscm import init_tscm_state, tscm_bp
|
||||||
|
from .updater import updater_bp
|
||||||
|
from .vdl2 import vdl2_bp
|
||||||
|
from .weather_sat import weather_sat_bp
|
||||||
|
from .websdr import websdr_bp
|
||||||
|
from .wifi import wifi_bp
|
||||||
|
from .wifi_v2 import wifi_v2_bp
|
||||||
|
|
||||||
app.register_blueprint(pager_bp)
|
app.register_blueprint(pager_bp)
|
||||||
app.register_blueprint(sensor_bp)
|
app.register_blueprint(sensor_bp)
|
||||||
@@ -54,7 +54,7 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(gps_bp)
|
app.register_blueprint(gps_bp)
|
||||||
app.register_blueprint(settings_bp)
|
app.register_blueprint(settings_bp)
|
||||||
app.register_blueprint(correlation_bp)
|
app.register_blueprint(correlation_bp)
|
||||||
app.register_blueprint(listening_post_bp)
|
app.register_blueprint(receiver_bp)
|
||||||
app.register_blueprint(meshtastic_bp)
|
app.register_blueprint(meshtastic_bp)
|
||||||
app.register_blueprint(tscm_bp)
|
app.register_blueprint(tscm_bp)
|
||||||
app.register_blueprint(spy_stations_bp)
|
app.register_blueprint(spy_stations_bp)
|
||||||
@@ -69,8 +69,8 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(recordings_bp) # Session recordings
|
app.register_blueprint(recordings_bp) # Session recordings
|
||||||
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
||||||
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
||||||
app.register_blueprint(analytics_bp) # Cross-mode analytics dashboard
|
|
||||||
app.register_blueprint(space_weather_bp) # Space weather monitoring
|
app.register_blueprint(space_weather_bp) # Space weather monitoring
|
||||||
|
app.register_blueprint(signalid_bp) # External signal ID enrichment
|
||||||
|
|
||||||
# Initialize TSCM state with queue and lock from app
|
# Initialize TSCM state with queue and lock from app
|
||||||
import app as app_module
|
import app as app_module
|
||||||
|
|||||||
+78
-42
@@ -379,10 +379,62 @@ def parse_sbs_stream(service_addr):
|
|||||||
adsb_bytes_received = 0
|
adsb_bytes_received = 0
|
||||||
adsb_lines_received = 0
|
adsb_lines_received = 0
|
||||||
|
|
||||||
|
def flush_pending_updates(force: bool = False) -> None:
|
||||||
|
nonlocal last_update
|
||||||
|
if not pending_updates:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
if not force and now - last_update < ADSB_UPDATE_INTERVAL:
|
||||||
|
return
|
||||||
|
|
||||||
|
captured_at = datetime.now(timezone.utc)
|
||||||
|
for update_icao in tuple(pending_updates):
|
||||||
|
if update_icao in app_module.adsb_aircraft:
|
||||||
|
snapshot = app_module.adsb_aircraft[update_icao]
|
||||||
|
_broadcast_adsb_update({
|
||||||
|
'type': 'aircraft',
|
||||||
|
**snapshot
|
||||||
|
})
|
||||||
|
adsb_snapshot_writer.enqueue({
|
||||||
|
'captured_at': captured_at,
|
||||||
|
'icao': update_icao,
|
||||||
|
'callsign': snapshot.get('callsign'),
|
||||||
|
'registration': snapshot.get('registration'),
|
||||||
|
'type_code': snapshot.get('type_code'),
|
||||||
|
'type_desc': snapshot.get('type_desc'),
|
||||||
|
'altitude': snapshot.get('altitude'),
|
||||||
|
'speed': snapshot.get('speed'),
|
||||||
|
'heading': snapshot.get('heading'),
|
||||||
|
'vertical_rate': snapshot.get('vertical_rate'),
|
||||||
|
'lat': snapshot.get('lat'),
|
||||||
|
'lon': snapshot.get('lon'),
|
||||||
|
'squawk': snapshot.get('squawk'),
|
||||||
|
'source_host': service_addr,
|
||||||
|
'snapshot': snapshot,
|
||||||
|
})
|
||||||
|
# Geofence check
|
||||||
|
_gf_lat = snapshot.get('lat')
|
||||||
|
_gf_lon = snapshot.get('lon')
|
||||||
|
if _gf_lat is not None and _gf_lon is not None:
|
||||||
|
try:
|
||||||
|
from utils.geofence import get_geofence_manager
|
||||||
|
for _gf_evt in get_geofence_manager().check_position(
|
||||||
|
update_icao, 'aircraft', _gf_lat, _gf_lon,
|
||||||
|
{'callsign': snapshot.get('callsign'), 'altitude': snapshot.get('altitude')}
|
||||||
|
):
|
||||||
|
process_event('adsb', _gf_evt, 'geofence')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
pending_updates.clear()
|
||||||
|
last_update = now
|
||||||
|
|
||||||
while adsb_using_service:
|
while adsb_using_service:
|
||||||
try:
|
try:
|
||||||
data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore')
|
data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore')
|
||||||
if not data:
|
if not data:
|
||||||
|
flush_pending_updates(force=True)
|
||||||
logger.warning("SBS connection closed (no data)")
|
logger.warning("SBS connection closed (no data)")
|
||||||
break
|
break
|
||||||
adsb_bytes_received += len(data)
|
adsb_bytes_received += len(data)
|
||||||
@@ -501,56 +553,40 @@ def parse_sbs_stream(service_addr):
|
|||||||
'squawk': sq, 'meaning': _EMERGENCY_SQUAWKS[sq],
|
'squawk': sq, 'meaning': _EMERGENCY_SQUAWKS[sq],
|
||||||
}, 'squawk_emergency')
|
}, 'squawk_emergency')
|
||||||
|
|
||||||
|
elif msg_type == '2' and len(parts) > 15:
|
||||||
|
if parts[11]:
|
||||||
|
try:
|
||||||
|
aircraft['altitude'] = int(float(parts[11]))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if parts[12]:
|
||||||
|
try:
|
||||||
|
aircraft['speed'] = int(float(parts[12]))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if parts[13]:
|
||||||
|
try:
|
||||||
|
aircraft['heading'] = int(float(parts[13]))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if parts[14] and parts[15]:
|
||||||
|
try:
|
||||||
|
aircraft['lat'] = float(parts[14])
|
||||||
|
aircraft['lon'] = float(parts[15])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
app_module.adsb_aircraft.set(icao, aircraft)
|
app_module.adsb_aircraft.set(icao, aircraft)
|
||||||
pending_updates.add(icao)
|
pending_updates.add(icao)
|
||||||
adsb_messages_received += 1
|
adsb_messages_received += 1
|
||||||
adsb_last_message_time = time.time()
|
adsb_last_message_time = time.time()
|
||||||
|
flush_pending_updates()
|
||||||
now = time.time()
|
|
||||||
if now - last_update >= ADSB_UPDATE_INTERVAL:
|
|
||||||
for update_icao in pending_updates:
|
|
||||||
if update_icao in app_module.adsb_aircraft:
|
|
||||||
snapshot = app_module.adsb_aircraft[update_icao]
|
|
||||||
_broadcast_adsb_update({
|
|
||||||
'type': 'aircraft',
|
|
||||||
**snapshot
|
|
||||||
})
|
|
||||||
adsb_snapshot_writer.enqueue({
|
|
||||||
'captured_at': datetime.now(timezone.utc),
|
|
||||||
'icao': update_icao,
|
|
||||||
'callsign': snapshot.get('callsign'),
|
|
||||||
'registration': snapshot.get('registration'),
|
|
||||||
'type_code': snapshot.get('type_code'),
|
|
||||||
'type_desc': snapshot.get('type_desc'),
|
|
||||||
'altitude': snapshot.get('altitude'),
|
|
||||||
'speed': snapshot.get('speed'),
|
|
||||||
'heading': snapshot.get('heading'),
|
|
||||||
'vertical_rate': snapshot.get('vertical_rate'),
|
|
||||||
'lat': snapshot.get('lat'),
|
|
||||||
'lon': snapshot.get('lon'),
|
|
||||||
'squawk': snapshot.get('squawk'),
|
|
||||||
'source_host': service_addr,
|
|
||||||
'snapshot': snapshot,
|
|
||||||
})
|
|
||||||
# Geofence check
|
|
||||||
_gf_lat = snapshot.get('lat')
|
|
||||||
_gf_lon = snapshot.get('lon')
|
|
||||||
if _gf_lat is not None and _gf_lon is not None:
|
|
||||||
try:
|
|
||||||
from utils.geofence import get_geofence_manager
|
|
||||||
for _gf_evt in get_geofence_manager().check_position(
|
|
||||||
update_icao, 'aircraft', _gf_lat, _gf_lon,
|
|
||||||
{'callsign': snapshot.get('callsign'), 'altitude': snapshot.get('altitude')}
|
|
||||||
):
|
|
||||||
process_event('adsb', _gf_evt, 'geofence')
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
pending_updates.clear()
|
|
||||||
last_update = now
|
|
||||||
|
|
||||||
except socket.timeout:
|
except socket.timeout:
|
||||||
|
flush_pending_updates()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
flush_pending_updates(force=True)
|
||||||
sock.close()
|
sock.close()
|
||||||
adsb_connected = False
|
adsb_connected = False
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
|
|||||||
@@ -1,528 +0,0 @@
|
|||||||
"""Analytics dashboard: cross-mode summary, activity sparklines, export, geofence CRUD."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
|
||||||
|
|
||||||
import app as app_module
|
|
||||||
from utils.analytics import (
|
|
||||||
get_activity_tracker,
|
|
||||||
get_cross_mode_summary,
|
|
||||||
get_emergency_squawks,
|
|
||||||
get_mode_health,
|
|
||||||
)
|
|
||||||
from utils.alerts import get_alert_manager
|
|
||||||
from utils.flight_correlator import get_flight_correlator
|
|
||||||
from utils.geofence import get_geofence_manager
|
|
||||||
from utils.temporal_patterns import get_pattern_detector
|
|
||||||
|
|
||||||
analytics_bp = Blueprint('analytics', __name__, url_prefix='/analytics')
|
|
||||||
|
|
||||||
|
|
||||||
# Map mode names to DataStore attribute(s)
|
|
||||||
MODE_STORES: dict[str, list[str]] = {
|
|
||||||
'adsb': ['adsb_aircraft'],
|
|
||||||
'ais': ['ais_vessels'],
|
|
||||||
'wifi': ['wifi_networks', 'wifi_clients'],
|
|
||||||
'bluetooth': ['bt_devices'],
|
|
||||||
'dsc': ['dsc_messages'],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/summary')
|
|
||||||
def analytics_summary():
|
|
||||||
"""Return cross-mode counts, health, and emergency squawks."""
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'counts': get_cross_mode_summary(),
|
|
||||||
'health': get_mode_health(),
|
|
||||||
'squawks': get_emergency_squawks(),
|
|
||||||
'flight_messages': {
|
|
||||||
'acars': get_flight_correlator().acars_count,
|
|
||||||
'vdl2': get_flight_correlator().vdl2_count,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/activity')
|
|
||||||
def analytics_activity():
|
|
||||||
"""Return sparkline arrays for each mode."""
|
|
||||||
tracker = get_activity_tracker()
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'sparklines': tracker.get_all_sparklines(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/squawks')
|
|
||||||
def analytics_squawks():
|
|
||||||
"""Return current emergency squawk codes from ADS-B."""
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'squawks': get_emergency_squawks(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/patterns')
|
|
||||||
def analytics_patterns():
|
|
||||||
"""Return detected temporal patterns."""
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'patterns': get_pattern_detector().get_all_patterns(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/target')
|
|
||||||
def analytics_target():
|
|
||||||
"""Search entities across multiple modes for a target-centric view."""
|
|
||||||
query = (request.args.get('q') or '').strip()
|
|
||||||
requested_limit = request.args.get('limit', default=120, type=int) or 120
|
|
||||||
limit = max(1, min(500, requested_limit))
|
|
||||||
|
|
||||||
if not query:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'query': '',
|
|
||||||
'results': [],
|
|
||||||
'mode_counts': {},
|
|
||||||
})
|
|
||||||
|
|
||||||
needle = query.lower()
|
|
||||||
results: list[dict[str, Any]] = []
|
|
||||||
mode_counts: dict[str, int] = {}
|
|
||||||
|
|
||||||
def push(mode: str, entity_id: str, title: str, subtitle: str, last_seen: str | None = None) -> None:
|
|
||||||
if len(results) >= limit:
|
|
||||||
return
|
|
||||||
results.append({
|
|
||||||
'mode': mode,
|
|
||||||
'id': entity_id,
|
|
||||||
'title': title,
|
|
||||||
'subtitle': subtitle,
|
|
||||||
'last_seen': last_seen,
|
|
||||||
})
|
|
||||||
mode_counts[mode] = mode_counts.get(mode, 0) + 1
|
|
||||||
|
|
||||||
# ADS-B
|
|
||||||
for icao, aircraft in app_module.adsb_aircraft.items():
|
|
||||||
if not isinstance(aircraft, dict):
|
|
||||||
continue
|
|
||||||
fields = [
|
|
||||||
icao,
|
|
||||||
aircraft.get('icao'),
|
|
||||||
aircraft.get('hex'),
|
|
||||||
aircraft.get('callsign'),
|
|
||||||
aircraft.get('registration'),
|
|
||||||
aircraft.get('flight'),
|
|
||||||
]
|
|
||||||
if not _matches_query(needle, fields):
|
|
||||||
continue
|
|
||||||
title = str(aircraft.get('callsign') or icao or 'Aircraft').strip()
|
|
||||||
subtitle = f"ICAO {aircraft.get('icao') or icao} | Alt {aircraft.get('altitude', '--')} | Speed {aircraft.get('speed', '--')}"
|
|
||||||
push('adsb', str(icao), title, subtitle, aircraft.get('lastSeen') or aircraft.get('last_seen'))
|
|
||||||
if len(results) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
# AIS
|
|
||||||
if len(results) < limit:
|
|
||||||
for mmsi, vessel in app_module.ais_vessels.items():
|
|
||||||
if not isinstance(vessel, dict):
|
|
||||||
continue
|
|
||||||
fields = [
|
|
||||||
mmsi,
|
|
||||||
vessel.get('mmsi'),
|
|
||||||
vessel.get('name'),
|
|
||||||
vessel.get('shipname'),
|
|
||||||
vessel.get('callsign'),
|
|
||||||
vessel.get('imo'),
|
|
||||||
]
|
|
||||||
if not _matches_query(needle, fields):
|
|
||||||
continue
|
|
||||||
vessel_name = vessel.get('name') or vessel.get('shipname') or mmsi or 'Vessel'
|
|
||||||
subtitle = f"MMSI {vessel.get('mmsi') or mmsi} | Type {vessel.get('ship_type') or vessel.get('type') or '--'}"
|
|
||||||
push('ais', str(mmsi), str(vessel_name), subtitle, vessel.get('lastSeen') or vessel.get('last_seen'))
|
|
||||||
if len(results) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
# WiFi networks and clients
|
|
||||||
if len(results) < limit:
|
|
||||||
for bssid, net in app_module.wifi_networks.items():
|
|
||||||
if not isinstance(net, dict):
|
|
||||||
continue
|
|
||||||
fields = [bssid, net.get('bssid'), net.get('ssid'), net.get('vendor')]
|
|
||||||
if not _matches_query(needle, fields):
|
|
||||||
continue
|
|
||||||
title = str(net.get('ssid') or net.get('bssid') or bssid or 'WiFi Network')
|
|
||||||
subtitle = f"BSSID {net.get('bssid') or bssid} | CH {net.get('channel', '--')} | RSSI {net.get('signal', '--')}"
|
|
||||||
push('wifi', str(bssid), title, subtitle, net.get('lastSeen') or net.get('last_seen'))
|
|
||||||
if len(results) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
if len(results) < limit:
|
|
||||||
for client_mac, client in app_module.wifi_clients.items():
|
|
||||||
if not isinstance(client, dict):
|
|
||||||
continue
|
|
||||||
fields = [client_mac, client.get('mac'), client.get('bssid'), client.get('ssid'), client.get('vendor')]
|
|
||||||
if not _matches_query(needle, fields):
|
|
||||||
continue
|
|
||||||
title = str(client.get('mac') or client_mac or 'WiFi Client')
|
|
||||||
subtitle = f"BSSID {client.get('bssid') or '--'} | Probe {client.get('ssid') or '--'}"
|
|
||||||
push('wifi', str(client_mac), title, subtitle, client.get('lastSeen') or client.get('last_seen'))
|
|
||||||
if len(results) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Bluetooth
|
|
||||||
if len(results) < limit:
|
|
||||||
for address, dev in app_module.bt_devices.items():
|
|
||||||
if not isinstance(dev, dict):
|
|
||||||
continue
|
|
||||||
fields = [
|
|
||||||
address,
|
|
||||||
dev.get('address'),
|
|
||||||
dev.get('mac'),
|
|
||||||
dev.get('name'),
|
|
||||||
dev.get('manufacturer'),
|
|
||||||
dev.get('vendor'),
|
|
||||||
]
|
|
||||||
if not _matches_query(needle, fields):
|
|
||||||
continue
|
|
||||||
title = str(dev.get('name') or dev.get('address') or address or 'Bluetooth Device')
|
|
||||||
subtitle = f"MAC {dev.get('address') or address} | RSSI {dev.get('rssi', '--')} | Vendor {dev.get('manufacturer') or dev.get('vendor') or '--'}"
|
|
||||||
push('bluetooth', str(address), title, subtitle, dev.get('lastSeen') or dev.get('last_seen'))
|
|
||||||
if len(results) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
# DSC recent messages
|
|
||||||
if len(results) < limit:
|
|
||||||
for msg_id, msg in app_module.dsc_messages.items():
|
|
||||||
if not isinstance(msg, dict):
|
|
||||||
continue
|
|
||||||
fields = [
|
|
||||||
msg_id,
|
|
||||||
msg.get('mmsi'),
|
|
||||||
msg.get('from_mmsi'),
|
|
||||||
msg.get('to_mmsi'),
|
|
||||||
msg.get('from_callsign'),
|
|
||||||
msg.get('to_callsign'),
|
|
||||||
msg.get('category'),
|
|
||||||
]
|
|
||||||
if not _matches_query(needle, fields):
|
|
||||||
continue
|
|
||||||
title = str(msg.get('from_mmsi') or msg.get('mmsi') or msg_id or 'DSC Message')
|
|
||||||
subtitle = f"To {msg.get('to_mmsi') or '--'} | Cat {msg.get('category') or '--'} | Freq {msg.get('frequency') or '--'}"
|
|
||||||
push('dsc', str(msg_id), title, subtitle, msg.get('timestamp') or msg.get('lastSeen') or msg.get('last_seen'))
|
|
||||||
if len(results) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'query': query,
|
|
||||||
'results': results,
|
|
||||||
'mode_counts': mode_counts,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/insights')
|
|
||||||
def analytics_insights():
|
|
||||||
"""Return actionable insight cards and top changes."""
|
|
||||||
counts = get_cross_mode_summary()
|
|
||||||
tracker = get_activity_tracker()
|
|
||||||
sparklines = tracker.get_all_sparklines()
|
|
||||||
squawks = get_emergency_squawks()
|
|
||||||
patterns = get_pattern_detector().get_all_patterns()
|
|
||||||
alerts = get_alert_manager().list_events(limit=120)
|
|
||||||
|
|
||||||
top_changes = _compute_mode_changes(sparklines)
|
|
||||||
busiest_mode, busiest_count = _get_busiest_mode(counts)
|
|
||||||
critical_1h = _count_recent_alerts(alerts, severities={'critical', 'high'}, max_age_seconds=3600)
|
|
||||||
recurring_emitters = sum(1 for p in patterns if float(p.get('confidence') or 0.0) >= 0.7)
|
|
||||||
|
|
||||||
cards = []
|
|
||||||
if top_changes:
|
|
||||||
lead = top_changes[0]
|
|
||||||
direction = 'up' if lead['delta'] >= 0 else 'down'
|
|
||||||
cards.append({
|
|
||||||
'id': 'fastest_change',
|
|
||||||
'title': 'Fastest Change',
|
|
||||||
'value': f"{lead['mode_label']} ({lead['signed_delta']})",
|
|
||||||
'label': 'last window vs prior',
|
|
||||||
'severity': 'high' if lead['delta'] > 0 else 'low',
|
|
||||||
'detail': f"Traffic is trending {direction} in {lead['mode_label']}.",
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
cards.append({
|
|
||||||
'id': 'fastest_change',
|
|
||||||
'title': 'Fastest Change',
|
|
||||||
'value': 'Insufficient data',
|
|
||||||
'label': 'wait for activity history',
|
|
||||||
'severity': 'low',
|
|
||||||
'detail': 'Sparklines need more samples to score momentum.',
|
|
||||||
})
|
|
||||||
|
|
||||||
cards.append({
|
|
||||||
'id': 'busiest_mode',
|
|
||||||
'title': 'Busiest Mode',
|
|
||||||
'value': f"{busiest_mode} ({busiest_count})",
|
|
||||||
'label': 'current observed entities',
|
|
||||||
'severity': 'medium' if busiest_count > 0 else 'low',
|
|
||||||
'detail': 'Highest live entity count across monitoring modes.',
|
|
||||||
})
|
|
||||||
cards.append({
|
|
||||||
'id': 'critical_alerts',
|
|
||||||
'title': 'Critical Alerts (1h)',
|
|
||||||
'value': str(critical_1h),
|
|
||||||
'label': 'critical/high severities',
|
|
||||||
'severity': 'critical' if critical_1h > 0 else 'low',
|
|
||||||
'detail': 'Prioritize triage if this count is non-zero.',
|
|
||||||
})
|
|
||||||
cards.append({
|
|
||||||
'id': 'emergency_squawks',
|
|
||||||
'title': 'Emergency Squawks',
|
|
||||||
'value': str(len(squawks)),
|
|
||||||
'label': 'active ADS-B emergency codes',
|
|
||||||
'severity': 'critical' if squawks else 'low',
|
|
||||||
'detail': 'Immediate aviation anomalies currently visible.',
|
|
||||||
})
|
|
||||||
cards.append({
|
|
||||||
'id': 'recurring_emitters',
|
|
||||||
'title': 'Recurring Emitters',
|
|
||||||
'value': str(recurring_emitters),
|
|
||||||
'label': 'pattern confidence >= 0.70',
|
|
||||||
'severity': 'medium' if recurring_emitters > 0 else 'low',
|
|
||||||
'detail': 'Potentially stationary or periodic emitters detected.',
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'generated_at': datetime.now(timezone.utc).isoformat(),
|
|
||||||
'cards': cards,
|
|
||||||
'top_changes': top_changes[:5],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _compute_mode_changes(sparklines: dict[str, list[int]]) -> list[dict]:
|
|
||||||
mode_labels = {
|
|
||||||
'adsb': 'ADS-B',
|
|
||||||
'ais': 'AIS',
|
|
||||||
'wifi': 'WiFi',
|
|
||||||
'bluetooth': 'Bluetooth',
|
|
||||||
'dsc': 'DSC',
|
|
||||||
'acars': 'ACARS',
|
|
||||||
'vdl2': 'VDL2',
|
|
||||||
'aprs': 'APRS',
|
|
||||||
'meshtastic': 'Meshtastic',
|
|
||||||
}
|
|
||||||
rows = []
|
|
||||||
for mode, samples in (sparklines or {}).items():
|
|
||||||
if not isinstance(samples, list) or len(samples) < 4:
|
|
||||||
continue
|
|
||||||
|
|
||||||
window = max(2, min(12, len(samples) // 2))
|
|
||||||
recent = samples[-window:]
|
|
||||||
previous = samples[-(window * 2):-window]
|
|
||||||
if not previous:
|
|
||||||
continue
|
|
||||||
|
|
||||||
recent_avg = sum(recent) / len(recent)
|
|
||||||
prev_avg = sum(previous) / len(previous)
|
|
||||||
delta = round(recent_avg - prev_avg, 1)
|
|
||||||
rows.append({
|
|
||||||
'mode': mode,
|
|
||||||
'mode_label': mode_labels.get(mode, mode.upper()),
|
|
||||||
'delta': delta,
|
|
||||||
'signed_delta': ('+' if delta >= 0 else '') + str(delta),
|
|
||||||
'recent_avg': round(recent_avg, 1),
|
|
||||||
'previous_avg': round(prev_avg, 1),
|
|
||||||
'direction': 'up' if delta > 0 else ('down' if delta < 0 else 'flat'),
|
|
||||||
})
|
|
||||||
|
|
||||||
rows.sort(key=lambda r: abs(r['delta']), reverse=True)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def _matches_query(needle: str, values: list[Any]) -> bool:
|
|
||||||
for value in values:
|
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
if needle in str(value).lower():
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _count_recent_alerts(alerts: list[dict], severities: set[str], max_age_seconds: int) -> int:
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
count = 0
|
|
||||||
for event in alerts:
|
|
||||||
sev = str(event.get('severity') or '').lower()
|
|
||||||
if sev not in severities:
|
|
||||||
continue
|
|
||||||
created_raw = event.get('created_at')
|
|
||||||
if not created_raw:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
created = datetime.fromisoformat(str(created_raw).replace('Z', '+00:00'))
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if created.tzinfo is None:
|
|
||||||
created = created.replace(tzinfo=timezone.utc)
|
|
||||||
age = (now - created).total_seconds()
|
|
||||||
if 0 <= age <= max_age_seconds:
|
|
||||||
count += 1
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
|
||||||
def _get_busiest_mode(counts: dict[str, int]) -> tuple[str, int]:
|
|
||||||
mode_labels = {
|
|
||||||
'adsb': 'ADS-B',
|
|
||||||
'ais': 'AIS',
|
|
||||||
'wifi': 'WiFi',
|
|
||||||
'bluetooth': 'Bluetooth',
|
|
||||||
'dsc': 'DSC',
|
|
||||||
'acars': 'ACARS',
|
|
||||||
'vdl2': 'VDL2',
|
|
||||||
'aprs': 'APRS',
|
|
||||||
'meshtastic': 'Meshtastic',
|
|
||||||
}
|
|
||||||
filtered = {k: int(v or 0) for k, v in (counts or {}).items() if k in mode_labels}
|
|
||||||
if not filtered:
|
|
||||||
return ('None', 0)
|
|
||||||
mode = max(filtered, key=filtered.get)
|
|
||||||
return (mode_labels.get(mode, mode.upper()), filtered[mode])
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/export/<mode>')
|
|
||||||
def analytics_export(mode: str):
|
|
||||||
"""Export current DataStore contents as JSON or CSV."""
|
|
||||||
fmt = request.args.get('format', 'json').lower()
|
|
||||||
|
|
||||||
if mode == 'sensor':
|
|
||||||
# Sensor doesn't use DataStore; return recent queue-based data
|
|
||||||
return jsonify({'status': 'success', 'data': [], 'message': 'Sensor data is stream-only'})
|
|
||||||
|
|
||||||
store_names = MODE_STORES.get(mode)
|
|
||||||
if not store_names:
|
|
||||||
return jsonify({'status': 'error', 'message': f'Unknown mode: {mode}'}), 400
|
|
||||||
|
|
||||||
all_items: list[dict] = []
|
|
||||||
|
|
||||||
# Try v2 scanners first for wifi/bluetooth
|
|
||||||
if mode == 'wifi':
|
|
||||||
try:
|
|
||||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
|
||||||
if wifi_scanner is not None:
|
|
||||||
for ap in wifi_scanner.access_points:
|
|
||||||
all_items.append(ap.to_dict())
|
|
||||||
for client in wifi_scanner.clients:
|
|
||||||
item = client.to_dict()
|
|
||||||
item['_store'] = 'wifi_clients'
|
|
||||||
all_items.append(item)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
elif mode == 'bluetooth':
|
|
||||||
try:
|
|
||||||
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
|
||||||
if bt_scanner is not None:
|
|
||||||
for dev in bt_scanner.get_devices():
|
|
||||||
all_items.append(dev.to_dict())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Fall back to legacy DataStores if v2 scanners yielded nothing
|
|
||||||
if not all_items:
|
|
||||||
for store_name in store_names:
|
|
||||||
store = getattr(app_module, store_name, None)
|
|
||||||
if store is None:
|
|
||||||
continue
|
|
||||||
for key, value in store.items():
|
|
||||||
item = dict(value) if isinstance(value, dict) else {'id': key, 'value': value}
|
|
||||||
item.setdefault('_store', store_name)
|
|
||||||
all_items.append(item)
|
|
||||||
|
|
||||||
if fmt == 'csv':
|
|
||||||
if not all_items:
|
|
||||||
output = ''
|
|
||||||
else:
|
|
||||||
# Collect all keys across items
|
|
||||||
fieldnames: list[str] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for item in all_items:
|
|
||||||
for k in item:
|
|
||||||
if k not in seen:
|
|
||||||
fieldnames.append(k)
|
|
||||||
seen.add(k)
|
|
||||||
|
|
||||||
buf = io.StringIO()
|
|
||||||
writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction='ignore')
|
|
||||||
writer.writeheader()
|
|
||||||
for item in all_items:
|
|
||||||
# Serialize non-scalar values
|
|
||||||
row = {}
|
|
||||||
for k in fieldnames:
|
|
||||||
v = item.get(k)
|
|
||||||
if isinstance(v, (dict, list)):
|
|
||||||
row[k] = json.dumps(v)
|
|
||||||
else:
|
|
||||||
row[k] = v
|
|
||||||
writer.writerow(row)
|
|
||||||
output = buf.getvalue()
|
|
||||||
|
|
||||||
response = Response(output, mimetype='text/csv')
|
|
||||||
response.headers['Content-Disposition'] = f'attachment; filename={mode}_export.csv'
|
|
||||||
return response
|
|
||||||
|
|
||||||
# Default: JSON
|
|
||||||
return jsonify({'status': 'success', 'mode': mode, 'count': len(all_items), 'data': all_items})
|
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# Geofence CRUD
|
|
||||||
# =========================================================================
|
|
||||||
|
|
||||||
@analytics_bp.route('/geofences')
|
|
||||||
def list_geofences():
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'zones': get_geofence_manager().list_zones(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/geofences', methods=['POST'])
|
|
||||||
def create_geofence():
|
|
||||||
data = request.get_json() or {}
|
|
||||||
name = data.get('name')
|
|
||||||
lat = data.get('lat')
|
|
||||||
lon = data.get('lon')
|
|
||||||
radius_m = data.get('radius_m')
|
|
||||||
|
|
||||||
if not all([name, lat is not None, lon is not None, radius_m is not None]):
|
|
||||||
return jsonify({'status': 'error', 'message': 'name, lat, lon, radius_m are required'}), 400
|
|
||||||
|
|
||||||
try:
|
|
||||||
lat = float(lat)
|
|
||||||
lon = float(lon)
|
|
||||||
radius_m = float(radius_m)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return jsonify({'status': 'error', 'message': 'lat, lon, radius_m must be numbers'}), 400
|
|
||||||
|
|
||||||
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
|
|
||||||
if radius_m <= 0:
|
|
||||||
return jsonify({'status': 'error', 'message': 'radius_m must be positive'}), 400
|
|
||||||
|
|
||||||
alert_on = data.get('alert_on', 'enter_exit')
|
|
||||||
zone_id = get_geofence_manager().add_zone(name, lat, lon, radius_m, alert_on)
|
|
||||||
return jsonify({'status': 'success', 'zone_id': zone_id})
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/geofences/<int:zone_id>', methods=['DELETE'])
|
|
||||||
def delete_geofence(zone_id: int):
|
|
||||||
ok = get_geofence_manager().delete_zone(zone_id)
|
|
||||||
if not ok:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Zone not found'}), 404
|
|
||||||
return jsonify({'status': 'success'})
|
|
||||||
+379
-141
@@ -1,4 +1,4 @@
|
|||||||
"""Listening Post routes for radio monitoring and frequency scanning."""
|
"""Receiver routes for radio monitoring and frequency scanning."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -9,11 +9,12 @@ import queue
|
|||||||
import select
|
import select
|
||||||
import signal
|
import signal
|
||||||
import shutil
|
import shutil
|
||||||
|
import struct
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Generator, Optional, List, Dict
|
from typing import Any, Dict, Generator, List, Optional
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
@@ -28,9 +29,9 @@ from utils.constants import (
|
|||||||
)
|
)
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
|
||||||
logger = get_logger('intercept.listening_post')
|
logger = get_logger('intercept.receiver')
|
||||||
|
|
||||||
listening_post_bp = Blueprint('listening_post', __name__, url_prefix='/listening')
|
receiver_bp = Blueprint('receiver', __name__, url_prefix='/receiver')
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# GLOBAL STATE
|
# GLOBAL STATE
|
||||||
@@ -40,9 +41,12 @@ listening_post_bp = Blueprint('listening_post', __name__, url_prefix='/listening
|
|||||||
audio_process = None
|
audio_process = None
|
||||||
audio_rtl_process = None
|
audio_rtl_process = None
|
||||||
audio_lock = threading.Lock()
|
audio_lock = threading.Lock()
|
||||||
|
audio_start_lock = threading.Lock()
|
||||||
audio_running = False
|
audio_running = False
|
||||||
audio_frequency = 0.0
|
audio_frequency = 0.0
|
||||||
audio_modulation = 'fm'
|
audio_modulation = 'fm'
|
||||||
|
audio_source = 'process'
|
||||||
|
audio_start_token = 0
|
||||||
|
|
||||||
# Scanner state
|
# Scanner state
|
||||||
scanner_thread: Optional[threading.Thread] = None
|
scanner_thread: Optional[threading.Thread] = None
|
||||||
@@ -51,7 +55,7 @@ scanner_lock = threading.Lock()
|
|||||||
scanner_paused = False
|
scanner_paused = False
|
||||||
scanner_current_freq = 0.0
|
scanner_current_freq = 0.0
|
||||||
scanner_active_device: Optional[int] = None
|
scanner_active_device: Optional[int] = None
|
||||||
listening_active_device: Optional[int] = None
|
receiver_active_device: Optional[int] = None
|
||||||
scanner_power_process: Optional[subprocess.Popen] = None
|
scanner_power_process: Optional[subprocess.Popen] = None
|
||||||
scanner_config = {
|
scanner_config = {
|
||||||
'start_freq': 88.0,
|
'start_freq': 88.0,
|
||||||
@@ -119,6 +123,22 @@ def _rtl_fm_demod_mode(modulation: str) -> str:
|
|||||||
return 'wbfm' if mod == 'wfm' else mod
|
return 'wbfm' if mod == 'wfm' else mod
|
||||||
|
|
||||||
|
|
||||||
|
def _wav_header(sample_rate: int = 48000, bits_per_sample: int = 16, channels: int = 1) -> bytes:
|
||||||
|
"""Create a streaming WAV header with unknown data length."""
|
||||||
|
bytes_per_sample = bits_per_sample // 8
|
||||||
|
byte_rate = sample_rate * channels * bytes_per_sample
|
||||||
|
block_align = channels * bytes_per_sample
|
||||||
|
return (
|
||||||
|
b'RIFF'
|
||||||
|
+ struct.pack('<I', 0xFFFFFFFF)
|
||||||
|
+ b'WAVE'
|
||||||
|
+ b'fmt '
|
||||||
|
+ struct.pack('<IHHIIHH', 16, 1, channels, sample_rate, byte_rate, block_align, bits_per_sample)
|
||||||
|
+ b'data'
|
||||||
|
+ struct.pack('<I', 0xFFFFFFFF)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def add_activity_log(event_type: str, frequency: float, details: str = ''):
|
def add_activity_log(event_type: str, frequency: float, details: str = ''):
|
||||||
@@ -697,8 +717,8 @@ def _start_audio_stream(frequency: float, modulation: str):
|
|||||||
]
|
]
|
||||||
if scanner_config.get('bias_t', False):
|
if scanner_config.get('bias_t', False):
|
||||||
sdr_cmd.append('-T')
|
sdr_cmd.append('-T')
|
||||||
# Explicitly output to stdout (some rtl_fm versions need this)
|
# Omit explicit filename: rtl_fm defaults to stdout.
|
||||||
sdr_cmd.append('-')
|
# (Some builds intermittently stall when '-' is passed explicitly.)
|
||||||
else:
|
else:
|
||||||
# Use SDR abstraction layer for HackRF, Airspy, LimeSDR, SDRPlay
|
# Use SDR abstraction layer for HackRF, Airspy, LimeSDR, SDRPlay
|
||||||
rx_fm_path = find_rx_fm()
|
rx_fm_path = find_rx_fm()
|
||||||
@@ -842,15 +862,15 @@ def _start_audio_stream(frequency: float, modulation: str):
|
|||||||
# Pipeline started successfully
|
# Pipeline started successfully
|
||||||
break
|
break
|
||||||
|
|
||||||
# Validate that audio is producing data quickly
|
# Keep monitor startup tolerant: some demod chains can take
|
||||||
try:
|
# several seconds before producing stream bytes.
|
||||||
ready, _, _ = select.select([audio_process.stdout], [], [], 4.0)
|
if (
|
||||||
if not ready:
|
not audio_process
|
||||||
logger.warning("Audio pipeline produced no data in startup window — killing stalled pipeline")
|
or not audio_rtl_process
|
||||||
_stop_audio_stream_internal()
|
or audio_process.poll() is not None
|
||||||
return
|
or audio_rtl_process.poll() is not None
|
||||||
except Exception as e:
|
):
|
||||||
logger.warning(f"Audio startup check failed: {e}")
|
logger.warning("Audio pipeline did not remain alive after startup")
|
||||||
_stop_audio_stream_internal()
|
_stop_audio_stream_internal()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -871,11 +891,21 @@ def _stop_audio_stream():
|
|||||||
|
|
||||||
def _stop_audio_stream_internal():
|
def _stop_audio_stream_internal():
|
||||||
"""Internal stop (must hold lock)."""
|
"""Internal stop (must hold lock)."""
|
||||||
global audio_process, audio_rtl_process, audio_running, audio_frequency
|
global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_source
|
||||||
|
|
||||||
# Set flag first to stop any streaming
|
# Set flag first to stop any streaming
|
||||||
audio_running = False
|
audio_running = False
|
||||||
audio_frequency = 0.0
|
audio_frequency = 0.0
|
||||||
|
previous_source = audio_source
|
||||||
|
audio_source = 'process'
|
||||||
|
|
||||||
|
if previous_source == 'waterfall':
|
||||||
|
try:
|
||||||
|
from routes.waterfall_websocket import stop_shared_monitor_from_capture
|
||||||
|
|
||||||
|
stop_shared_monitor_from_capture()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
had_processes = audio_process is not None or audio_rtl_process is not None
|
had_processes = audio_process is not None or audio_rtl_process is not None
|
||||||
|
|
||||||
@@ -904,16 +934,18 @@ def _stop_audio_stream_internal():
|
|||||||
audio_process = None
|
audio_process = None
|
||||||
audio_rtl_process = None
|
audio_rtl_process = None
|
||||||
|
|
||||||
# Pause for SDR device USB interface to be released by kernel
|
# Brief pause for SDR device USB interface to be released by kernel.
|
||||||
|
# The _start_audio_stream retry loop handles longer contention windows
|
||||||
|
# so only a minimal delay is needed here.
|
||||||
if had_processes:
|
if had_processes:
|
||||||
time.sleep(1.0)
|
time.sleep(0.15)
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# API ENDPOINTS
|
# API ENDPOINTS
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
@listening_post_bp.route('/tools')
|
@receiver_bp.route('/tools')
|
||||||
def check_tools() -> Response:
|
def check_tools() -> Response:
|
||||||
"""Check for required tools."""
|
"""Check for required tools."""
|
||||||
rtl_fm = find_rtl_fm()
|
rtl_fm = find_rtl_fm()
|
||||||
@@ -939,10 +971,10 @@ def check_tools() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/start', methods=['POST'])
|
@receiver_bp.route('/scanner/start', methods=['POST'])
|
||||||
def start_scanner() -> Response:
|
def start_scanner() -> Response:
|
||||||
"""Start the frequency scanner."""
|
"""Start the frequency scanner."""
|
||||||
global scanner_thread, scanner_running, scanner_config, scanner_active_device, listening_active_device
|
global scanner_thread, scanner_running, scanner_config, scanner_active_device, receiver_active_device
|
||||||
|
|
||||||
with scanner_lock:
|
with scanner_lock:
|
||||||
if scanner_running:
|
if scanner_running:
|
||||||
@@ -1008,9 +1040,9 @@ def start_scanner() -> Response:
|
|||||||
'message': 'rtl_power not found. Install rtl-sdr tools.'
|
'message': 'rtl_power not found. Install rtl-sdr tools.'
|
||||||
}), 503
|
}), 503
|
||||||
# Release listening device if active
|
# Release listening device if active
|
||||||
if listening_active_device is not None:
|
if receiver_active_device is not None:
|
||||||
app_module.release_sdr_device(listening_active_device)
|
app_module.release_sdr_device(receiver_active_device)
|
||||||
listening_active_device = None
|
receiver_active_device = None
|
||||||
# Claim device for scanner
|
# Claim device for scanner
|
||||||
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
|
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
|
||||||
if error:
|
if error:
|
||||||
@@ -1036,9 +1068,9 @@ def start_scanner() -> Response:
|
|||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
|
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
|
||||||
}), 503
|
}), 503
|
||||||
if listening_active_device is not None:
|
if receiver_active_device is not None:
|
||||||
app_module.release_sdr_device(listening_active_device)
|
app_module.release_sdr_device(receiver_active_device)
|
||||||
listening_active_device = None
|
receiver_active_device = None
|
||||||
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
|
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -1058,7 +1090,7 @@ def start_scanner() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/stop', methods=['POST'])
|
@receiver_bp.route('/scanner/stop', methods=['POST'])
|
||||||
def stop_scanner() -> Response:
|
def stop_scanner() -> Response:
|
||||||
"""Stop the frequency scanner."""
|
"""Stop the frequency scanner."""
|
||||||
global scanner_running, scanner_active_device, scanner_power_process
|
global scanner_running, scanner_active_device, scanner_power_process
|
||||||
@@ -1082,7 +1114,7 @@ def stop_scanner() -> Response:
|
|||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/pause', methods=['POST'])
|
@receiver_bp.route('/scanner/pause', methods=['POST'])
|
||||||
def pause_scanner() -> Response:
|
def pause_scanner() -> Response:
|
||||||
"""Pause/resume the scanner."""
|
"""Pause/resume the scanner."""
|
||||||
global scanner_paused
|
global scanner_paused
|
||||||
@@ -1104,7 +1136,7 @@ def pause_scanner() -> Response:
|
|||||||
scanner_skip_signal = False
|
scanner_skip_signal = False
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/skip', methods=['POST'])
|
@receiver_bp.route('/scanner/skip', methods=['POST'])
|
||||||
def skip_signal() -> Response:
|
def skip_signal() -> Response:
|
||||||
"""Skip current signal and continue scanning."""
|
"""Skip current signal and continue scanning."""
|
||||||
global scanner_skip_signal
|
global scanner_skip_signal
|
||||||
@@ -1124,7 +1156,7 @@ def skip_signal() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/config', methods=['POST'])
|
@receiver_bp.route('/scanner/config', methods=['POST'])
|
||||||
def update_scanner_config() -> Response:
|
def update_scanner_config() -> Response:
|
||||||
"""Update scanner config while running (step, squelch, gain, dwell)."""
|
"""Update scanner config while running (step, squelch, gain, dwell)."""
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
@@ -1166,7 +1198,7 @@ def update_scanner_config() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/status')
|
@receiver_bp.route('/scanner/status')
|
||||||
def scanner_status() -> Response:
|
def scanner_status() -> Response:
|
||||||
"""Get scanner status."""
|
"""Get scanner status."""
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -1179,16 +1211,16 @@ def scanner_status() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/stream')
|
@receiver_bp.route('/scanner/stream')
|
||||||
def stream_scanner_events() -> Response:
|
def stream_scanner_events() -> Response:
|
||||||
"""SSE stream for scanner events."""
|
"""SSE stream for scanner events."""
|
||||||
def _on_msg(msg: dict[str, Any]) -> None:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
process_event('listening_scanner', msg, msg.get('type'))
|
process_event('receiver_scanner', msg, msg.get('type'))
|
||||||
|
|
||||||
response = Response(
|
response = Response(
|
||||||
sse_stream_fanout(
|
sse_stream_fanout(
|
||||||
source_queue=scanner_queue,
|
source_queue=scanner_queue,
|
||||||
channel_key='listening_scanner',
|
channel_key='receiver_scanner',
|
||||||
timeout=SSE_QUEUE_TIMEOUT,
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
on_message=_on_msg,
|
on_message=_on_msg,
|
||||||
@@ -1200,7 +1232,7 @@ def stream_scanner_events() -> Response:
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/log')
|
@receiver_bp.route('/scanner/log')
|
||||||
def get_activity_log() -> Response:
|
def get_activity_log() -> Response:
|
||||||
"""Get activity log."""
|
"""Get activity log."""
|
||||||
limit = request.args.get('limit', 100, type=int)
|
limit = request.args.get('limit', 100, type=int)
|
||||||
@@ -1211,7 +1243,7 @@ def get_activity_log() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/log/clear', methods=['POST'])
|
@receiver_bp.route('/scanner/log/clear', methods=['POST'])
|
||||||
def clear_activity_log() -> Response:
|
def clear_activity_log() -> Response:
|
||||||
"""Clear activity log."""
|
"""Clear activity log."""
|
||||||
with activity_log_lock:
|
with activity_log_lock:
|
||||||
@@ -1219,7 +1251,7 @@ def clear_activity_log() -> Response:
|
|||||||
return jsonify({'status': 'cleared'})
|
return jsonify({'status': 'cleared'})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/presets')
|
@receiver_bp.route('/presets')
|
||||||
def get_presets() -> Response:
|
def get_presets() -> Response:
|
||||||
"""Get scanner presets."""
|
"""Get scanner presets."""
|
||||||
presets = [
|
presets = [
|
||||||
@@ -1239,37 +1271,11 @@ def get_presets() -> Response:
|
|||||||
# MANUAL AUDIO ENDPOINTS (for direct listening)
|
# MANUAL AUDIO ENDPOINTS (for direct listening)
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
@listening_post_bp.route('/audio/start', methods=['POST'])
|
@receiver_bp.route('/audio/start', methods=['POST'])
|
||||||
def start_audio() -> Response:
|
def start_audio() -> Response:
|
||||||
"""Start audio at specific frequency (manual mode)."""
|
"""Start audio at specific frequency (manual mode)."""
|
||||||
global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread
|
global scanner_running, scanner_active_device, receiver_active_device, scanner_power_process, scanner_thread
|
||||||
|
global audio_running, audio_frequency, audio_modulation, audio_source, audio_start_token
|
||||||
# Stop scanner if running
|
|
||||||
if scanner_running:
|
|
||||||
scanner_running = False
|
|
||||||
if scanner_active_device is not None:
|
|
||||||
app_module.release_sdr_device(scanner_active_device)
|
|
||||||
scanner_active_device = None
|
|
||||||
if scanner_thread and scanner_thread.is_alive():
|
|
||||||
try:
|
|
||||||
scanner_thread.join(timeout=2.0)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if scanner_power_process and scanner_power_process.poll() is None:
|
|
||||||
try:
|
|
||||||
scanner_power_process.terminate()
|
|
||||||
scanner_power_process.wait(timeout=1)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
scanner_power_process.kill()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
scanner_power_process = None
|
|
||||||
try:
|
|
||||||
subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
@@ -1280,6 +1286,13 @@ def start_audio() -> Response:
|
|||||||
gain = int(data.get('gain', 40))
|
gain = int(data.get('gain', 40))
|
||||||
device = int(data.get('device', 0))
|
device = int(data.get('device', 0))
|
||||||
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
|
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||||
|
request_token_raw = data.get('request_token')
|
||||||
|
request_token = int(request_token_raw) if request_token_raw is not None else None
|
||||||
|
bias_t_raw = data.get('bias_t', scanner_config.get('bias_t', False))
|
||||||
|
if isinstance(bias_t_raw, str):
|
||||||
|
bias_t = bias_t_raw.strip().lower() in {'1', 'true', 'yes', 'on'}
|
||||||
|
else:
|
||||||
|
bias_t = bool(bias_t_raw)
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -1299,91 +1312,196 @@ def start_audio() -> Response:
|
|||||||
'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}'
|
'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
# Update config for audio
|
with audio_start_lock:
|
||||||
scanner_config['squelch'] = squelch
|
if request_token is not None:
|
||||||
scanner_config['gain'] = gain
|
if request_token < audio_start_token:
|
||||||
scanner_config['device'] = device
|
return jsonify({
|
||||||
scanner_config['sdr_type'] = sdr_type
|
'status': 'stale',
|
||||||
|
'message': 'Superseded audio start request',
|
||||||
|
'source': audio_source,
|
||||||
|
'superseded': True,
|
||||||
|
}), 409
|
||||||
|
audio_start_token = request_token
|
||||||
|
else:
|
||||||
|
audio_start_token += 1
|
||||||
|
request_token = audio_start_token
|
||||||
|
|
||||||
# Stop waterfall if it's using the same SDR (SSE path)
|
# Stop scanner if running
|
||||||
if waterfall_running and waterfall_active_device == device:
|
if scanner_running:
|
||||||
_stop_waterfall_internal()
|
scanner_running = False
|
||||||
time.sleep(0.2)
|
if scanner_active_device is not None:
|
||||||
|
app_module.release_sdr_device(scanner_active_device)
|
||||||
|
scanner_active_device = None
|
||||||
|
if scanner_thread and scanner_thread.is_alive():
|
||||||
|
try:
|
||||||
|
scanner_thread.join(timeout=2.0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if scanner_power_process and scanner_power_process.poll() is None:
|
||||||
|
try:
|
||||||
|
scanner_power_process.terminate()
|
||||||
|
scanner_power_process.wait(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
scanner_power_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
scanner_power_process = None
|
||||||
|
try:
|
||||||
|
subprocess.run(['pkill', '-9', 'rtl_power'], capture_output=True, timeout=0.5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
# Claim device for listening audio. The WebSocket waterfall handler
|
# Update config for audio
|
||||||
# may still be tearing down its IQ capture process (thread join +
|
scanner_config['squelch'] = squelch
|
||||||
# safe_terminate can take several seconds), so we retry with back-off
|
scanner_config['gain'] = gain
|
||||||
# to give the USB device time to be fully released.
|
scanner_config['device'] = device
|
||||||
if listening_active_device is None or listening_active_device != device:
|
scanner_config['sdr_type'] = sdr_type
|
||||||
if listening_active_device is not None:
|
scanner_config['bias_t'] = bias_t
|
||||||
app_module.release_sdr_device(listening_active_device)
|
|
||||||
listening_active_device = None
|
|
||||||
|
|
||||||
error = None
|
# Preferred path: when waterfall WebSocket is active on the same SDR,
|
||||||
max_claim_attempts = 6
|
# derive monitor audio from that IQ stream instead of spawning rtl_fm.
|
||||||
for attempt in range(max_claim_attempts):
|
try:
|
||||||
# Force-release a stale waterfall registry entry on each
|
from routes.waterfall_websocket import (
|
||||||
# attempt — the WebSocket handler may not have finished
|
get_shared_capture_status,
|
||||||
# cleanup yet.
|
start_shared_monitor_from_capture,
|
||||||
device_status = app_module.get_sdr_device_status()
|
)
|
||||||
if device_status.get(device) == 'waterfall':
|
|
||||||
app_module.release_sdr_device(device)
|
|
||||||
|
|
||||||
error = app_module.claim_sdr_device(device, 'listening')
|
shared = get_shared_capture_status()
|
||||||
if not error:
|
if shared.get('running') and shared.get('device') == device:
|
||||||
break
|
_stop_audio_stream()
|
||||||
if attempt < max_claim_attempts - 1:
|
ok, msg = start_shared_monitor_from_capture(
|
||||||
logger.debug(
|
device=device,
|
||||||
f"Device claim attempt {attempt + 1}/{max_claim_attempts} "
|
frequency_mhz=frequency,
|
||||||
f"failed, retrying in 0.5s: {error}"
|
modulation=modulation,
|
||||||
|
squelch=squelch,
|
||||||
)
|
)
|
||||||
time.sleep(0.5)
|
if ok:
|
||||||
|
audio_running = True
|
||||||
|
audio_frequency = frequency
|
||||||
|
audio_modulation = modulation
|
||||||
|
audio_source = 'waterfall'
|
||||||
|
# Shared monitor uses the waterfall's existing SDR claim.
|
||||||
|
if receiver_active_device is not None:
|
||||||
|
app_module.release_sdr_device(receiver_active_device)
|
||||||
|
receiver_active_device = None
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'frequency': frequency,
|
||||||
|
'modulation': modulation,
|
||||||
|
'source': 'waterfall',
|
||||||
|
'request_token': request_token,
|
||||||
|
})
|
||||||
|
logger.warning(f"Shared waterfall monitor unavailable: {msg}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Shared waterfall monitor probe failed: {e}")
|
||||||
|
|
||||||
if error:
|
# Stop waterfall if it's using the same SDR (SSE path)
|
||||||
|
if waterfall_running and waterfall_active_device == device:
|
||||||
|
_stop_waterfall_internal()
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
# Claim device for listening audio. The WebSocket waterfall handler
|
||||||
|
# may still be tearing down its IQ capture process (thread join +
|
||||||
|
# safe_terminate can take several seconds), so we retry with back-off
|
||||||
|
# to give the USB device time to be fully released.
|
||||||
|
if receiver_active_device is None or receiver_active_device != device:
|
||||||
|
if receiver_active_device is not None:
|
||||||
|
app_module.release_sdr_device(receiver_active_device)
|
||||||
|
receiver_active_device = None
|
||||||
|
|
||||||
|
error = None
|
||||||
|
max_claim_attempts = 6
|
||||||
|
for attempt in range(max_claim_attempts):
|
||||||
|
error = app_module.claim_sdr_device(device, 'receiver')
|
||||||
|
if not error:
|
||||||
|
break
|
||||||
|
if attempt < max_claim_attempts - 1:
|
||||||
|
logger.debug(
|
||||||
|
f"Device claim attempt {attempt + 1}/{max_claim_attempts} "
|
||||||
|
f"failed, retrying in 0.5s: {error}"
|
||||||
|
)
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
receiver_active_device = device
|
||||||
|
|
||||||
|
_start_audio_stream(frequency, modulation)
|
||||||
|
|
||||||
|
if audio_running:
|
||||||
|
audio_source = 'process'
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'started',
|
||||||
'error_type': 'DEVICE_BUSY',
|
'frequency': audio_frequency,
|
||||||
'message': error
|
'modulation': audio_modulation,
|
||||||
}), 409
|
'source': 'process',
|
||||||
listening_active_device = device
|
'request_token': request_token,
|
||||||
|
})
|
||||||
|
|
||||||
_start_audio_stream(frequency, modulation)
|
# Avoid leaving a stale device claim after startup failure.
|
||||||
|
if receiver_active_device is not None:
|
||||||
|
app_module.release_sdr_device(receiver_active_device)
|
||||||
|
receiver_active_device = None
|
||||||
|
|
||||||
if audio_running:
|
start_error = ''
|
||||||
return jsonify({
|
for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'):
|
||||||
'status': 'started',
|
try:
|
||||||
'frequency': frequency,
|
with open(log_path, 'r') as handle:
|
||||||
'modulation': modulation
|
content = handle.read().strip()
|
||||||
})
|
if content:
|
||||||
else:
|
start_error = content.splitlines()[-1]
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
message = 'Failed to start audio. Check SDR device.'
|
||||||
|
if start_error:
|
||||||
|
message = f'Failed to start audio: {start_error}'
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Failed to start audio. Check SDR device.'
|
'message': message
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/audio/stop', methods=['POST'])
|
@receiver_bp.route('/audio/stop', methods=['POST'])
|
||||||
def stop_audio() -> Response:
|
def stop_audio() -> Response:
|
||||||
"""Stop audio."""
|
"""Stop audio."""
|
||||||
global listening_active_device
|
global receiver_active_device
|
||||||
_stop_audio_stream()
|
_stop_audio_stream()
|
||||||
if listening_active_device is not None:
|
if receiver_active_device is not None:
|
||||||
app_module.release_sdr_device(listening_active_device)
|
app_module.release_sdr_device(receiver_active_device)
|
||||||
listening_active_device = None
|
receiver_active_device = None
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/audio/status')
|
@receiver_bp.route('/audio/status')
|
||||||
def audio_status() -> Response:
|
def audio_status() -> Response:
|
||||||
"""Get audio status."""
|
"""Get audio status."""
|
||||||
|
running = audio_running
|
||||||
|
if audio_source == 'waterfall':
|
||||||
|
try:
|
||||||
|
from routes.waterfall_websocket import get_shared_capture_status
|
||||||
|
|
||||||
|
shared = get_shared_capture_status()
|
||||||
|
running = bool(shared.get('running') and shared.get('monitor_enabled'))
|
||||||
|
except Exception:
|
||||||
|
running = False
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'running': audio_running,
|
'running': running,
|
||||||
'frequency': audio_frequency,
|
'frequency': audio_frequency,
|
||||||
'modulation': audio_modulation
|
'modulation': audio_modulation,
|
||||||
|
'source': audio_source,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/audio/debug')
|
@receiver_bp.route('/audio/debug')
|
||||||
def audio_debug() -> Response:
|
def audio_debug() -> Response:
|
||||||
"""Get audio debug status and recent stderr logs."""
|
"""Get audio debug status and recent stderr logs."""
|
||||||
rtl_log_path = '/tmp/rtl_fm_stderr.log'
|
rtl_log_path = '/tmp/rtl_fm_stderr.log'
|
||||||
@@ -1397,26 +1515,51 @@ def audio_debug() -> Response:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
shared = {}
|
||||||
|
if audio_source == 'waterfall':
|
||||||
|
try:
|
||||||
|
from routes.waterfall_websocket import get_shared_capture_status
|
||||||
|
|
||||||
|
shared = get_shared_capture_status()
|
||||||
|
except Exception:
|
||||||
|
shared = {}
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'running': audio_running,
|
'running': audio_running,
|
||||||
'frequency': audio_frequency,
|
'frequency': audio_frequency,
|
||||||
'modulation': audio_modulation,
|
'modulation': audio_modulation,
|
||||||
|
'source': audio_source,
|
||||||
'sdr_type': scanner_config.get('sdr_type', 'rtlsdr'),
|
'sdr_type': scanner_config.get('sdr_type', 'rtlsdr'),
|
||||||
'device': scanner_config.get('device', 0),
|
'device': scanner_config.get('device', 0),
|
||||||
'gain': scanner_config.get('gain', 0),
|
'gain': scanner_config.get('gain', 0),
|
||||||
'squelch': scanner_config.get('squelch', 0),
|
'squelch': scanner_config.get('squelch', 0),
|
||||||
'audio_process_alive': bool(audio_process and audio_process.poll() is None),
|
'audio_process_alive': bool(audio_process and audio_process.poll() is None),
|
||||||
|
'shared_capture': shared,
|
||||||
'rtl_fm_stderr': _read_log(rtl_log_path),
|
'rtl_fm_stderr': _read_log(rtl_log_path),
|
||||||
'ffmpeg_stderr': _read_log(ffmpeg_log_path),
|
'ffmpeg_stderr': _read_log(ffmpeg_log_path),
|
||||||
'audio_probe_bytes': os.path.getsize(sample_path) if os.path.exists(sample_path) else 0,
|
'audio_probe_bytes': os.path.getsize(sample_path) if os.path.exists(sample_path) else 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/audio/probe')
|
@receiver_bp.route('/audio/probe')
|
||||||
def audio_probe() -> Response:
|
def audio_probe() -> Response:
|
||||||
"""Grab a small chunk of audio bytes from the pipeline for debugging."""
|
"""Grab a small chunk of audio bytes from the pipeline for debugging."""
|
||||||
global audio_process
|
global audio_process
|
||||||
|
|
||||||
|
if audio_source == 'waterfall':
|
||||||
|
try:
|
||||||
|
from routes.waterfall_websocket import read_shared_monitor_audio_chunk
|
||||||
|
|
||||||
|
data = read_shared_monitor_audio_chunk(timeout=2.0)
|
||||||
|
if not data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'no shared audio data available'}), 504
|
||||||
|
sample_path = '/tmp/audio_probe.bin'
|
||||||
|
with open(sample_path, 'wb') as handle:
|
||||||
|
handle.write(data)
|
||||||
|
return jsonify({'status': 'ok', 'bytes': len(data), 'source': 'waterfall'})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
if not audio_process or not audio_process.stdout:
|
if not audio_process or not audio_process.stdout:
|
||||||
return jsonify({'status': 'error', 'message': 'audio process not running'}), 400
|
return jsonify({'status': 'error', 'message': 'audio process not running'}), 400
|
||||||
|
|
||||||
@@ -1438,17 +1581,71 @@ def audio_probe() -> Response:
|
|||||||
return jsonify({'status': 'ok', 'bytes': size})
|
return jsonify({'status': 'ok', 'bytes': size})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/audio/stream')
|
@receiver_bp.route('/audio/stream')
|
||||||
def stream_audio() -> Response:
|
def stream_audio() -> Response:
|
||||||
"""Stream WAV audio."""
|
"""Stream WAV audio."""
|
||||||
# Wait for audio to be ready (up to 2 seconds for modulation/squelch changes)
|
if audio_source == 'waterfall':
|
||||||
|
for _ in range(40):
|
||||||
|
if audio_running:
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
if not audio_running:
|
||||||
|
return Response(b'', mimetype='audio/wav', status=204)
|
||||||
|
|
||||||
|
def generate_shared():
|
||||||
|
global audio_running, audio_source
|
||||||
|
try:
|
||||||
|
from routes.waterfall_websocket import (
|
||||||
|
get_shared_capture_status,
|
||||||
|
read_shared_monitor_audio_chunk,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Browser expects an immediate WAV header.
|
||||||
|
yield _wav_header(sample_rate=48000)
|
||||||
|
inactive_since: float | None = None
|
||||||
|
|
||||||
|
while audio_running and audio_source == 'waterfall':
|
||||||
|
chunk = read_shared_monitor_audio_chunk(timeout=1.0)
|
||||||
|
if chunk:
|
||||||
|
inactive_since = None
|
||||||
|
yield chunk
|
||||||
|
continue
|
||||||
|
shared = get_shared_capture_status()
|
||||||
|
if shared.get('running') and shared.get('monitor_enabled'):
|
||||||
|
inactive_since = None
|
||||||
|
continue
|
||||||
|
if inactive_since is None:
|
||||||
|
inactive_since = time.monotonic()
|
||||||
|
continue
|
||||||
|
if (time.monotonic() - inactive_since) < 4.0:
|
||||||
|
continue
|
||||||
|
if not shared.get('running') or not shared.get('monitor_enabled'):
|
||||||
|
audio_running = False
|
||||||
|
audio_source = 'process'
|
||||||
|
break
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
generate_shared(),
|
||||||
|
mimetype='audio/wav',
|
||||||
|
headers={
|
||||||
|
'Content-Type': 'audio/wav',
|
||||||
|
'Cache-Control': 'no-cache, no-store',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
'Transfer-Encoding': 'chunked',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for audio process to be ready (up to 2 seconds).
|
||||||
for _ in range(40):
|
for _ in range(40):
|
||||||
if audio_running and audio_process:
|
if audio_running and audio_process:
|
||||||
break
|
break
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
|
|
||||||
if not audio_running or not audio_process:
|
if not audio_running or not audio_process:
|
||||||
return Response(b'', mimetype='audio/mpeg', status=204)
|
return Response(b'', mimetype='audio/wav', status=204)
|
||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
# Capture local reference to avoid race condition with stop
|
# Capture local reference to avoid race condition with stop
|
||||||
@@ -1474,21 +1671,25 @@ def stream_audio() -> Response:
|
|||||||
yield header_chunk
|
yield header_chunk
|
||||||
|
|
||||||
# Stream real-time audio
|
# Stream real-time audio
|
||||||
first_chunk_deadline = time.time() + 3.0
|
first_chunk_deadline = time.time() + 20.0
|
||||||
|
warned_wait = False
|
||||||
while audio_running and proc.poll() is None:
|
while audio_running and proc.poll() is None:
|
||||||
# Use select to avoid blocking forever
|
# Use select to avoid blocking forever
|
||||||
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
|
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
|
||||||
if ready:
|
if ready:
|
||||||
chunk = proc.stdout.read(8192)
|
chunk = proc.stdout.read(8192)
|
||||||
if chunk:
|
if chunk:
|
||||||
|
warned_wait = False
|
||||||
yield chunk
|
yield chunk
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
# If no data arrives shortly after start, exit so caller can retry
|
# Keep connection open while demodulator settles.
|
||||||
if time.time() > first_chunk_deadline:
|
if time.time() > first_chunk_deadline:
|
||||||
logger.warning("Audio stream timed out waiting for first chunk")
|
if not warned_wait:
|
||||||
break
|
logger.warning("Audio stream still waiting for first chunk")
|
||||||
|
warned_wait = True
|
||||||
|
continue
|
||||||
# Timeout - check if process died
|
# Timeout - check if process died
|
||||||
if proc.poll() is not None:
|
if proc.poll() is not None:
|
||||||
break
|
break
|
||||||
@@ -1513,7 +1714,7 @@ def stream_audio() -> Response:
|
|||||||
# SIGNAL IDENTIFICATION ENDPOINT
|
# SIGNAL IDENTIFICATION ENDPOINT
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
@listening_post_bp.route('/signal/guess', methods=['POST'])
|
@receiver_bp.route('/signal/guess', methods=['POST'])
|
||||||
def guess_signal() -> Response:
|
def guess_signal() -> Response:
|
||||||
"""Identify a signal based on frequency, modulation, and other parameters."""
|
"""Identify a signal based on frequency, modulation, and other parameters."""
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
@@ -1621,9 +1822,20 @@ def _waterfall_loop():
|
|||||||
"""Continuous rtl_power sweep loop emitting waterfall data."""
|
"""Continuous rtl_power sweep loop emitting waterfall data."""
|
||||||
global waterfall_running, waterfall_process
|
global waterfall_running, waterfall_process
|
||||||
|
|
||||||
|
def _queue_waterfall_error(message: str) -> None:
|
||||||
|
try:
|
||||||
|
waterfall_queue.put_nowait({
|
||||||
|
'type': 'waterfall_error',
|
||||||
|
'message': message,
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
rtl_power_path = find_rtl_power()
|
rtl_power_path = find_rtl_power()
|
||||||
if not rtl_power_path:
|
if not rtl_power_path:
|
||||||
logger.error("rtl_power not found for waterfall")
|
logger.error("rtl_power not found for waterfall")
|
||||||
|
_queue_waterfall_error('rtl_power not found')
|
||||||
waterfall_running = False
|
waterfall_running = False
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1646,17 +1858,33 @@ def _waterfall_loop():
|
|||||||
waterfall_process = subprocess.Popen(
|
waterfall_process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.PIPE,
|
||||||
bufsize=1,
|
bufsize=1,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Detect immediate startup failures (e.g. device busy / no device).
|
||||||
|
time.sleep(0.35)
|
||||||
|
if waterfall_process.poll() is not None:
|
||||||
|
stderr_text = ''
|
||||||
|
try:
|
||||||
|
if waterfall_process.stderr:
|
||||||
|
stderr_text = waterfall_process.stderr.read().strip()
|
||||||
|
except Exception:
|
||||||
|
stderr_text = ''
|
||||||
|
msg = stderr_text or f'rtl_power exited early (code {waterfall_process.returncode})'
|
||||||
|
logger.error(f"Waterfall startup failed: {msg}")
|
||||||
|
_queue_waterfall_error(msg)
|
||||||
|
return
|
||||||
|
|
||||||
current_ts = None
|
current_ts = None
|
||||||
all_bins: list[float] = []
|
all_bins: list[float] = []
|
||||||
sweep_start_hz = start_hz
|
sweep_start_hz = start_hz
|
||||||
sweep_end_hz = end_hz
|
sweep_end_hz = end_hz
|
||||||
|
received_any = False
|
||||||
|
|
||||||
if not waterfall_process.stdout:
|
if not waterfall_process.stdout:
|
||||||
|
_queue_waterfall_error('rtl_power stdout unavailable')
|
||||||
return
|
return
|
||||||
|
|
||||||
for line in waterfall_process.stdout:
|
for line in waterfall_process.stdout:
|
||||||
@@ -1666,6 +1894,7 @@ def _waterfall_loop():
|
|||||||
ts, seg_start, seg_end, bins = _parse_rtl_power_line(line)
|
ts, seg_start, seg_end, bins = _parse_rtl_power_line(line)
|
||||||
if ts is None or not bins:
|
if ts is None or not bins:
|
||||||
continue
|
continue
|
||||||
|
received_any = True
|
||||||
|
|
||||||
if current_ts is None:
|
if current_ts is None:
|
||||||
current_ts = ts
|
current_ts = ts
|
||||||
@@ -1723,8 +1952,12 @@ def _waterfall_loop():
|
|||||||
except queue.Full:
|
except queue.Full:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if waterfall_running and not received_any:
|
||||||
|
_queue_waterfall_error('No waterfall FFT data received from rtl_power')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Waterfall loop error: {e}")
|
logger.error(f"Waterfall loop error: {e}")
|
||||||
|
_queue_waterfall_error(f"Waterfall loop error: {e}")
|
||||||
finally:
|
finally:
|
||||||
waterfall_running = False
|
waterfall_running = False
|
||||||
if waterfall_process and waterfall_process.poll() is None:
|
if waterfall_process and waterfall_process.poll() is None:
|
||||||
@@ -1761,14 +1994,19 @@ def _stop_waterfall_internal() -> None:
|
|||||||
waterfall_active_device = None
|
waterfall_active_device = None
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/waterfall/start', methods=['POST'])
|
@receiver_bp.route('/waterfall/start', methods=['POST'])
|
||||||
def start_waterfall() -> Response:
|
def start_waterfall() -> Response:
|
||||||
"""Start the waterfall/spectrogram display."""
|
"""Start the waterfall/spectrogram display."""
|
||||||
global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device
|
global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device
|
||||||
|
|
||||||
with waterfall_lock:
|
with waterfall_lock:
|
||||||
if waterfall_running:
|
if waterfall_running:
|
||||||
return jsonify({'status': 'error', 'message': 'Waterfall already running'}), 409
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'already_running': True,
|
||||||
|
'message': 'Waterfall already running',
|
||||||
|
'config': waterfall_config,
|
||||||
|
})
|
||||||
|
|
||||||
if not find_rtl_power():
|
if not find_rtl_power():
|
||||||
return jsonify({'status': 'error', 'message': 'rtl_power not found'}), 503
|
return jsonify({'status': 'error', 'message': 'rtl_power not found'}), 503
|
||||||
@@ -1817,7 +2055,7 @@ def start_waterfall() -> Response:
|
|||||||
return jsonify({'status': 'started', 'config': waterfall_config})
|
return jsonify({'status': 'started', 'config': waterfall_config})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/waterfall/stop', methods=['POST'])
|
@receiver_bp.route('/waterfall/stop', methods=['POST'])
|
||||||
def stop_waterfall() -> Response:
|
def stop_waterfall() -> Response:
|
||||||
"""Stop the waterfall display."""
|
"""Stop the waterfall display."""
|
||||||
_stop_waterfall_internal()
|
_stop_waterfall_internal()
|
||||||
@@ -1825,7 +2063,7 @@ def stop_waterfall() -> Response:
|
|||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/waterfall/stream')
|
@receiver_bp.route('/waterfall/stream')
|
||||||
def stream_waterfall() -> Response:
|
def stream_waterfall() -> Response:
|
||||||
"""SSE stream for waterfall data."""
|
"""SSE stream for waterfall data."""
|
||||||
def _on_msg(msg: dict[str, Any]) -> None:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
@@ -1834,7 +2072,7 @@ def stream_waterfall() -> Response:
|
|||||||
response = Response(
|
response = Response(
|
||||||
sse_stream_fanout(
|
sse_stream_fanout(
|
||||||
source_queue=waterfall_queue,
|
source_queue=waterfall_queue,
|
||||||
channel_key='listening_waterfall',
|
channel_key='receiver_waterfall',
|
||||||
timeout=SSE_QUEUE_TIMEOUT,
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
on_message=_on_msg,
|
on_message=_on_msg,
|
||||||
|
|||||||
+16
-1
@@ -108,6 +108,20 @@ def log_message(msg: dict[str, Any]) -> None:
|
|||||||
logger.error(f"Failed to log message: {e}")
|
logger.error(f"Failed to log message: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]:
|
||||||
|
"""Compress recent PCM samples into a signed 8-bit waveform for SSE."""
|
||||||
|
if not samples:
|
||||||
|
return []
|
||||||
|
|
||||||
|
window = samples[-window_size:] if len(samples) > window_size else samples
|
||||||
|
waveform: list[int] = []
|
||||||
|
for sample in window:
|
||||||
|
# Convert int16 PCM to int8 range for lightweight transport.
|
||||||
|
packed = int(round(sample / 256))
|
||||||
|
waveform.append(max(-127, min(127, packed)))
|
||||||
|
return waveform
|
||||||
|
|
||||||
|
|
||||||
def audio_relay_thread(
|
def audio_relay_thread(
|
||||||
rtl_stdout,
|
rtl_stdout,
|
||||||
multimon_stdin,
|
multimon_stdin,
|
||||||
@@ -118,7 +132,7 @@ def audio_relay_thread(
|
|||||||
|
|
||||||
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
||||||
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
||||||
event onto *output_queue*.
|
event plus a compact waveform sample onto *output_queue*.
|
||||||
"""
|
"""
|
||||||
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
||||||
INTERVAL = 0.1 # seconds between scope updates
|
INTERVAL = 0.1 # seconds between scope updates
|
||||||
@@ -152,6 +166,7 @@ def audio_relay_thread(
|
|||||||
'type': 'scope',
|
'type': 'scope',
|
||||||
'rms': rms,
|
'rms': rms,
|
||||||
'peak': peak,
|
'peak': peak,
|
||||||
|
'waveform': _encode_scope_waveform(samples),
|
||||||
})
|
})
|
||||||
except (struct.error, ValueError, queue.Full):
|
except (struct.error, ValueError, queue.Full):
|
||||||
pass
|
pass
|
||||||
|
|||||||
+44
-5
@@ -3,12 +3,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import queue
|
import queue
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Generator
|
from typing import Any, Generator
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
@@ -33,6 +34,36 @@ sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
|
|||||||
_MAX_RSSI_HISTORY = 60
|
_MAX_RSSI_HISTORY = 60
|
||||||
|
|
||||||
|
|
||||||
|
def _build_scope_waveform(rssi: float, snr: float, noise: float, points: int = 256) -> list[int]:
|
||||||
|
"""Synthesize a compact waveform from rtl_433 level metrics."""
|
||||||
|
points = max(32, min(points, 512))
|
||||||
|
|
||||||
|
# rssi is usually negative; stronger signals are closer to 0 dBm.
|
||||||
|
rssi_norm = min(max(abs(rssi) / 40.0, 0.0), 1.0)
|
||||||
|
snr_norm = min(max((snr + 5.0) / 35.0, 0.0), 1.0)
|
||||||
|
noise_norm = min(max(abs(noise) / 40.0, 0.0), 1.0)
|
||||||
|
|
||||||
|
amplitude = max(0.06, min(1.0, (0.6 * rssi_norm + 0.4 * snr_norm) - (0.22 * noise_norm)))
|
||||||
|
cycles = 3.0 + (snr_norm * 8.0)
|
||||||
|
harmonic = 0.25 + (0.35 * snr_norm)
|
||||||
|
hiss = 0.08 + (0.18 * noise_norm)
|
||||||
|
phase = (time.monotonic() * (1.4 + (snr_norm * 2.2))) % (2.0 * math.pi)
|
||||||
|
|
||||||
|
waveform: list[int] = []
|
||||||
|
for i in range(points):
|
||||||
|
t = i / (points - 1)
|
||||||
|
base = math.sin((2.0 * math.pi * cycles * t) + phase)
|
||||||
|
overtone = math.sin((2.0 * math.pi * (cycles * 2.4) * t) + (phase * 0.7))
|
||||||
|
noise_wobble = math.sin((2.0 * math.pi * (cycles * 7.0) * t) + (phase * 2.1))
|
||||||
|
|
||||||
|
sample = amplitude * (base + (harmonic * overtone) + (hiss * noise_wobble))
|
||||||
|
sample /= (1.0 + harmonic + hiss)
|
||||||
|
packed = int(round(max(-1.0, min(1.0, sample)) * 127.0))
|
||||||
|
waveform.append(max(-127, min(127, packed)))
|
||||||
|
|
||||||
|
return waveform
|
||||||
|
|
||||||
|
|
||||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||||
"""Stream rtl_433 JSON output to queue."""
|
"""Stream rtl_433 JSON output to queue."""
|
||||||
try:
|
try:
|
||||||
@@ -66,13 +97,21 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
noise = data.get('noise')
|
noise = data.get('noise')
|
||||||
if rssi is not None or snr is not None:
|
if rssi is not None or snr is not None:
|
||||||
try:
|
try:
|
||||||
|
rssi_value = float(rssi) if rssi is not None else 0.0
|
||||||
|
snr_value = float(snr) if snr is not None else 0.0
|
||||||
|
noise_value = float(noise) if noise is not None else 0.0
|
||||||
app_module.sensor_queue.put_nowait({
|
app_module.sensor_queue.put_nowait({
|
||||||
'type': 'scope',
|
'type': 'scope',
|
||||||
'rssi': rssi if rssi is not None else 0,
|
'rssi': rssi_value,
|
||||||
'snr': snr if snr is not None else 0,
|
'snr': snr_value,
|
||||||
'noise': noise if noise is not None else 0,
|
'noise': noise_value,
|
||||||
|
'waveform': _build_scope_waveform(
|
||||||
|
rssi=rssi_value,
|
||||||
|
snr=snr_value,
|
||||||
|
noise=noise_value,
|
||||||
|
),
|
||||||
})
|
})
|
||||||
except queue.Full:
|
except (TypeError, ValueError, queue.Full):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Log if enabled
|
# Log if enabled
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
"""Signal identification enrichment routes (SigID Wiki proxy lookup)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('intercept.signalid')
|
||||||
|
|
||||||
|
signalid_bp = Blueprint('signalid', __name__, url_prefix='/signalid')
|
||||||
|
|
||||||
|
SIGID_API_URL = 'https://www.sigidwiki.com/api.php'
|
||||||
|
SIGID_USER_AGENT = 'INTERCEPT-SignalID/1.0'
|
||||||
|
SIGID_TIMEOUT_SECONDS = 12
|
||||||
|
SIGID_CACHE_TTL_SECONDS = 600
|
||||||
|
|
||||||
|
_cache: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_get(key: str) -> Any | None:
|
||||||
|
entry = _cache.get(key)
|
||||||
|
if not entry:
|
||||||
|
return None
|
||||||
|
if time.time() >= entry['expires']:
|
||||||
|
_cache.pop(key, None)
|
||||||
|
return None
|
||||||
|
return entry['data']
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_set(key: str, data: Any, ttl_seconds: int = SIGID_CACHE_TTL_SECONDS) -> None:
|
||||||
|
_cache[key] = {
|
||||||
|
'data': data,
|
||||||
|
'expires': time.time() + ttl_seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_api_json(params: dict[str, str]) -> dict[str, Any] | None:
|
||||||
|
query = urllib.parse.urlencode(params, doseq=True)
|
||||||
|
url = f'{SIGID_API_URL}?{query}'
|
||||||
|
req = urllib.request.Request(url, headers={'User-Agent': SIGID_USER_AGENT})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=SIGID_TIMEOUT_SECONDS) as resp:
|
||||||
|
payload = resp.read().decode('utf-8', errors='replace')
|
||||||
|
data = json.loads(payload)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('SigID API request failed: %s', exc)
|
||||||
|
return None
|
||||||
|
if isinstance(data, dict) and data.get('error'):
|
||||||
|
logger.warning('SigID API returned error: %s', data.get('error'))
|
||||||
|
return None
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _ask_query(query: str) -> dict[str, Any] | None:
|
||||||
|
return _fetch_api_json({
|
||||||
|
'action': 'ask',
|
||||||
|
'query': query,
|
||||||
|
'format': 'json',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _search_query(search_text: str, limit: int) -> dict[str, Any] | None:
|
||||||
|
return _fetch_api_json({
|
||||||
|
'action': 'query',
|
||||||
|
'list': 'search',
|
||||||
|
'srsearch': search_text,
|
||||||
|
'srlimit': str(limit),
|
||||||
|
'format': 'json',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float_list(values: Any) -> list[float]:
|
||||||
|
if not isinstance(values, list):
|
||||||
|
return []
|
||||||
|
out: list[float] = []
|
||||||
|
for value in values:
|
||||||
|
try:
|
||||||
|
out.append(float(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _to_text_list(values: Any) -> list[str]:
|
||||||
|
if not isinstance(values, list):
|
||||||
|
return []
|
||||||
|
out: list[str] = []
|
||||||
|
for value in values:
|
||||||
|
text = str(value or '').strip()
|
||||||
|
if text:
|
||||||
|
out.append(text)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_modes(values: list[str]) -> list[str]:
|
||||||
|
out: list[str] = []
|
||||||
|
for value in values:
|
||||||
|
for token in str(value).replace('/', ',').split(','):
|
||||||
|
mode = token.strip().upper()
|
||||||
|
if mode and mode not in out:
|
||||||
|
out.append(mode)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_matches_from_ask(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
results = data.get('query', {}).get('results', {})
|
||||||
|
if not isinstance(results, dict):
|
||||||
|
return []
|
||||||
|
|
||||||
|
matches: list[dict[str, Any]] = []
|
||||||
|
for title, entry in results.items():
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
printouts = entry.get('printouts', {})
|
||||||
|
if not isinstance(printouts, dict):
|
||||||
|
printouts = {}
|
||||||
|
|
||||||
|
frequencies_hz = _to_float_list(printouts.get('Frequencies'))
|
||||||
|
frequencies_mhz = [round(v / 1e6, 6) for v in frequencies_hz if v > 0]
|
||||||
|
|
||||||
|
modes = _normalize_modes(_to_text_list(printouts.get('Mode')))
|
||||||
|
modulations = _normalize_modes(_to_text_list(printouts.get('Modulation')))
|
||||||
|
|
||||||
|
match = {
|
||||||
|
'title': str(entry.get('fulltext') or title),
|
||||||
|
'url': str(entry.get('fullurl') or ''),
|
||||||
|
'frequencies_mhz': frequencies_mhz,
|
||||||
|
'modes': modes,
|
||||||
|
'modulations': modulations,
|
||||||
|
'source': 'SigID Wiki',
|
||||||
|
}
|
||||||
|
matches.append(match)
|
||||||
|
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_matches(matches: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
deduped: dict[str, dict[str, Any]] = {}
|
||||||
|
for match in matches:
|
||||||
|
key = f"{match.get('title', '')}|{match.get('url', '')}"
|
||||||
|
if key not in deduped:
|
||||||
|
deduped[key] = match
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Merge frequencies/modes/modulations from duplicates.
|
||||||
|
existing = deduped[key]
|
||||||
|
for field in ('frequencies_mhz', 'modes', 'modulations'):
|
||||||
|
base = existing.get(field, [])
|
||||||
|
extra = match.get(field, [])
|
||||||
|
if not isinstance(base, list):
|
||||||
|
base = []
|
||||||
|
if not isinstance(extra, list):
|
||||||
|
extra = []
|
||||||
|
merged = list(base)
|
||||||
|
for item in extra:
|
||||||
|
if item not in merged:
|
||||||
|
merged.append(item)
|
||||||
|
existing[field] = merged
|
||||||
|
return list(deduped.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _rank_matches(
|
||||||
|
matches: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
frequency_mhz: float,
|
||||||
|
modulation: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
target_hz = frequency_mhz * 1e6
|
||||||
|
wanted_mod = str(modulation or '').strip().upper()
|
||||||
|
|
||||||
|
def score(match: dict[str, Any]) -> tuple[int, float, str]:
|
||||||
|
score_value = 0
|
||||||
|
freqs_mhz = match.get('frequencies_mhz') or []
|
||||||
|
distances_hz: list[float] = []
|
||||||
|
for f_mhz in freqs_mhz:
|
||||||
|
try:
|
||||||
|
distances_hz.append(abs((float(f_mhz) * 1e6) - target_hz))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
min_distance_hz = min(distances_hz) if distances_hz else 1e12
|
||||||
|
|
||||||
|
if min_distance_hz <= 100:
|
||||||
|
score_value += 120
|
||||||
|
elif min_distance_hz <= 1_000:
|
||||||
|
score_value += 90
|
||||||
|
elif min_distance_hz <= 10_000:
|
||||||
|
score_value += 70
|
||||||
|
elif min_distance_hz <= 100_000:
|
||||||
|
score_value += 40
|
||||||
|
|
||||||
|
if wanted_mod:
|
||||||
|
modes = [str(v).upper() for v in (match.get('modes') or [])]
|
||||||
|
modulations = [str(v).upper() for v in (match.get('modulations') or [])]
|
||||||
|
if wanted_mod in modes:
|
||||||
|
score_value += 25
|
||||||
|
if wanted_mod in modulations:
|
||||||
|
score_value += 25
|
||||||
|
|
||||||
|
title = str(match.get('title') or '')
|
||||||
|
title_lower = title.lower()
|
||||||
|
if 'unidentified' in title_lower or 'unknown' in title_lower:
|
||||||
|
score_value -= 10
|
||||||
|
|
||||||
|
return (score_value, min_distance_hz, title.lower())
|
||||||
|
|
||||||
|
ranked = sorted(matches, key=score, reverse=True)
|
||||||
|
for match in ranked:
|
||||||
|
try:
|
||||||
|
nearest = min(abs((float(f) * 1e6) - target_hz) for f in (match.get('frequencies_mhz') or []))
|
||||||
|
match['distance_hz'] = int(round(nearest))
|
||||||
|
except Exception:
|
||||||
|
match['distance_hz'] = None
|
||||||
|
return ranked
|
||||||
|
|
||||||
|
|
||||||
|
def _format_freq_variants_mhz(freq_mhz: float) -> list[str]:
|
||||||
|
variants = [
|
||||||
|
f'{freq_mhz:.6f}'.rstrip('0').rstrip('.'),
|
||||||
|
f'{freq_mhz:.4f}'.rstrip('0').rstrip('.'),
|
||||||
|
f'{freq_mhz:.3f}'.rstrip('0').rstrip('.'),
|
||||||
|
]
|
||||||
|
out: list[str] = []
|
||||||
|
for value in variants:
|
||||||
|
if value and value not in out:
|
||||||
|
out.append(value)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_sigidwiki_matches(frequency_mhz: float, modulation: str, limit: int) -> dict[str, Any]:
|
||||||
|
all_matches: list[dict[str, Any]] = []
|
||||||
|
exact_queries: list[str] = []
|
||||||
|
|
||||||
|
for freq_token in _format_freq_variants_mhz(frequency_mhz):
|
||||||
|
query = (
|
||||||
|
f'[[Category:Signal]][[Frequencies::{freq_token} MHz]]'
|
||||||
|
f'|?Frequencies|?Mode|?Modulation|limit={max(10, limit * 2)}'
|
||||||
|
)
|
||||||
|
exact_queries.append(query)
|
||||||
|
data = _ask_query(query)
|
||||||
|
if data:
|
||||||
|
all_matches.extend(_extract_matches_from_ask(data))
|
||||||
|
if all_matches:
|
||||||
|
break
|
||||||
|
|
||||||
|
search_used = False
|
||||||
|
if not all_matches:
|
||||||
|
search_used = True
|
||||||
|
search_terms = [f'{frequency_mhz:.4f} MHz']
|
||||||
|
if modulation:
|
||||||
|
search_terms.insert(0, f'{frequency_mhz:.4f} MHz {modulation.upper()}')
|
||||||
|
|
||||||
|
seen_titles: set[str] = set()
|
||||||
|
for term in search_terms:
|
||||||
|
search_data = _search_query(term, max(5, min(limit * 2, 10)))
|
||||||
|
search_results = search_data.get('query', {}).get('search', []) if isinstance(search_data, dict) else []
|
||||||
|
if not isinstance(search_results, list) or not search_results:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for item in search_results:
|
||||||
|
title = str(item.get('title') or '').strip()
|
||||||
|
if not title or title in seen_titles:
|
||||||
|
continue
|
||||||
|
seen_titles.add(title)
|
||||||
|
page_query = f'[[{title}]]|?Frequencies|?Mode|?Modulation|limit=1'
|
||||||
|
page_data = _ask_query(page_query)
|
||||||
|
if page_data:
|
||||||
|
all_matches.extend(_extract_matches_from_ask(page_data))
|
||||||
|
if len(all_matches) >= max(limit * 3, 12):
|
||||||
|
break
|
||||||
|
if all_matches:
|
||||||
|
break
|
||||||
|
|
||||||
|
deduped = _dedupe_matches(all_matches)
|
||||||
|
ranked = _rank_matches(deduped, frequency_mhz=frequency_mhz, modulation=modulation)
|
||||||
|
return {
|
||||||
|
'matches': ranked[:limit],
|
||||||
|
'search_used': search_used,
|
||||||
|
'exact_queries': exact_queries,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@signalid_bp.route('/sigidwiki', methods=['POST'])
|
||||||
|
def sigidwiki_lookup() -> Response:
|
||||||
|
"""Lookup likely signal types from SigID Wiki by tuned frequency."""
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
freq_raw = payload.get('frequency_mhz')
|
||||||
|
if freq_raw is None:
|
||||||
|
return jsonify({'status': 'error', 'message': 'frequency_mhz is required'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
frequency_mhz = float(freq_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid frequency_mhz'}), 400
|
||||||
|
|
||||||
|
if frequency_mhz <= 0:
|
||||||
|
return jsonify({'status': 'error', 'message': 'frequency_mhz must be positive'}), 400
|
||||||
|
|
||||||
|
modulation = str(payload.get('modulation') or '').strip().upper()
|
||||||
|
if modulation and len(modulation) > 16:
|
||||||
|
modulation = modulation[:16]
|
||||||
|
|
||||||
|
limit_raw = payload.get('limit', 8)
|
||||||
|
try:
|
||||||
|
limit = int(limit_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = 8
|
||||||
|
limit = max(1, min(limit, 20))
|
||||||
|
|
||||||
|
cache_key = f'{round(frequency_mhz, 6)}|{modulation}|{limit}'
|
||||||
|
cached = _cache_get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'source': 'sigidwiki',
|
||||||
|
'frequency_mhz': round(frequency_mhz, 6),
|
||||||
|
'modulation': modulation or None,
|
||||||
|
'cached': True,
|
||||||
|
**cached,
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
lookup = _lookup_sigidwiki_matches(frequency_mhz, modulation, limit)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('SigID lookup failed: %s', exc)
|
||||||
|
return jsonify({'status': 'error', 'message': 'SigID lookup failed'}), 502
|
||||||
|
|
||||||
|
response_payload = {
|
||||||
|
'matches': lookup.get('matches', []),
|
||||||
|
'match_count': len(lookup.get('matches', [])),
|
||||||
|
'search_used': bool(lookup.get('search_used')),
|
||||||
|
'exact_queries': lookup.get('exact_queries', []),
|
||||||
|
}
|
||||||
|
_cache_set(cache_key, response_payload)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'source': 'sigidwiki',
|
||||||
|
'frequency_mhz': round(frequency_mhz, 6),
|
||||||
|
'modulation': modulation or None,
|
||||||
|
'cached': False,
|
||||||
|
**response_payload,
|
||||||
|
})
|
||||||
|
|
||||||
+486
-51
@@ -6,7 +6,10 @@ import socket
|
|||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from contextlib import suppress
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -17,18 +20,37 @@ except ImportError:
|
|||||||
Sock = None
|
Sock = None
|
||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
from utils.process import register_process, safe_terminate, unregister_process
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.sdr.base import SDRCapabilities, SDRDevice
|
||||||
from utils.waterfall_fft import (
|
from utils.waterfall_fft import (
|
||||||
build_binary_frame,
|
build_binary_frame,
|
||||||
compute_power_spectrum,
|
compute_power_spectrum,
|
||||||
cu8_to_complex,
|
cu8_to_complex,
|
||||||
quantize_to_uint8,
|
quantize_to_uint8,
|
||||||
)
|
)
|
||||||
from utils.sdr import SDRFactory, SDRType
|
|
||||||
from utils.sdr.base import SDRCapabilities, SDRDevice
|
|
||||||
|
|
||||||
logger = get_logger('intercept.waterfall_ws')
|
logger = get_logger('intercept.waterfall_ws')
|
||||||
|
|
||||||
|
AUDIO_SAMPLE_RATE = 48000
|
||||||
|
_shared_state_lock = threading.Lock()
|
||||||
|
_shared_audio_queue: queue.Queue[bytes] = queue.Queue(maxsize=20)
|
||||||
|
_shared_state: dict[str, Any] = {
|
||||||
|
'running': False,
|
||||||
|
'device': None,
|
||||||
|
'center_mhz': 0.0,
|
||||||
|
'span_mhz': 0.0,
|
||||||
|
'sample_rate': 0,
|
||||||
|
'monitor_enabled': False,
|
||||||
|
'monitor_freq_mhz': 0.0,
|
||||||
|
'monitor_modulation': 'wfm',
|
||||||
|
'monitor_squelch': 0,
|
||||||
|
}
|
||||||
|
# Generation counter to prevent stale WebSocket handlers from clobbering
|
||||||
|
# shared state set by a newer handler (e.g. old handler's finally block
|
||||||
|
# running after a new connection has already started capture).
|
||||||
|
_capture_generation: int = 0
|
||||||
|
|
||||||
# Maximum bandwidth per SDR type (Hz)
|
# Maximum bandwidth per SDR type (Hz)
|
||||||
MAX_BANDWIDTH = {
|
MAX_BANDWIDTH = {
|
||||||
SDRType.RTL_SDR: 2400000,
|
SDRType.RTL_SDR: 2400000,
|
||||||
@@ -39,6 +61,261 @@ MAX_BANDWIDTH = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_shared_audio_queue() -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
_shared_audio_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def _set_shared_capture_state(
|
||||||
|
*,
|
||||||
|
running: bool,
|
||||||
|
device: int | None = None,
|
||||||
|
center_mhz: float | None = None,
|
||||||
|
span_mhz: float | None = None,
|
||||||
|
sample_rate: int | None = None,
|
||||||
|
generation: int | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Update shared capture state.
|
||||||
|
|
||||||
|
Returns the current generation counter. When *running* is True and
|
||||||
|
*generation* is None the counter is bumped; callers should capture
|
||||||
|
the returned value and pass it back when setting running=False so
|
||||||
|
that stale handlers cannot clobber a newer session.
|
||||||
|
"""
|
||||||
|
global _capture_generation
|
||||||
|
with _shared_state_lock:
|
||||||
|
if not running and generation is not None:
|
||||||
|
# Only allow the matching generation to clear the state.
|
||||||
|
if generation != _capture_generation:
|
||||||
|
return _capture_generation
|
||||||
|
if running and generation is None:
|
||||||
|
_capture_generation += 1
|
||||||
|
_shared_state['running'] = bool(running)
|
||||||
|
_shared_state['device'] = device if running else None
|
||||||
|
if center_mhz is not None:
|
||||||
|
_shared_state['center_mhz'] = float(center_mhz)
|
||||||
|
if span_mhz is not None:
|
||||||
|
_shared_state['span_mhz'] = float(span_mhz)
|
||||||
|
if sample_rate is not None:
|
||||||
|
_shared_state['sample_rate'] = int(sample_rate)
|
||||||
|
if not running:
|
||||||
|
_shared_state['monitor_enabled'] = False
|
||||||
|
gen = _capture_generation
|
||||||
|
if not running:
|
||||||
|
_clear_shared_audio_queue()
|
||||||
|
return gen
|
||||||
|
|
||||||
|
|
||||||
|
def _set_shared_monitor(
|
||||||
|
*,
|
||||||
|
enabled: bool,
|
||||||
|
frequency_mhz: float | None = None,
|
||||||
|
modulation: str | None = None,
|
||||||
|
squelch: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
was_enabled = False
|
||||||
|
freq_changed = False
|
||||||
|
with _shared_state_lock:
|
||||||
|
was_enabled = bool(_shared_state.get('monitor_enabled'))
|
||||||
|
_shared_state['monitor_enabled'] = bool(enabled)
|
||||||
|
if frequency_mhz is not None:
|
||||||
|
old_freq = float(_shared_state.get('monitor_freq_mhz', 0.0) or 0.0)
|
||||||
|
_shared_state['monitor_freq_mhz'] = float(frequency_mhz)
|
||||||
|
if abs(float(frequency_mhz) - old_freq) > 1e-6:
|
||||||
|
freq_changed = True
|
||||||
|
if modulation is not None:
|
||||||
|
_shared_state['monitor_modulation'] = str(modulation).lower().strip()
|
||||||
|
if squelch is not None:
|
||||||
|
_shared_state['monitor_squelch'] = max(0, min(100, int(squelch)))
|
||||||
|
if (was_enabled and not enabled) or (enabled and freq_changed):
|
||||||
|
_clear_shared_audio_queue()
|
||||||
|
|
||||||
|
|
||||||
|
def get_shared_capture_status() -> dict[str, Any]:
|
||||||
|
with _shared_state_lock:
|
||||||
|
return {
|
||||||
|
'running': bool(_shared_state['running']),
|
||||||
|
'device': _shared_state['device'],
|
||||||
|
'center_mhz': float(_shared_state.get('center_mhz', 0.0) or 0.0),
|
||||||
|
'span_mhz': float(_shared_state.get('span_mhz', 0.0) or 0.0),
|
||||||
|
'sample_rate': int(_shared_state.get('sample_rate', 0) or 0),
|
||||||
|
'monitor_enabled': bool(_shared_state.get('monitor_enabled')),
|
||||||
|
'monitor_freq_mhz': float(_shared_state.get('monitor_freq_mhz', 0.0) or 0.0),
|
||||||
|
'monitor_modulation': str(_shared_state.get('monitor_modulation', 'wfm')),
|
||||||
|
'monitor_squelch': int(_shared_state.get('monitor_squelch', 0) or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def start_shared_monitor_from_capture(
|
||||||
|
*,
|
||||||
|
device: int,
|
||||||
|
frequency_mhz: float,
|
||||||
|
modulation: str,
|
||||||
|
squelch: int,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
with _shared_state_lock:
|
||||||
|
if not _shared_state['running']:
|
||||||
|
return False, 'Waterfall IQ stream not active'
|
||||||
|
if _shared_state['device'] != device:
|
||||||
|
return False, 'Waterfall stream is using a different SDR device'
|
||||||
|
_shared_state['monitor_enabled'] = True
|
||||||
|
_shared_state['monitor_freq_mhz'] = float(frequency_mhz)
|
||||||
|
_shared_state['monitor_modulation'] = str(modulation).lower().strip()
|
||||||
|
_shared_state['monitor_squelch'] = max(0, min(100, int(squelch)))
|
||||||
|
_clear_shared_audio_queue()
|
||||||
|
return True, 'started'
|
||||||
|
|
||||||
|
|
||||||
|
def stop_shared_monitor_from_capture() -> None:
|
||||||
|
_set_shared_monitor(enabled=False)
|
||||||
|
|
||||||
|
|
||||||
|
def read_shared_monitor_audio_chunk(timeout: float = 1.0) -> bytes | None:
|
||||||
|
with _shared_state_lock:
|
||||||
|
if not _shared_state['running'] or not _shared_state['monitor_enabled']:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return _shared_audio_queue.get(timeout=max(0.0, float(timeout)))
|
||||||
|
except queue.Empty:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_monitor_config() -> dict[str, Any] | None:
|
||||||
|
with _shared_state_lock:
|
||||||
|
if not (_shared_state['running'] and _shared_state['monitor_enabled']):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
'center_mhz': float(_shared_state['center_mhz']),
|
||||||
|
'monitor_freq_mhz': float(_shared_state['monitor_freq_mhz']),
|
||||||
|
'modulation': str(_shared_state['monitor_modulation']),
|
||||||
|
'squelch': int(_shared_state['monitor_squelch']),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _push_shared_audio_chunk(chunk: bytes) -> None:
|
||||||
|
if not chunk:
|
||||||
|
return
|
||||||
|
if _shared_audio_queue.full():
|
||||||
|
with suppress(queue.Empty):
|
||||||
|
_shared_audio_queue.get_nowait()
|
||||||
|
with suppress(queue.Full):
|
||||||
|
_shared_audio_queue.put_nowait(chunk)
|
||||||
|
|
||||||
|
|
||||||
|
def _demodulate_monitor_audio(
|
||||||
|
samples: np.ndarray,
|
||||||
|
sample_rate: int,
|
||||||
|
center_mhz: float,
|
||||||
|
monitor_freq_mhz: float,
|
||||||
|
modulation: str,
|
||||||
|
squelch: int,
|
||||||
|
rotator_phase: float = 0.0,
|
||||||
|
) -> tuple[bytes | None, float]:
|
||||||
|
if samples.size < 32 or sample_rate <= 0:
|
||||||
|
return None, float(rotator_phase)
|
||||||
|
|
||||||
|
fs = float(sample_rate)
|
||||||
|
freq_offset_hz = (float(monitor_freq_mhz) - float(center_mhz)) * 1e6
|
||||||
|
nyquist = fs * 0.5
|
||||||
|
if abs(freq_offset_hz) > nyquist * 0.98:
|
||||||
|
return None, float(rotator_phase)
|
||||||
|
|
||||||
|
phase_inc = (2.0 * np.pi * freq_offset_hz) / fs
|
||||||
|
n = np.arange(samples.size, dtype=np.float64)
|
||||||
|
rotator = np.exp(-1j * (float(rotator_phase) + phase_inc * n)).astype(np.complex64)
|
||||||
|
next_phase = float((float(rotator_phase) + phase_inc * samples.size) % (2.0 * np.pi))
|
||||||
|
shifted = samples * rotator
|
||||||
|
|
||||||
|
mod = str(modulation or 'wfm').lower().strip()
|
||||||
|
target_bb = 220000.0 if mod == 'wfm' else 48000.0
|
||||||
|
pre_decim = max(1, int(fs // target_bb))
|
||||||
|
if pre_decim > 1:
|
||||||
|
usable = (shifted.size // pre_decim) * pre_decim
|
||||||
|
if usable < pre_decim:
|
||||||
|
return None, next_phase
|
||||||
|
shifted = shifted[:usable].reshape(-1, pre_decim).mean(axis=1)
|
||||||
|
fs1 = fs / pre_decim
|
||||||
|
if shifted.size < 16:
|
||||||
|
return None, next_phase
|
||||||
|
|
||||||
|
if mod in ('wfm', 'fm'):
|
||||||
|
audio = np.angle(shifted[1:] * np.conj(shifted[:-1])).astype(np.float32)
|
||||||
|
elif mod == 'am':
|
||||||
|
envelope = np.abs(shifted).astype(np.float32)
|
||||||
|
audio = envelope - float(np.mean(envelope))
|
||||||
|
elif mod == 'usb':
|
||||||
|
audio = np.real(shifted).astype(np.float32)
|
||||||
|
elif mod == 'lsb':
|
||||||
|
audio = -np.real(shifted).astype(np.float32)
|
||||||
|
else:
|
||||||
|
audio = np.real(shifted).astype(np.float32)
|
||||||
|
|
||||||
|
if audio.size < 8:
|
||||||
|
return None, next_phase
|
||||||
|
|
||||||
|
audio = audio - float(np.mean(audio))
|
||||||
|
|
||||||
|
if mod in ('fm', 'am', 'usb', 'lsb'):
|
||||||
|
taps = int(max(1, min(31, fs1 / 12000.0)))
|
||||||
|
if taps > 1:
|
||||||
|
kernel = np.ones(taps, dtype=np.float32) / float(taps)
|
||||||
|
audio = np.convolve(audio, kernel, mode='same')
|
||||||
|
|
||||||
|
out_len = int(audio.size * AUDIO_SAMPLE_RATE / fs1)
|
||||||
|
if out_len < 32:
|
||||||
|
return None, next_phase
|
||||||
|
x_old = np.linspace(0.0, 1.0, audio.size, endpoint=False, dtype=np.float32)
|
||||||
|
x_new = np.linspace(0.0, 1.0, out_len, endpoint=False, dtype=np.float32)
|
||||||
|
audio = np.interp(x_new, x_old, audio).astype(np.float32)
|
||||||
|
|
||||||
|
rms = float(np.sqrt(np.mean(audio * audio) + 1e-12))
|
||||||
|
level = min(100.0, rms * 450.0)
|
||||||
|
if squelch > 0 and level < float(squelch):
|
||||||
|
audio.fill(0.0)
|
||||||
|
|
||||||
|
peak = float(np.max(np.abs(audio))) if audio.size else 0.0
|
||||||
|
if peak > 0:
|
||||||
|
audio = audio * min(20.0, 0.85 / peak)
|
||||||
|
|
||||||
|
pcm = np.clip(audio, -1.0, 1.0)
|
||||||
|
return (pcm * 32767.0).astype(np.int16).tobytes(), next_phase
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_center_freq_mhz(payload: dict[str, Any]) -> float:
|
||||||
|
"""Parse center frequency from mixed legacy/new payload formats."""
|
||||||
|
if payload.get('center_freq_mhz') is not None:
|
||||||
|
return float(payload['center_freq_mhz'])
|
||||||
|
|
||||||
|
if payload.get('center_freq_hz') is not None:
|
||||||
|
return float(payload['center_freq_hz']) / 1e6
|
||||||
|
|
||||||
|
raw = float(payload.get('center_freq', 100.0))
|
||||||
|
# Backward compatibility: some clients still send center_freq in Hz.
|
||||||
|
if raw > 100000:
|
||||||
|
return raw / 1e6
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_span_mhz(payload: dict[str, Any]) -> float:
|
||||||
|
"""Parse display span in MHz from mixed payload formats."""
|
||||||
|
if payload.get('span_hz') is not None:
|
||||||
|
return float(payload['span_hz']) / 1e6
|
||||||
|
return float(payload.get('span_mhz', 2.0))
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_sample_rate(span_hz: int, caps: SDRCapabilities, sdr_type: SDRType) -> int:
|
||||||
|
"""Pick a valid hardware sample rate nearest the requested span."""
|
||||||
|
valid_rates = sorted({int(r) for r in caps.sample_rates if int(r) > 0})
|
||||||
|
if valid_rates:
|
||||||
|
return min(valid_rates, key=lambda rate: abs(rate - span_hz))
|
||||||
|
|
||||||
|
max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000)
|
||||||
|
return max(62500, min(span_hz, max_bw))
|
||||||
|
|
||||||
|
|
||||||
def _resolve_sdr_type(sdr_type_str: str) -> SDRType:
|
def _resolve_sdr_type(sdr_type_str: str) -> SDRType:
|
||||||
"""Convert client sdr_type string to SDRType enum."""
|
"""Convert client sdr_type string to SDRType enum."""
|
||||||
mapping = {
|
mapping = {
|
||||||
@@ -87,6 +364,11 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
reader_thread = None
|
reader_thread = None
|
||||||
stop_event = threading.Event()
|
stop_event = threading.Event()
|
||||||
claimed_device = None
|
claimed_device = None
|
||||||
|
my_generation = None # tracks which capture generation this handler owns
|
||||||
|
capture_center_mhz = 0.0
|
||||||
|
capture_start_freq = 0.0
|
||||||
|
capture_end_freq = 0.0
|
||||||
|
capture_span_mhz = 0.0
|
||||||
# Queue for outgoing messages — only the main loop touches ws.send()
|
# Queue for outgoing messages — only the main loop touches ws.send()
|
||||||
send_queue = queue.Queue(maxsize=120)
|
send_queue = queue.Queue(maxsize=120)
|
||||||
|
|
||||||
@@ -105,7 +387,7 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
break
|
break
|
||||||
|
|
||||||
try:
|
try:
|
||||||
msg = ws.receive(timeout=0.1)
|
msg = ws.receive(timeout=0.01)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
err = str(e).lower()
|
err = str(e).lower()
|
||||||
if "closed" in err:
|
if "closed" in err:
|
||||||
@@ -131,6 +413,10 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
cmd = data.get('cmd')
|
cmd = data.get('cmd')
|
||||||
|
|
||||||
if cmd == 'start':
|
if cmd == 'start':
|
||||||
|
shared_before = get_shared_capture_status()
|
||||||
|
keep_monitor_enabled = bool(shared_before.get('monitor_enabled'))
|
||||||
|
keep_monitor_modulation = str(shared_before.get('monitor_modulation', 'wfm'))
|
||||||
|
keep_monitor_squelch = int(shared_before.get('monitor_squelch', 0) or 0)
|
||||||
# Stop any existing capture
|
# Stop any existing capture
|
||||||
was_restarting = iq_process is not None
|
was_restarting = iq_process is not None
|
||||||
stop_event.set()
|
stop_event.set()
|
||||||
@@ -143,6 +429,8 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
if claimed_device is not None:
|
if claimed_device is not None:
|
||||||
app_module.release_sdr_device(claimed_device)
|
app_module.release_sdr_device(claimed_device)
|
||||||
claimed_device = None
|
claimed_device = None
|
||||||
|
_set_shared_capture_state(running=False, generation=my_generation)
|
||||||
|
my_generation = None
|
||||||
stop_event.clear()
|
stop_event.clear()
|
||||||
# Flush stale frames from previous capture
|
# Flush stale frames from previous capture
|
||||||
while not send_queue.empty():
|
while not send_queue.empty():
|
||||||
@@ -155,37 +443,78 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
# Parse config
|
# Parse config
|
||||||
center_freq = float(data.get('center_freq', 100.0))
|
try:
|
||||||
span_mhz = float(data.get('span_mhz', 2.0))
|
center_freq_mhz = _parse_center_freq_mhz(data)
|
||||||
gain = data.get('gain')
|
requested_vfo_mhz = float(
|
||||||
if gain is not None:
|
data.get(
|
||||||
gain = float(gain)
|
'vfo_freq_mhz',
|
||||||
device_index = int(data.get('device', 0))
|
data.get('frequency_mhz', center_freq_mhz),
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
)
|
||||||
fft_size = int(data.get('fft_size', 1024))
|
)
|
||||||
fps = int(data.get('fps', 25))
|
span_mhz = _parse_span_mhz(data)
|
||||||
avg_count = int(data.get('avg_count', 4))
|
gain_raw = data.get('gain')
|
||||||
ppm = data.get('ppm')
|
if gain_raw is None or str(gain_raw).lower() == 'auto':
|
||||||
if ppm is not None:
|
gain = None
|
||||||
ppm = int(ppm)
|
else:
|
||||||
bias_t = bool(data.get('bias_t', False))
|
gain = float(gain_raw)
|
||||||
|
device_index = int(data.get('device', 0))
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
fft_size = int(data.get('fft_size', 1024))
|
||||||
|
fps = int(data.get('fps', 25))
|
||||||
|
avg_count = int(data.get('avg_count', 4))
|
||||||
|
ppm = data.get('ppm')
|
||||||
|
if ppm is not None:
|
||||||
|
ppm = int(ppm)
|
||||||
|
bias_t = bool(data.get('bias_t', False))
|
||||||
|
db_min = data.get('db_min')
|
||||||
|
db_max = data.get('db_max')
|
||||||
|
if db_min is not None:
|
||||||
|
db_min = float(db_min)
|
||||||
|
if db_max is not None:
|
||||||
|
db_max = float(db_max)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid waterfall configuration: {exc}',
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
|
||||||
# Clamp FFT size to valid powers of 2
|
# Clamp and normalize runtime settings
|
||||||
fft_size = max(256, min(8192, fft_size))
|
fft_size = max(256, min(8192, fft_size))
|
||||||
|
fps = max(2, min(60, fps))
|
||||||
|
avg_count = max(1, min(32, avg_count))
|
||||||
|
if center_freq_mhz <= 0 or span_mhz <= 0:
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'center_freq_mhz and span_mhz must be > 0',
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
|
||||||
# Resolve SDR type and bandwidth
|
# Resolve SDR type and choose a valid sample rate
|
||||||
sdr_type = _resolve_sdr_type(sdr_type_str)
|
sdr_type = _resolve_sdr_type(sdr_type_str)
|
||||||
max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000)
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
span_hz = int(span_mhz * 1e6)
|
caps = builder.get_capabilities()
|
||||||
sample_rate = min(span_hz, max_bw)
|
requested_span_hz = max(1000, int(span_mhz * 1e6))
|
||||||
|
sample_rate = _pick_sample_rate(requested_span_hz, caps, sdr_type)
|
||||||
|
|
||||||
# Compute effective frequency range
|
# Compute effective frequency range
|
||||||
effective_span_mhz = sample_rate / 1e6
|
effective_span_mhz = sample_rate / 1e6
|
||||||
start_freq = center_freq - effective_span_mhz / 2
|
start_freq = center_freq_mhz - effective_span_mhz / 2
|
||||||
end_freq = center_freq + effective_span_mhz / 2
|
end_freq = center_freq_mhz + effective_span_mhz / 2
|
||||||
|
target_vfo_mhz = requested_vfo_mhz
|
||||||
|
if not (start_freq <= target_vfo_mhz <= end_freq):
|
||||||
|
target_vfo_mhz = center_freq_mhz
|
||||||
|
|
||||||
# Claim the device
|
# Claim the device (retry when restarting to allow
|
||||||
claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
|
# the kernel time to release the USB handle).
|
||||||
|
max_claim_attempts = 4 if was_restarting else 1
|
||||||
|
claim_err = None
|
||||||
|
for _claim_attempt in range(max_claim_attempts):
|
||||||
|
claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
|
||||||
|
if not claim_err:
|
||||||
|
break
|
||||||
|
if _claim_attempt < max_claim_attempts - 1:
|
||||||
|
time.sleep(0.4)
|
||||||
if claim_err:
|
if claim_err:
|
||||||
ws.send(json.dumps({
|
ws.send(json.dumps({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -197,11 +526,10 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
|
|
||||||
# Build I/Q capture command
|
# Build I/Q capture command
|
||||||
try:
|
try:
|
||||||
builder = SDRFactory.get_builder(sdr_type)
|
|
||||||
device = _build_dummy_device(device_index, sdr_type)
|
device = _build_dummy_device(device_index, sdr_type)
|
||||||
iq_cmd = builder.build_iq_capture_command(
|
iq_cmd = builder.build_iq_capture_command(
|
||||||
device=device,
|
device=device,
|
||||||
frequency_mhz=center_freq,
|
frequency_mhz=center_freq_mhz,
|
||||||
sample_rate=sample_rate,
|
sample_rate=sample_rate,
|
||||||
gain=gain,
|
gain=gain,
|
||||||
ppm=ppm,
|
ppm=ppm,
|
||||||
@@ -221,7 +549,7 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
try:
|
try:
|
||||||
for attempt in range(max_attempts):
|
for attempt in range(max_attempts):
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Starting I/Q capture: {center_freq} MHz, "
|
f"Starting I/Q capture: {center_freq_mhz:.6f} MHz, "
|
||||||
f"span={effective_span_mhz:.1f} MHz, "
|
f"span={effective_span_mhz:.1f} MHz, "
|
||||||
f"sr={sample_rate}, fft={fft_size}"
|
f"sr={sample_rate}, fft={fft_size}"
|
||||||
)
|
)
|
||||||
@@ -263,24 +591,53 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
}))
|
}))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
capture_center_mhz = center_freq_mhz
|
||||||
|
capture_start_freq = start_freq
|
||||||
|
capture_end_freq = end_freq
|
||||||
|
capture_span_mhz = effective_span_mhz
|
||||||
|
|
||||||
|
my_generation = _set_shared_capture_state(
|
||||||
|
running=True,
|
||||||
|
device=device_index,
|
||||||
|
center_mhz=center_freq_mhz,
|
||||||
|
span_mhz=effective_span_mhz,
|
||||||
|
sample_rate=sample_rate,
|
||||||
|
)
|
||||||
|
_set_shared_monitor(
|
||||||
|
enabled=keep_monitor_enabled,
|
||||||
|
frequency_mhz=target_vfo_mhz,
|
||||||
|
modulation=keep_monitor_modulation,
|
||||||
|
squelch=keep_monitor_squelch,
|
||||||
|
)
|
||||||
|
|
||||||
# Send started confirmation
|
# Send started confirmation
|
||||||
ws.send(json.dumps({
|
ws.send(json.dumps({
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
|
'center_mhz': center_freq_mhz,
|
||||||
'start_freq': start_freq,
|
'start_freq': start_freq,
|
||||||
'end_freq': end_freq,
|
'end_freq': end_freq,
|
||||||
'fft_size': fft_size,
|
'fft_size': fft_size,
|
||||||
'sample_rate': sample_rate,
|
'sample_rate': sample_rate,
|
||||||
|
'effective_span_mhz': effective_span_mhz,
|
||||||
|
'db_min': db_min,
|
||||||
|
'db_max': db_max,
|
||||||
|
'vfo_freq_mhz': target_vfo_mhz,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
# Start reader thread — puts frames on queue, never calls ws.send()
|
# Start reader thread — puts frames on queue, never calls ws.send()
|
||||||
def fft_reader(
|
def fft_reader(
|
||||||
proc, _send_q, stop_evt,
|
proc, _send_q, stop_evt,
|
||||||
_fft_size, _avg_count, _fps,
|
_fft_size, _avg_count, _fps, _sample_rate,
|
||||||
_start_freq, _end_freq,
|
_start_freq, _end_freq, _center_mhz,
|
||||||
|
_db_min=None, _db_max=None,
|
||||||
):
|
):
|
||||||
"""Read I/Q from subprocess, compute FFT, enqueue binary frames."""
|
"""Read I/Q from subprocess, compute FFT, enqueue binary frames."""
|
||||||
bytes_per_frame = _fft_size * _avg_count * 2
|
required_fft_samples = _fft_size * _avg_count
|
||||||
|
timeslice_samples = max(required_fft_samples, int(_sample_rate / max(1, _fps)))
|
||||||
|
bytes_per_frame = timeslice_samples * 2
|
||||||
frame_interval = 1.0 / _fps
|
frame_interval = 1.0 / _fps
|
||||||
|
monitor_rotator_phase = 0.0
|
||||||
|
last_monitor_offset_hz = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while not stop_evt.is_set():
|
while not stop_evt.is_set():
|
||||||
@@ -304,21 +661,51 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
|
|
||||||
# Process FFT pipeline
|
# Process FFT pipeline
|
||||||
samples = cu8_to_complex(raw)
|
samples = cu8_to_complex(raw)
|
||||||
|
fft_samples = samples[-required_fft_samples:] if len(samples) > required_fft_samples else samples
|
||||||
power_db = compute_power_spectrum(
|
power_db = compute_power_spectrum(
|
||||||
samples,
|
fft_samples,
|
||||||
fft_size=_fft_size,
|
fft_size=_fft_size,
|
||||||
avg_count=_avg_count,
|
avg_count=_avg_count,
|
||||||
)
|
)
|
||||||
quantized = quantize_to_uint8(power_db)
|
quantized = quantize_to_uint8(
|
||||||
|
power_db,
|
||||||
|
db_min=_db_min,
|
||||||
|
db_max=_db_max,
|
||||||
|
)
|
||||||
frame = build_binary_frame(
|
frame = build_binary_frame(
|
||||||
_start_freq, _end_freq, quantized,
|
_start_freq, _end_freq, quantized,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
# Drop frame if main loop cannot keep up.
|
||||||
|
with suppress(queue.Full):
|
||||||
_send_q.put_nowait(frame)
|
_send_q.put_nowait(frame)
|
||||||
except queue.Full:
|
|
||||||
# Drop frame if main loop can't keep up
|
monitor_cfg = _snapshot_monitor_config()
|
||||||
pass
|
if monitor_cfg:
|
||||||
|
center_mhz_cfg = float(monitor_cfg.get('center_mhz', _center_mhz))
|
||||||
|
monitor_mhz_cfg = float(monitor_cfg.get('monitor_freq_mhz', _center_mhz))
|
||||||
|
offset_hz = (monitor_mhz_cfg - center_mhz_cfg) * 1e6
|
||||||
|
if (
|
||||||
|
last_monitor_offset_hz is None
|
||||||
|
or abs(offset_hz - last_monitor_offset_hz) > 1.0
|
||||||
|
):
|
||||||
|
monitor_rotator_phase = 0.0
|
||||||
|
last_monitor_offset_hz = offset_hz
|
||||||
|
|
||||||
|
audio_chunk, monitor_rotator_phase = _demodulate_monitor_audio(
|
||||||
|
samples=samples,
|
||||||
|
sample_rate=_sample_rate,
|
||||||
|
center_mhz=center_mhz_cfg,
|
||||||
|
monitor_freq_mhz=monitor_mhz_cfg,
|
||||||
|
modulation=monitor_cfg.get('modulation', 'wfm'),
|
||||||
|
squelch=int(monitor_cfg.get('squelch', 0)),
|
||||||
|
rotator_phase=monitor_rotator_phase,
|
||||||
|
)
|
||||||
|
if audio_chunk:
|
||||||
|
_push_shared_audio_chunk(audio_chunk)
|
||||||
|
else:
|
||||||
|
monitor_rotator_phase = 0.0
|
||||||
|
last_monitor_offset_hz = None
|
||||||
|
|
||||||
# Pace to target FPS
|
# Pace to target FPS
|
||||||
elapsed = time.monotonic() - frame_start
|
elapsed = time.monotonic() - frame_start
|
||||||
@@ -333,13 +720,63 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
target=fft_reader,
|
target=fft_reader,
|
||||||
args=(
|
args=(
|
||||||
iq_process, send_queue, stop_event,
|
iq_process, send_queue, stop_event,
|
||||||
fft_size, avg_count, fps,
|
fft_size, avg_count, fps, sample_rate,
|
||||||
start_freq, end_freq,
|
start_freq, end_freq, center_freq_mhz,
|
||||||
|
db_min, db_max,
|
||||||
),
|
),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
reader_thread.start()
|
reader_thread.start()
|
||||||
|
|
||||||
|
elif cmd in ('tune', 'set_vfo'):
|
||||||
|
if not iq_process or claimed_device is None or iq_process.poll() is not None:
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Waterfall capture is not running',
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
shared = get_shared_capture_status()
|
||||||
|
vfo_freq_mhz = float(
|
||||||
|
data.get(
|
||||||
|
'vfo_freq_mhz',
|
||||||
|
data.get('frequency_mhz', data.get('center_freq_mhz', capture_center_mhz)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
squelch = int(data.get('squelch', shared.get('monitor_squelch', 0)))
|
||||||
|
modulation = str(data.get('modulation', shared.get('monitor_modulation', 'wfm')))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid tune request: {exc}',
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not (capture_start_freq <= vfo_freq_mhz <= capture_end_freq):
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'retune_required',
|
||||||
|
'message': 'Frequency outside current capture span',
|
||||||
|
'capture_start_freq': capture_start_freq,
|
||||||
|
'capture_end_freq': capture_end_freq,
|
||||||
|
'vfo_freq_mhz': vfo_freq_mhz,
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
|
||||||
|
monitor_enabled = bool(shared.get('monitor_enabled'))
|
||||||
|
_set_shared_monitor(
|
||||||
|
enabled=monitor_enabled,
|
||||||
|
frequency_mhz=vfo_freq_mhz,
|
||||||
|
modulation=modulation,
|
||||||
|
squelch=squelch,
|
||||||
|
)
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'tuned',
|
||||||
|
'vfo_freq_mhz': vfo_freq_mhz,
|
||||||
|
'start_freq': capture_start_freq,
|
||||||
|
'end_freq': capture_end_freq,
|
||||||
|
'center_mhz': capture_center_mhz,
|
||||||
|
}))
|
||||||
|
|
||||||
elif cmd == 'stop':
|
elif cmd == 'stop':
|
||||||
stop_event.set()
|
stop_event.set()
|
||||||
if reader_thread and reader_thread.is_alive():
|
if reader_thread and reader_thread.is_alive():
|
||||||
@@ -352,13 +789,16 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
if claimed_device is not None:
|
if claimed_device is not None:
|
||||||
app_module.release_sdr_device(claimed_device)
|
app_module.release_sdr_device(claimed_device)
|
||||||
claimed_device = None
|
claimed_device = None
|
||||||
|
_set_shared_capture_state(running=False, generation=my_generation)
|
||||||
|
my_generation = None
|
||||||
stop_event.clear()
|
stop_event.clear()
|
||||||
ws.send(json.dumps({'status': 'stopped'}))
|
ws.send(json.dumps({'status': 'stopped'}))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.info(f"WebSocket waterfall closed: {e}")
|
logger.info(f"WebSocket waterfall closed: {e}")
|
||||||
finally:
|
finally:
|
||||||
# Cleanup
|
# Cleanup — use generation guard so a stale handler cannot
|
||||||
|
# clobber shared state owned by a newer WS connection.
|
||||||
stop_event.set()
|
stop_event.set()
|
||||||
if reader_thread and reader_thread.is_alive():
|
if reader_thread and reader_thread.is_alive():
|
||||||
reader_thread.join(timeout=2)
|
reader_thread.join(timeout=2)
|
||||||
@@ -367,20 +807,15 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
unregister_process(iq_process)
|
unregister_process(iq_process)
|
||||||
if claimed_device is not None:
|
if claimed_device is not None:
|
||||||
app_module.release_sdr_device(claimed_device)
|
app_module.release_sdr_device(claimed_device)
|
||||||
|
_set_shared_capture_state(running=False, generation=my_generation)
|
||||||
# Complete WebSocket close handshake, then shut down the
|
# Complete WebSocket close handshake, then shut down the
|
||||||
# raw socket so Werkzeug cannot write its HTTP 200 response
|
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||||
# on top of the WebSocket stream (which browsers see as
|
# on top of the WebSocket stream (which browsers see as
|
||||||
# "Invalid frame header").
|
# "Invalid frame header").
|
||||||
try:
|
with suppress(Exception):
|
||||||
ws.close()
|
ws.close()
|
||||||
except Exception:
|
with suppress(Exception):
|
||||||
pass
|
|
||||||
try:
|
|
||||||
ws.sock.shutdown(socket.SHUT_RDWR)
|
ws.sock.shutdown(socket.SHUT_RDWR)
|
||||||
except Exception:
|
with suppress(Exception):
|
||||||
pass
|
|
||||||
try:
|
|
||||||
ws.sock.close()
|
ws.sock.close()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
logger.info("WebSocket waterfall client disconnected")
|
logger.info("WebSocket waterfall client disconnected")
|
||||||
|
|||||||
@@ -893,6 +893,92 @@ body {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-crosshair-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1200;
|
||||||
|
--crosshair-x-start: 100%;
|
||||||
|
--crosshair-y-start: 100%;
|
||||||
|
--crosshair-x-end: 50%;
|
||||||
|
--crosshair-y-end: 50%;
|
||||||
|
--crosshair-duration: 1500ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-crosshair-line {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
box-shadow: none;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-crosshair-vertical {
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
left: 0;
|
||||||
|
transform: translateX(var(--crosshair-x-start));
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-crosshair-horizontal {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
top: 0;
|
||||||
|
transform: translateY(var(--crosshair-y-start));
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-crosshair-overlay.active .map-crosshair-vertical {
|
||||||
|
animation: mapCrosshairSweepX var(--crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-crosshair-overlay.active .map-crosshair-horizontal {
|
||||||
|
animation: mapCrosshairSweepY var(--crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mapCrosshairSweepX {
|
||||||
|
0% {
|
||||||
|
transform: translateX(var(--crosshair-x-start));
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
12% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
85% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(var(--crosshair-x-end));
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mapCrosshairSweepY {
|
||||||
|
0% {
|
||||||
|
transform: translateY(var(--crosshair-y-start));
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
12% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
85% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(var(--crosshair-y-end));
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.map-crosshair-overlay.active .map-crosshair-vertical,
|
||||||
|
.map-crosshair-overlay.active .map-crosshair-horizontal {
|
||||||
|
animation-duration: 220ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Right sidebar - Mobile first */
|
/* Right sidebar - Mobile first */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1802,6 +1802,14 @@ header h1 .tagline {
|
|||||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes stop-btn-pulse {
|
||||||
|
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(239,68,68,0); }
|
||||||
|
50% { opacity: 0.75; box-shadow: 0 0 8px 2px rgba(239,68,68,0.45); }
|
||||||
|
}
|
||||||
|
.stop-btn {
|
||||||
|
animation: stop-btn-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.output-panel {
|
.output-panel {
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,500 +0,0 @@
|
|||||||
/* Analytics Dashboard Styles */
|
|
||||||
|
|
||||||
/* Analytics is a sidebar-only mode — hide the output panel and expand the sidebar */
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.main-content.analytics-active {
|
|
||||||
grid-template-columns: 1fr !important;
|
|
||||||
}
|
|
||||||
.main-content.analytics-active > .output-panel {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
.main-content.analytics-active > .sidebar {
|
|
||||||
max-width: 100% !important;
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
.main-content.analytics-active .sidebar-collapse-btn {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1023px) {
|
|
||||||
.main-content.analytics-active > .output-panel {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
|
||||||
gap: var(--space-3, 12px);
|
|
||||||
margin-bottom: var(--space-4, 16px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
|
||||||
gap: var(--space-3, 12px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card {
|
|
||||||
background: var(--bg-card, #151f2b);
|
|
||||||
border: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
border-radius: var(--radius-md, 8px);
|
|
||||||
padding: 10px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card.low {
|
|
||||||
border-color: rgba(90, 106, 122, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card.medium {
|
|
||||||
border-color: rgba(74, 163, 255, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card.high {
|
|
||||||
border-color: rgba(214, 168, 94, 0.55);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card.critical {
|
|
||||||
border-color: rgba(226, 93, 93, 0.65);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card .insight-title {
|
|
||||||
font-size: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card .insight-value {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card .insight-label {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-secondary, #9aabba);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card .insight-detail {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-top-changes {
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-change-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 7px 0;
|
|
||||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-change-row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-change-row .mode {
|
|
||||||
min-width: 84px;
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-change-row .delta {
|
|
||||||
min-width: 48px;
|
|
||||||
font-family: var(--font-mono, monospace);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-change-row .delta.up {
|
|
||||||
color: var(--accent-green, #38c180);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-change-row .delta.down {
|
|
||||||
color: var(--accent-red, #e25d5d);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-change-row .delta.flat {
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-change-row .avg {
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-card {
|
|
||||||
background: var(--bg-card, #151f2b);
|
|
||||||
border: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
border-radius: var(--radius-md, 8px);
|
|
||||||
padding: var(--space-3, 12px);
|
|
||||||
text-align: center;
|
|
||||||
transition: var(--transition-fast, 150ms ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-card:hover {
|
|
||||||
border-color: var(--accent-cyan, #4aa3ff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-card .card-count {
|
|
||||||
font-size: var(--text-2xl, 24px);
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-card .card-label {
|
|
||||||
font-size: var(--text-xs, 10px);
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin-top: var(--space-1, 4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-card .card-sparkline {
|
|
||||||
height: 24px;
|
|
||||||
margin-top: var(--space-2, 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-card .card-sparkline svg {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-card .card-sparkline polyline {
|
|
||||||
fill: none;
|
|
||||||
stroke: var(--accent-cyan, #4aa3ff);
|
|
||||||
stroke-width: 1.5;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Health indicators */
|
|
||||||
.analytics-health {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-2, 8px);
|
|
||||||
margin-bottom: var(--space-4, 16px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.health-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-1, 4px);
|
|
||||||
font-size: var(--text-xs, 10px);
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.health-dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--accent-red, #e25d5d);
|
|
||||||
}
|
|
||||||
|
|
||||||
.health-dot.running {
|
|
||||||
background: var(--accent-green, #38c180);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Emergency squawk panel */
|
|
||||||
.squawk-emergency {
|
|
||||||
background: rgba(226, 93, 93, 0.1);
|
|
||||||
border: 1px solid var(--accent-red, #e25d5d);
|
|
||||||
border-radius: var(--radius-md, 8px);
|
|
||||||
padding: var(--space-3, 12px);
|
|
||||||
margin-bottom: var(--space-3, 12px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.squawk-emergency .squawk-title {
|
|
||||||
color: var(--accent-red, #e25d5d);
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: var(--text-sm, 12px);
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-bottom: var(--space-2, 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.squawk-emergency .squawk-item {
|
|
||||||
font-size: var(--text-sm, 12px);
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
padding: var(--space-1, 4px) 0;
|
|
||||||
border-bottom: 1px solid rgba(226, 93, 93, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.squawk-emergency .squawk-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Alert feed */
|
|
||||||
.analytics-alert-feed {
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
margin-bottom: var(--space-4, 16px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-alert-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--space-2, 8px);
|
|
||||||
padding: var(--space-2, 8px);
|
|
||||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
font-size: var(--text-xs, 10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-alert-item .alert-severity {
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: var(--radius-sm, 4px);
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 9px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-severity.critical { background: var(--accent-red, #e25d5d); color: #fff; }
|
|
||||||
.alert-severity.high { background: var(--accent-orange, #d6a85e); color: #000; }
|
|
||||||
.alert-severity.medium { background: var(--accent-cyan, #4aa3ff); color: #fff; }
|
|
||||||
.alert-severity.low { background: var(--border-color, #1e2d3d); color: var(--text-dim, #5a6a7a); }
|
|
||||||
|
|
||||||
/* Correlation panel */
|
|
||||||
.analytics-correlation-pair {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2, 8px);
|
|
||||||
padding: var(--space-2, 8px);
|
|
||||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
font-size: var(--text-xs, 10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-correlation-pair .confidence-bar {
|
|
||||||
height: 4px;
|
|
||||||
background: var(--bg-secondary, #101823);
|
|
||||||
border-radius: 2px;
|
|
||||||
flex: 1;
|
|
||||||
max-width: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-correlation-pair .confidence-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: var(--accent-green, #38c180);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-pattern-item {
|
|
||||||
padding: 8px;
|
|
||||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-pattern-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-pattern-item .pattern-main {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-pattern-item .pattern-mode {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-pattern-item .pattern-device {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
font-family: var(--font-mono, monospace);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-pattern-item .pattern-meta {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-pattern-item .pattern-confidence {
|
|
||||||
color: var(--accent-green, #38c180);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Geofence zone list */
|
|
||||||
.geofence-zone-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: var(--space-2, 8px);
|
|
||||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
font-size: var(--text-xs, 10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.geofence-zone-item .zone-name {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
}
|
|
||||||
|
|
||||||
.geofence-zone-item .zone-radius {
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.geofence-zone-item .zone-delete {
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--accent-red, #e25d5d);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border: 1px solid var(--accent-red, #e25d5d);
|
|
||||||
border-radius: var(--radius-sm, 4px);
|
|
||||||
background: transparent;
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Export controls */
|
|
||||||
.export-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-2, 8px);
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-controls select,
|
|
||||||
.export-controls button {
|
|
||||||
font-size: var(--text-xs, 10px);
|
|
||||||
padding: var(--space-1, 4px) var(--space-2, 8px);
|
|
||||||
background: var(--bg-card, #151f2b);
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
border: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
border-radius: var(--radius-sm, 4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-controls button {
|
|
||||||
cursor: pointer;
|
|
||||||
background: var(--accent-cyan, #4aa3ff);
|
|
||||||
color: #fff;
|
|
||||||
border-color: var(--accent-cyan, #4aa3ff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-controls button:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section headers */
|
|
||||||
.analytics-section-header {
|
|
||||||
font-size: var(--text-xs, 10px);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin-bottom: var(--space-2, 8px);
|
|
||||||
padding-bottom: var(--space-1, 4px);
|
|
||||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Empty state */
|
|
||||||
.analytics-empty {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
font-size: var(--text-xs, 10px);
|
|
||||||
padding: var(--space-4, 16px);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-toolbar,
|
|
||||||
.analytics-replay-toolbar {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-toolbar input {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 220px;
|
|
||||||
background: var(--bg-card, #151f2b);
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
border: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-toolbar button,
|
|
||||||
.analytics-replay-toolbar button,
|
|
||||||
.analytics-replay-toolbar select {
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 5px 9px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
background: var(--bg-card, #151f2b);
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-toolbar button,
|
|
||||||
.analytics-replay-toolbar button {
|
|
||||||
cursor: pointer;
|
|
||||||
background: rgba(74, 163, 255, 0.2);
|
|
||||||
border-color: rgba(74, 163, 255, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-summary {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-item,
|
|
||||||
.analytics-replay-item {
|
|
||||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
padding: 7px 0;
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-item:last-child,
|
|
||||||
.analytics-replay-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-item .title,
|
|
||||||
.analytics-replay-item .title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-item .mode,
|
|
||||||
.analytics-replay-item .mode {
|
|
||||||
font-size: 9px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
border: 1px solid rgba(74, 163, 255, 0.35);
|
|
||||||
color: var(--accent-cyan, #4aa3ff);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-item .meta,
|
|
||||||
.analytics-replay-item .meta {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
@@ -266,7 +266,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
height: 100%;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,8 +282,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#btLocateMap {
|
#btLocateMap {
|
||||||
width: 100%;
|
position: absolute;
|
||||||
height: 100%;
|
inset: 0;
|
||||||
background: #1a1a2e;
|
background: #1a1a2e;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,3 +560,69 @@
|
|||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Crosshair sweep animation ───────────────────────────────────── */
|
||||||
|
.btl-crosshair-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1200;
|
||||||
|
--btl-crosshair-x-start: 100%;
|
||||||
|
--btl-crosshair-y-start: 100%;
|
||||||
|
--btl-crosshair-x-end: 50%;
|
||||||
|
--btl-crosshair-y-end: 50%;
|
||||||
|
--btl-crosshair-duration: 1500ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-crosshair-line {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-crosshair-vertical {
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
left: 0;
|
||||||
|
transform: translateX(var(--btl-crosshair-x-start));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-crosshair-horizontal {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
top: 0;
|
||||||
|
transform: translateY(var(--btl-crosshair-y-start));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-crosshair-overlay.active .btl-crosshair-vertical {
|
||||||
|
animation: btlCrosshairSweepX var(--btl-crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-crosshair-overlay.active .btl-crosshair-horizontal {
|
||||||
|
animation: btlCrosshairSweepY var(--btl-crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes btlCrosshairSweepX {
|
||||||
|
0% { transform: translateX(var(--btl-crosshair-x-start)); opacity: 0; }
|
||||||
|
12% { opacity: 1; }
|
||||||
|
85% { opacity: 1; }
|
||||||
|
100% { transform: translateX(var(--btl-crosshair-x-end)); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes btlCrosshairSweepY {
|
||||||
|
0% { transform: translateY(var(--btl-crosshair-y-start)); opacity: 0; }
|
||||||
|
12% { opacity: 1; }
|
||||||
|
85% { opacity: 1; }
|
||||||
|
100% { transform: translateY(var(--btl-crosshair-y-end)); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.btl-crosshair-overlay.active .btl-crosshair-vertical,
|
||||||
|
.btl-crosshair-overlay.active .btl-crosshair-horizontal {
|
||||||
|
animation-duration: 220ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -140,14 +140,65 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.gps-skyview-canvas-wrap {
|
.gps-skyview-canvas-wrap {
|
||||||
display: flex;
|
position: relative;
|
||||||
justify-content: center;
|
display: block;
|
||||||
align-items: center;
|
width: min(100%, 430px);
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
margin: 0 auto;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#gpsSkyCanvas {
|
#gpsSkyCanvas {
|
||||||
max-width: 100%;
|
display: block;
|
||||||
height: auto;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#gpsSkyCanvas:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-sky-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-sky-label {
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
text-shadow: 0 0 6px rgba(0, 0, 0, 0.9);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-sky-label-cardinal {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-sky-label-sat {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-sky-label-sat.unused {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-sky-hint {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Position info panel */
|
/* Position info panel */
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 919 B |
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<!-- Background -->
|
||||||
|
<rect width="100" height="100" fill="#0a0a0f"/>
|
||||||
|
|
||||||
|
<!-- Signal brackets - left side -->
|
||||||
|
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||||
|
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||||
|
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Signal brackets - right side -->
|
||||||
|
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||||
|
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||||
|
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- The 'i' letter -->
|
||||||
|
<circle cx="50" cy="22" r="7" fill="#00ff88"/>
|
||||||
|
<rect x="43" y="35" width="14" height="45" rx="2" fill="#00d4ff"/>
|
||||||
|
<rect x="36" y="35" width="28" height="5" rx="1" fill="#00d4ff"/>
|
||||||
|
<rect x="36" y="75" width="28" height="5" rx="1" fill="#00d4ff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Activity Timeline Component
|
* Activity Timeline Component
|
||||||
* Reusable, configuration-driven timeline visualization for time-based metadata
|
* Reusable, configuration-driven timeline visualization for time-based metadata
|
||||||
* Supports multiple modes: TSCM, Listening Post, Bluetooth, WiFi, Monitoring
|
* Supports multiple modes: TSCM, RF Receiver, Bluetooth, WiFi, Monitoring
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ActivityTimeline = (function() {
|
const ActivityTimeline = (function() {
|
||||||
@@ -176,7 +176,7 @@ const ActivityTimeline = (function() {
|
|||||||
*/
|
*/
|
||||||
function categorizeById(id, mode) {
|
function categorizeById(id, mode) {
|
||||||
// RF frequency categorization
|
// RF frequency categorization
|
||||||
if (mode === 'rf' || mode === 'tscm' || mode === 'listening-post') {
|
if (mode === 'rf' || mode === 'tscm' || mode === 'waterfall') {
|
||||||
const f = parseFloat(id);
|
const f = parseFloat(id);
|
||||||
if (!isNaN(f)) {
|
if (!isNaN(f)) {
|
||||||
if (f >= 2400 && f <= 2500) return '2.4 GHz wireless band';
|
if (f >= 2400 && f <= 2500) return '2.4 GHz wireless band';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* RF Signal Timeline Adapter
|
* RF Signal Timeline Adapter
|
||||||
* Normalizes RF signal data for the Activity Timeline component
|
* Normalizes RF signal data for the Activity Timeline component
|
||||||
* Used by: Listening Post, TSCM
|
* Used by: Spectrum Waterfall, TSCM
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const RFTimelineAdapter = (function() {
|
const RFTimelineAdapter = (function() {
|
||||||
@@ -158,12 +158,12 @@ const RFTimelineAdapter = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create timeline configuration for Listening Post mode
|
* Create timeline configuration for spectrum waterfall mode.
|
||||||
*/
|
*/
|
||||||
function getListeningPostConfig() {
|
function getWaterfallConfig() {
|
||||||
return {
|
return {
|
||||||
title: 'Signal Activity',
|
title: 'Spectrum Activity',
|
||||||
mode: 'listening-post',
|
mode: 'waterfall',
|
||||||
visualMode: 'enriched',
|
visualMode: 'enriched',
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
showAnnotations: true,
|
showAnnotations: true,
|
||||||
@@ -188,6 +188,11 @@ const RFTimelineAdapter = (function() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backward compatibility alias for legacy callers.
|
||||||
|
function getListeningPostConfig() {
|
||||||
|
return getWaterfallConfig();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create timeline configuration for TSCM mode
|
* Create timeline configuration for TSCM mode
|
||||||
*/
|
*/
|
||||||
@@ -224,6 +229,7 @@ const RFTimelineAdapter = (function() {
|
|||||||
categorizeFrequency: categorizeFrequency,
|
categorizeFrequency: categorizeFrequency,
|
||||||
|
|
||||||
// Configuration presets
|
// Configuration presets
|
||||||
|
getWaterfallConfig: getWaterfallConfig,
|
||||||
getListeningPostConfig: getListeningPostConfig,
|
getListeningPostConfig: getListeningPostConfig,
|
||||||
getTscmConfig: getTscmConfig,
|
getTscmConfig: getTscmConfig,
|
||||||
|
|
||||||
|
|||||||
+4
-13
@@ -98,7 +98,7 @@ function switchMode(mode) {
|
|||||||
const modeMap = {
|
const modeMap = {
|
||||||
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
|
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
|
||||||
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
|
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
|
||||||
'listening': 'listening', 'meshtastic': 'meshtastic'
|
'meshtastic': 'meshtastic'
|
||||||
};
|
};
|
||||||
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
||||||
const label = btn.querySelector('.nav-label');
|
const label = btn.querySelector('.nav-label');
|
||||||
@@ -114,7 +114,6 @@ function switchMode(mode) {
|
|||||||
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
|
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
|
||||||
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
|
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
|
||||||
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
|
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
|
||||||
document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
|
|
||||||
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
||||||
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
||||||
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
|
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
|
||||||
@@ -143,7 +142,6 @@ function switchMode(mode) {
|
|||||||
'satellite': 'SATELLITE',
|
'satellite': 'SATELLITE',
|
||||||
'wifi': 'WIFI',
|
'wifi': 'WIFI',
|
||||||
'bluetooth': 'BLUETOOTH',
|
'bluetooth': 'BLUETOOTH',
|
||||||
'listening': 'LISTENING POST',
|
|
||||||
'tscm': 'TSCM',
|
'tscm': 'TSCM',
|
||||||
'aprs': 'APRS',
|
'aprs': 'APRS',
|
||||||
'meshtastic': 'MESHTASTIC'
|
'meshtastic': 'MESHTASTIC'
|
||||||
@@ -166,7 +164,6 @@ function switchMode(mode) {
|
|||||||
const showRadar = document.getElementById('adsbEnableMap')?.checked;
|
const showRadar = document.getElementById('adsbEnableMap')?.checked;
|
||||||
document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
|
document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
|
||||||
document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
|
document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
|
||||||
document.getElementById('listeningPostVisuals').style.display = mode === 'listening' ? 'grid' : 'none';
|
|
||||||
|
|
||||||
// Update output panel title based on mode
|
// Update output panel title based on mode
|
||||||
const titles = {
|
const titles = {
|
||||||
@@ -176,7 +173,6 @@ function switchMode(mode) {
|
|||||||
'satellite': 'Satellite Monitor',
|
'satellite': 'Satellite Monitor',
|
||||||
'wifi': 'WiFi Scanner',
|
'wifi': 'WiFi Scanner',
|
||||||
'bluetooth': 'Bluetooth Scanner',
|
'bluetooth': 'Bluetooth Scanner',
|
||||||
'listening': 'Listening Post',
|
|
||||||
'meshtastic': 'Meshtastic Mesh Monitor'
|
'meshtastic': 'Meshtastic Mesh Monitor'
|
||||||
};
|
};
|
||||||
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
|
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
|
||||||
@@ -184,7 +180,7 @@ function switchMode(mode) {
|
|||||||
// Show/hide Device Intelligence for modes that use it
|
// Show/hide Device Intelligence for modes that use it
|
||||||
const reconBtn = document.getElementById('reconBtn');
|
const reconBtn = document.getElementById('reconBtn');
|
||||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||||
if (mode === 'satellite' || mode === 'aircraft' || mode === 'listening') {
|
if (mode === 'satellite' || mode === 'aircraft') {
|
||||||
document.getElementById('reconPanel').style.display = 'none';
|
document.getElementById('reconPanel').style.display = 'none';
|
||||||
if (reconBtn) reconBtn.style.display = 'none';
|
if (reconBtn) reconBtn.style.display = 'none';
|
||||||
if (intelBtn) intelBtn.style.display = 'none';
|
if (intelBtn) intelBtn.style.display = 'none';
|
||||||
@@ -198,7 +194,7 @@ function switchMode(mode) {
|
|||||||
|
|
||||||
// Show RTL-SDR device section for modes that use it
|
// Show RTL-SDR device section for modes that use it
|
||||||
document.getElementById('rtlDeviceSection').style.display =
|
document.getElementById('rtlDeviceSection').style.display =
|
||||||
(mode === 'pager' || mode === 'sensor' || mode === 'aircraft' || mode === 'listening') ? 'block' : 'none';
|
(mode === 'pager' || mode === 'sensor' || mode === 'aircraft') ? 'block' : 'none';
|
||||||
|
|
||||||
// Toggle mode-specific tool status displays
|
// Toggle mode-specific tool status displays
|
||||||
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
|
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
|
||||||
@@ -207,7 +203,7 @@ function switchMode(mode) {
|
|||||||
|
|
||||||
// Hide waterfall and output console for modes with their own visualizations
|
// Hide waterfall and output console for modes with their own visualizations
|
||||||
document.querySelector('.waterfall-container').style.display =
|
document.querySelector('.waterfall-container').style.display =
|
||||||
(mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||||
document.getElementById('output').style.display =
|
document.getElementById('output').style.display =
|
||||||
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||||
document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex';
|
document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex';
|
||||||
@@ -226,11 +222,6 @@ function switchMode(mode) {
|
|||||||
} else if (mode === 'satellite') {
|
} else if (mode === 'satellite') {
|
||||||
if (typeof initPolarPlot === 'function') initPolarPlot();
|
if (typeof initPolarPlot === 'function') initPolarPlot();
|
||||||
if (typeof initSatelliteList === 'function') initSatelliteList();
|
if (typeof initSatelliteList === 'function') initSatelliteList();
|
||||||
} else if (mode === 'listening') {
|
|
||||||
if (typeof checkScannerTools === 'function') checkScannerTools();
|
|
||||||
if (typeof checkAudioTools === 'function') checkAudioTools();
|
|
||||||
if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect();
|
|
||||||
if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect();
|
|
||||||
} else if (mode === 'meshtastic') {
|
} else if (mode === 'meshtastic') {
|
||||||
if (typeof Meshtastic !== 'undefined' && Meshtastic.init) Meshtastic.init();
|
if (typeof Meshtastic !== 'undefined' && Meshtastic.init) Meshtastic.init();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/* INTERCEPT Per-Mode Cheat Sheets */
|
||||||
|
const CheatSheets = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const CONTENT = {
|
||||||
|
pager: { title: 'Pager Decoder', icon: '📟', hardware: 'RTL-SDR dongle', description: 'Decodes POCSAG and FLEX pager protocols via rtl_fm + multimon-ng.', whatToExpect: 'Numeric and alphanumeric pager messages with address codes.', tips: ['Try frequencies 152.240, 157.450, 462.9625 MHz', 'Gain 38–45 dB works well for most dongles', 'POCSAG 512/1200/2400 baud are common'] },
|
||||||
|
sensor: { title: '433MHz Sensors', icon: '🌡️', hardware: 'RTL-SDR dongle', description: 'Decodes 433MHz IoT sensors via rtl_433.', whatToExpect: 'JSON events from weather stations, door sensors, car key fobs.', tips: ['Leave gain on AUTO', 'Walk around to discover hidden sensors', 'Protocol filter narrows false positives'] },
|
||||||
|
wifi: { title: 'WiFi Scanner', icon: '📡', hardware: 'WiFi adapter (monitor mode)', description: 'Scans WiFi networks and clients via airodump-ng or nmcli.', whatToExpect: 'SSIDs, BSSIDs, channel, signal strength, encryption type.', tips: ['Run airmon-ng check kill before monitoring', 'Proximity radar shows signal strength', 'TSCM baseline detects rogue APs'] },
|
||||||
|
bluetooth: { title: 'Bluetooth Scanner', icon: '🔵', hardware: 'Built-in or USB Bluetooth adapter', description: 'Scans BLE and classic Bluetooth devices. Identifies trackers.', whatToExpect: 'Device names, MACs, RSSI, manufacturer, tracker type.', tips: ['Proximity radar shows device distance', 'Known tracker DB has 47K+ fingerprints', 'Use BT Locate to physically find a tracker'] },
|
||||||
|
bt_locate: { title: 'BT Locate (SAR)', icon: '🎯', hardware: 'Bluetooth adapter + optional GPS', description: 'SAR Bluetooth locator. Tracks RSSI over time to triangulate position.', whatToExpect: 'RSSI chart, proximity band (IMMEDIATE/NEAR/FAR), GPS trail.', tips: ['Handoff from Bluetooth mode to lock onto a device', 'Indoor n=3.0 gives better distance estimates', 'Follow the heat trail toward stronger signal'] },
|
||||||
|
meshtastic: { title: 'Meshtastic', icon: '🕸️', hardware: 'Meshtastic LoRa node (USB)', description: 'Monitors Meshtastic LoRa mesh network messages and positions.', whatToExpect: 'Text messages, node map, telemetry.', tips: ['Default channel must match your mesh', 'Long-Fast has best range', 'GPS nodes appear on map automatically'] },
|
||||||
|
adsb: { title: 'ADS-B Aircraft', icon: '✈️', hardware: 'RTL-SDR + 1090MHz antenna', description: 'Tracks aircraft via ADS-B Mode S transponders using dump1090.', whatToExpect: 'Flight numbers, positions, altitude, speed, squawk codes.', tips: ['1090MHz — use a dedicated antenna', 'Emergency squawks: 7500 hijack, 7600 radio fail, 7700 emergency', 'Full Dashboard shows map view'] },
|
||||||
|
ais: { title: 'AIS Vessels', icon: '🚢', hardware: 'RTL-SDR + VHF antenna (162 MHz)', description: 'Tracks marine vessels via AIS using AIS-catcher.', whatToExpect: 'MMSI, vessel names, positions, speed, heading, cargo type.', tips: ['VHF antenna centered at 162MHz works best', 'DSC distress alerts appear in red', 'Coastline range ~40 nautical miles'] },
|
||||||
|
aprs: { title: 'APRS', icon: '📻', hardware: 'RTL-SDR + VHF + direwolf', description: 'Decodes APRS amateur packet radio via direwolf TNC modem.', whatToExpect: 'Station positions, weather reports, messages, telemetry.', tips: ['Primary APRS frequency: 144.390 MHz (North America)', 'direwolf must be running', 'Positions appear on the map'] },
|
||||||
|
satellite: { title: 'Satellite Tracker', icon: '🛰️', hardware: 'None (pass prediction only)', description: 'Predicts satellite pass times using TLE data from CelesTrak.', whatToExpect: 'Pass windows with AOS/LOS times, max elevation, bearing.', tips: ['Set observer location in Settings', 'Plan ISS SSTV using pass times', 'TLEs auto-update every 24 hours'] },
|
||||||
|
sstv: { title: 'ISS SSTV', icon: '🖼️', hardware: 'RTL-SDR + 145MHz antenna', description: 'Receives ISS SSTV images via slowrx.', whatToExpect: 'Color images during ISS SSTV events (PD180 mode).', tips: ['ISS SSTV: 145.800 MHz', 'Check ARISS for active event dates', 'ISS must be overhead — check pass times'] },
|
||||||
|
weathersat: { title: 'Weather Satellites', icon: '🌤️', hardware: 'RTL-SDR + 137MHz turnstile/QFH antenna', description: 'Decodes NOAA APT and Meteor LRPT weather imagery via SatDump.', whatToExpect: 'Infrared/visible cloud imagery.', tips: ['NOAA 15/18/19: 137.1–137.9 MHz APT', 'Meteor M2-3: 137.9 MHz LRPT', 'Use circular polarized antenna (QFH or turnstile)'] },
|
||||||
|
sstv_general:{ title: 'HF SSTV', icon: '📷', hardware: 'RTL-SDR + HF upconverter', description: 'Receives HF SSTV transmissions.', whatToExpect: 'Amateur radio images on 14.230 MHz (USB mode).', tips: ['14.230 MHz USB is primary HF SSTV frequency', 'Scottie 1 and Martin 1 most common', 'Best during daylight hours'] },
|
||||||
|
gps: { title: 'GPS Receiver', icon: '🗺️', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction', 'Verify a 3D fix before relying on altitude'] },
|
||||||
|
spaceweather:{ title: 'Space Weather', icon: '☀️', hardware: 'None (NOAA/SpaceWeatherLive data)', description: 'Monitors solar activity and geomagnetic storm indices.', whatToExpect: 'Kp index, solar flux, X-ray flare alerts, CME tracking.', tips: ['High Kp (≥5) = geomagnetic storm', 'X-class flares cause HF radio blackouts', 'Check before HF or satellite operations'] },
|
||||||
|
tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] },
|
||||||
|
spystations: { title: 'Spy Stations', icon: '🕵️', hardware: 'RTL-SDR + HF antenna', description: 'Database of known number stations, military, and diplomatic HF signals.', whatToExpect: 'Scheduled broadcasts, frequency database, tune-to links.', tips: ['Numbers stations often broadcast on the hour', 'Use Spectrum Waterfall to tune directly', 'STANAG and HF mil signals are common'] },
|
||||||
|
websdr: { title: 'WebSDR', icon: '🌐', hardware: 'None (uses remote SDR servers)', description: 'Access remote WebSDR receivers worldwide for HF shortwave listening.', whatToExpect: 'Live audio from global HF receivers, waterfall display.', tips: ['websdr.org lists available servers', 'Good for HF when local antenna is lacking', 'Use in-app player for seamless experience'] },
|
||||||
|
subghz: { title: 'SubGHz Transceiver', icon: '📡', hardware: 'HackRF One', description: 'Transmit and receive sub-GHz RF signals for IoT and industrial protocols.', whatToExpect: 'Raw signal capture, replay, and protocol analysis.', tips: ['Only use on licensed frequencies', 'Capture mode records raw IQ for replay', 'Common: garage doors, keyfobs, 315/433/868/915 MHz'] },
|
||||||
|
rtlamr: { title: 'Utility Meter Reader', icon: '⚡', hardware: 'RTL-SDR dongle', description: 'Reads AMI/AMR smart utility meter broadcasts via rtlamr.', whatToExpect: 'Meter IDs, consumption readings, interval data.', tips: ['Most meters broadcast on 915 MHz', 'MSG types 5, 7, 13, 21 most common', 'Consumption data is read-only public broadcast'] },
|
||||||
|
waterfall: { title: 'Spectrum Waterfall', icon: '🌊', hardware: 'RTL-SDR or HackRF (WebSocket)', description: 'Full-screen real-time FFT spectrum waterfall display.', whatToExpect: 'Color-coded signal intensity scrolling over time.', tips: ['Turbo palette has best contrast for weak signals', 'Peak hold shows max power in red', 'Hover over waterfall to see frequency'] },
|
||||||
|
};
|
||||||
|
|
||||||
|
function show(mode) {
|
||||||
|
const data = CONTENT[mode];
|
||||||
|
const modal = document.getElementById('cheatSheetModal');
|
||||||
|
const content = document.getElementById('cheatSheetContent');
|
||||||
|
if (!modal || !content) return;
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
content.innerHTML = `<p style="color:var(--text-dim); font-family:var(--font-mono);">No cheat sheet for: ${mode}</p>`;
|
||||||
|
} else {
|
||||||
|
content.innerHTML = `
|
||||||
|
<div style="font-family:var(--font-mono, monospace);">
|
||||||
|
<div style="font-size:24px; margin-bottom:4px;">${data.icon}</div>
|
||||||
|
<h2 style="margin:0 0 8px; font-size:16px; color:var(--accent-cyan, #4aa3ff);">${data.title}</h2>
|
||||||
|
<div style="font-size:11px; color:var(--text-dim); margin-bottom:12px; border-bottom:1px solid rgba(255,255,255,0.08); padding-bottom:8px;">
|
||||||
|
Hardware: <span style="color:var(--text-secondary);">${data.hardware}</span>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:12px; color:var(--text-secondary); margin:0 0 12px;">${data.description}</p>
|
||||||
|
<div style="margin-bottom:12px;">
|
||||||
|
<div style="font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.08em; color:var(--text-dim); margin-bottom:4px;">What to expect</div>
|
||||||
|
<p style="font-size:12px; color:var(--text-secondary); margin:0;">${data.whatToExpect}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.08em; color:var(--text-dim); margin-bottom:6px;">Tips</div>
|
||||||
|
<ul style="margin:0; padding-left:16px; display:flex; flex-direction:column; gap:4px;">
|
||||||
|
${data.tips.map(t => `<li style="font-size:11px; color:var(--text-secondary);">${t}</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
const modal = document.getElementById('cheatSheetModal');
|
||||||
|
if (modal) modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showForCurrentMode() {
|
||||||
|
const mode = document.body.getAttribute('data-mode');
|
||||||
|
if (mode) show(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { show, hide, showForCurrentMode };
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.CheatSheets = CheatSheets;
|
||||||
@@ -12,8 +12,8 @@ const CommandPalette = (function() {
|
|||||||
{ mode: 'pager', label: 'Pager' },
|
{ mode: 'pager', label: 'Pager' },
|
||||||
{ mode: 'sensor', label: '433MHz Sensors' },
|
{ mode: 'sensor', label: '433MHz Sensors' },
|
||||||
{ mode: 'rtlamr', label: 'Meters' },
|
{ mode: 'rtlamr', label: 'Meters' },
|
||||||
{ mode: 'listening', label: 'Listening Post' },
|
|
||||||
{ mode: 'subghz', label: 'SubGHz' },
|
{ mode: 'subghz', label: 'SubGHz' },
|
||||||
|
{ mode: 'waterfall', label: 'Spectrum Waterfall' },
|
||||||
{ mode: 'aprs', label: 'APRS' },
|
{ mode: 'aprs', label: 'APRS' },
|
||||||
{ mode: 'wifi', label: 'WiFi Scanner' },
|
{ mode: 'wifi', label: 'WiFi Scanner' },
|
||||||
{ mode: 'bluetooth', label: 'Bluetooth Scanner' },
|
{ mode: 'bluetooth', label: 'Bluetooth Scanner' },
|
||||||
@@ -25,7 +25,6 @@ const CommandPalette = (function() {
|
|||||||
{ mode: 'gps', label: 'GPS' },
|
{ mode: 'gps', label: 'GPS' },
|
||||||
{ mode: 'meshtastic', label: 'Meshtastic' },
|
{ mode: 'meshtastic', label: 'Meshtastic' },
|
||||||
{ mode: 'websdr', label: 'WebSDR' },
|
{ mode: 'websdr', label: 'WebSDR' },
|
||||||
{ mode: 'analytics', label: 'Analytics' },
|
|
||||||
{ mode: 'spaceweather', label: 'Space Weather' },
|
{ mode: 'spaceweather', label: 'Space Weather' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -188,13 +187,39 @@ const CommandPalette = (function() {
|
|||||||
title: 'View Aircraft Dashboard',
|
title: 'View Aircraft Dashboard',
|
||||||
description: 'Open dedicated ADS-B dashboard page',
|
description: 'Open dedicated ADS-B dashboard page',
|
||||||
keyword: 'aircraft adsb dashboard',
|
keyword: 'aircraft adsb dashboard',
|
||||||
run: () => { window.location.href = '/adsb/dashboard'; }
|
run: () => {
|
||||||
|
if (window.InterceptNavPerf && typeof window.InterceptNavPerf.markStart === 'function') {
|
||||||
|
window.InterceptNavPerf.markStart({
|
||||||
|
targetPath: '/adsb/dashboard',
|
||||||
|
trigger: 'command-palette',
|
||||||
|
sourceMode: (typeof currentMode === 'string' && currentMode) ? currentMode : null,
|
||||||
|
activeScans: (typeof getActiveScanSummary === 'function') ? getActiveScanSummary() : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof stopActiveLocalScansForNavigation === 'function') {
|
||||||
|
stopActiveLocalScansForNavigation();
|
||||||
|
}
|
||||||
|
window.location.href = '/adsb/dashboard';
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'View Vessel Dashboard',
|
title: 'View Vessel Dashboard',
|
||||||
description: 'Open dedicated AIS dashboard page',
|
description: 'Open dedicated AIS dashboard page',
|
||||||
keyword: 'vessel ais dashboard',
|
keyword: 'vessel ais dashboard',
|
||||||
run: () => { window.location.href = '/ais/dashboard'; }
|
run: () => {
|
||||||
|
if (window.InterceptNavPerf && typeof window.InterceptNavPerf.markStart === 'function') {
|
||||||
|
window.InterceptNavPerf.markStart({
|
||||||
|
targetPath: '/ais/dashboard',
|
||||||
|
trigger: 'command-palette',
|
||||||
|
sourceMode: (typeof currentMode === 'string' && currentMode) ? currentMode : null,
|
||||||
|
activeScans: (typeof getActiveScanSummary === 'function') ? getActiveScanSummary() : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof stopActiveLocalScansForNavigation === 'function') {
|
||||||
|
stopActiveLocalScansForNavigation();
|
||||||
|
}
|
||||||
|
window.location.href = '/ais/dashboard';
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Kill All Running Processes',
|
title: 'Kill All Running Processes',
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ const FirstRunSetup = (function() {
|
|||||||
['pager', 'Pager'],
|
['pager', 'Pager'],
|
||||||
['sensor', '433MHz'],
|
['sensor', '433MHz'],
|
||||||
['rtlamr', 'Meters'],
|
['rtlamr', 'Meters'],
|
||||||
['listening', 'Listening Post'],
|
['waterfall', 'Waterfall'],
|
||||||
['wifi', 'WiFi'],
|
['wifi', 'WiFi'],
|
||||||
['bluetooth', 'Bluetooth'],
|
['bluetooth', 'Bluetooth'],
|
||||||
['bt_locate', 'BT Locate'],
|
['bt_locate', 'BT Locate'],
|
||||||
@@ -139,7 +139,6 @@ const FirstRunSetup = (function() {
|
|||||||
['sstv', 'ISS SSTV'],
|
['sstv', 'ISS SSTV'],
|
||||||
['weathersat', 'Weather Sat'],
|
['weathersat', 'Weather Sat'],
|
||||||
['sstv_general', 'HF SSTV'],
|
['sstv_general', 'HF SSTV'],
|
||||||
['analytics', 'Analytics'],
|
|
||||||
];
|
];
|
||||||
for (const [value, label] of modes) {
|
for (const [value, label] of modes) {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
@@ -150,7 +149,11 @@ const FirstRunSetup = (function() {
|
|||||||
|
|
||||||
const savedDefaultMode = localStorage.getItem(DEFAULT_MODE_KEY);
|
const savedDefaultMode = localStorage.getItem(DEFAULT_MODE_KEY);
|
||||||
if (savedDefaultMode) {
|
if (savedDefaultMode) {
|
||||||
modeSelectEl.value = savedDefaultMode;
|
const normalizedMode = savedDefaultMode === 'listening' ? 'waterfall' : savedDefaultMode;
|
||||||
|
modeSelectEl.value = normalizedMode;
|
||||||
|
if (normalizedMode !== savedDefaultMode) {
|
||||||
|
localStorage.setItem(DEFAULT_MODE_KEY, normalizedMode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actionsEl.appendChild(modeSelectEl);
|
actionsEl.appendChild(modeSelectEl);
|
||||||
|
|||||||
@@ -18,6 +18,18 @@
|
|||||||
if (menuLink) {
|
if (menuLink) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
try {
|
||||||
|
const target = new URL(menuLink.href, window.location.href);
|
||||||
|
if (window.InterceptNavPerf && typeof window.InterceptNavPerf.markStart === 'function') {
|
||||||
|
window.InterceptNavPerf.markStart({
|
||||||
|
targetPath: target.pathname,
|
||||||
|
trigger: 'global-nav',
|
||||||
|
sourceMode: document.body?.getAttribute('data-mode') || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore malformed link targets.
|
||||||
|
}
|
||||||
window.location.href = menuLink.href;
|
window.location.href = menuLink.href;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/* INTERCEPT Keyboard Shortcuts — global hotkey handler + help modal */
|
||||||
|
const KeyboardShortcuts = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const GUARD_SELECTOR = 'input, textarea, select, [contenteditable], .CodeMirror *';
|
||||||
|
let _handler = null;
|
||||||
|
|
||||||
|
function _handle(e) {
|
||||||
|
if (e.target.matches(GUARD_SELECTOR)) return;
|
||||||
|
|
||||||
|
if (e.altKey) {
|
||||||
|
switch (e.code) {
|
||||||
|
case 'KeyW': e.preventDefault(); window.switchMode && switchMode('waterfall'); break;
|
||||||
|
case 'KeyM': e.preventDefault(); window.VoiceAlerts && VoiceAlerts.toggleMute(); break;
|
||||||
|
case 'KeyS': e.preventDefault(); _toggleSidebar(); break;
|
||||||
|
case 'KeyK': e.preventDefault(); showHelp(); break;
|
||||||
|
case 'KeyC': e.preventDefault(); window.CheatSheets && CheatSheets.showForCurrentMode(); break;
|
||||||
|
default:
|
||||||
|
if (e.code >= 'Digit1' && e.code <= 'Digit9') {
|
||||||
|
e.preventDefault();
|
||||||
|
_switchToNthMode(parseInt(e.code.replace('Digit', '')) - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!e.ctrlKey && !e.metaKey) {
|
||||||
|
if (e.key === '?') { showHelp(); }
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
const kbModal = document.getElementById('kbShortcutsModal');
|
||||||
|
if (kbModal && kbModal.style.display !== 'none') { hideHelp(); return; }
|
||||||
|
const csModal = document.getElementById('cheatSheetModal');
|
||||||
|
if (csModal && csModal.style.display !== 'none') {
|
||||||
|
window.CheatSheets && CheatSheets.hide(); return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _toggleSidebar() {
|
||||||
|
const mc = document.querySelector('.main-content');
|
||||||
|
if (mc) mc.classList.toggle('sidebar-collapsed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _switchToNthMode(n) {
|
||||||
|
if (!window.interceptModeCatalog) return;
|
||||||
|
const mode = document.body.getAttribute('data-mode');
|
||||||
|
if (!mode) return;
|
||||||
|
const catalog = window.interceptModeCatalog;
|
||||||
|
const entry = catalog[mode];
|
||||||
|
if (!entry) return;
|
||||||
|
const groupModes = Object.keys(catalog).filter(k => catalog[k].group === entry.group);
|
||||||
|
if (groupModes[n]) window.switchMode && switchMode(groupModes[n]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showHelp() {
|
||||||
|
const modal = document.getElementById('kbShortcutsModal');
|
||||||
|
if (modal) modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideHelp() {
|
||||||
|
const modal = document.getElementById('kbShortcutsModal');
|
||||||
|
if (modal) modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (_handler) document.removeEventListener('keydown', _handler);
|
||||||
|
_handler = _handle;
|
||||||
|
document.addEventListener('keydown', _handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init, showHelp, hideHelp };
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.KeyboardShortcuts = KeyboardShortcuts;
|
||||||
@@ -114,13 +114,7 @@ const RecordingUI = (function() {
|
|||||||
|
|
||||||
function openReplay(sessionId) {
|
function openReplay(sessionId) {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
localStorage.setItem('analyticsReplaySession', sessionId);
|
window.open(`/recordings/${sessionId}/download`, '_blank');
|
||||||
if (typeof hideSettings === 'function') hideSettings();
|
|
||||||
if (typeof switchMode === 'function') {
|
|
||||||
switchMode('analytics', { updateUrl: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.location.href = '/?mode=analytics';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
|
|||||||
@@ -98,24 +98,15 @@ const Settings = {
|
|||||||
localStorage.setItem('intercept_map_theme_pref', pref);
|
localStorage.setItem('intercept_map_theme_pref', pref);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether Cyber map theme should be considered active globally.
|
|
||||||
* @param {Object} [config]
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
_isCyberThemeEnabled(config) {
|
|
||||||
const resolvedConfig = config || this.getTileConfig();
|
|
||||||
return this._getMapThemeClass(resolvedConfig) === 'map-theme-cyber';
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle root class used for hard global Leaflet theming.
|
* Toggle root class used for hard global Leaflet theming.
|
||||||
* @param {Object} [config]
|
* @param {Object} [config]
|
||||||
*/
|
*/
|
||||||
_syncRootMapThemeClass(config) {
|
_syncRootMapThemeClass(config) {
|
||||||
if (typeof document === 'undefined' || !document.documentElement) return;
|
if (typeof document === 'undefined' || !document.documentElement) return;
|
||||||
const enabled = this._isCyberThemeEnabled(config);
|
const resolvedConfig = config || this.getTileConfig();
|
||||||
document.documentElement.classList.toggle('map-cyber-enabled', enabled);
|
const themeClass = this._getMapThemeClass(resolvedConfig);
|
||||||
|
document.documentElement.classList.toggle('map-cyber-enabled', themeClass === 'map-theme-cyber');
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -381,17 +372,19 @@ const Settings = {
|
|||||||
|
|
||||||
container.classList.add(themeClass);
|
container.classList.add(themeClass);
|
||||||
|
|
||||||
if (container.style) {
|
if (themeClass === 'map-theme-cyber') {
|
||||||
container.style.background = '#020813';
|
if (container.style) {
|
||||||
}
|
container.style.background = '#020813';
|
||||||
if (tilePane && tilePane.style) {
|
}
|
||||||
tilePane.style.filter = 'sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08)';
|
if (tilePane && tilePane.style) {
|
||||||
tilePane.style.opacity = '1';
|
tilePane.style.filter = 'sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08)';
|
||||||
tilePane.style.willChange = 'filter';
|
tilePane.style.opacity = '1';
|
||||||
|
tilePane.style.willChange = 'filter';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grid/glow overlays are rendered via CSS pseudo elements on
|
// Map overlays are rendered via CSS pseudo elements on
|
||||||
// `html.map-cyber-enabled .leaflet-container` for consistent stacking.
|
// `html.map-*-enabled .leaflet-container` for consistent stacking.
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1265,6 +1258,7 @@ function switchSettingsTab(tabName) {
|
|||||||
} else if (tabName === 'location') {
|
} else if (tabName === 'location') {
|
||||||
loadObserverLocation();
|
loadObserverLocation();
|
||||||
} else if (tabName === 'alerts') {
|
} else if (tabName === 'alerts') {
|
||||||
|
loadVoiceAlertConfig();
|
||||||
if (typeof AlertCenter !== 'undefined') {
|
if (typeof AlertCenter !== 'undefined') {
|
||||||
AlertCenter.loadFeed();
|
AlertCenter.loadFeed();
|
||||||
}
|
}
|
||||||
@@ -1277,6 +1271,61 @@ function switchSettingsTab(tabName) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load voice alert configuration into Settings > Alerts tab
|
||||||
|
*/
|
||||||
|
function loadVoiceAlertConfig() {
|
||||||
|
if (typeof VoiceAlerts === 'undefined') return;
|
||||||
|
const cfg = VoiceAlerts.getConfig();
|
||||||
|
|
||||||
|
const pager = document.getElementById('voiceCfgPager');
|
||||||
|
const tscm = document.getElementById('voiceCfgTscm');
|
||||||
|
const tracker = document.getElementById('voiceCfgTracker');
|
||||||
|
const squawk = document.getElementById('voiceCfgSquawk');
|
||||||
|
const rate = document.getElementById('voiceCfgRate');
|
||||||
|
const pitch = document.getElementById('voiceCfgPitch');
|
||||||
|
const rateVal = document.getElementById('voiceCfgRateVal');
|
||||||
|
const pitchVal = document.getElementById('voiceCfgPitchVal');
|
||||||
|
|
||||||
|
if (pager) pager.checked = cfg.streams.pager !== false;
|
||||||
|
if (tscm) tscm.checked = cfg.streams.tscm !== false;
|
||||||
|
if (tracker) tracker.checked = cfg.streams.bluetooth !== false;
|
||||||
|
if (squawk) squawk.checked = cfg.streams.squawks !== false;
|
||||||
|
if (rate) rate.value = cfg.rate;
|
||||||
|
if (pitch) pitch.value = cfg.pitch;
|
||||||
|
if (rateVal) rateVal.textContent = cfg.rate;
|
||||||
|
if (pitchVal) pitchVal.textContent = cfg.pitch;
|
||||||
|
|
||||||
|
// Populate voice dropdown
|
||||||
|
VoiceAlerts.getAvailableVoices().then(function (voices) {
|
||||||
|
var sel = document.getElementById('voiceCfgVoice');
|
||||||
|
if (!sel) return;
|
||||||
|
sel.innerHTML = '<option value="">Default</option>' +
|
||||||
|
voices.filter(function (v) { return v.lang.startsWith('en'); }).map(function (v) {
|
||||||
|
return '<option value="' + v.name + '"' + (v.name === cfg.voiceName ? ' selected' : '') + '>' + v.name + '</option>';
|
||||||
|
}).join('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveVoiceAlertConfig() {
|
||||||
|
if (typeof VoiceAlerts === 'undefined') return;
|
||||||
|
VoiceAlerts.setConfig({
|
||||||
|
rate: parseFloat(document.getElementById('voiceCfgRate')?.value) || 1.1,
|
||||||
|
pitch: parseFloat(document.getElementById('voiceCfgPitch')?.value) || 0.9,
|
||||||
|
voiceName: document.getElementById('voiceCfgVoice')?.value || '',
|
||||||
|
streams: {
|
||||||
|
pager: !!document.getElementById('voiceCfgPager')?.checked,
|
||||||
|
tscm: !!document.getElementById('voiceCfgTscm')?.checked,
|
||||||
|
bluetooth: !!document.getElementById('voiceCfgTracker')?.checked,
|
||||||
|
squawks: !!document.getElementById('voiceCfgSquawk')?.checked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function testVoiceAlert() {
|
||||||
|
if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.testVoice();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load API key status into the API Keys settings tab
|
* Load API key status into the API Keys settings tab
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
/* INTERCEPT Voice Alerts — Web Speech API queue with priority system */
|
||||||
|
const VoiceAlerts = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const PRIORITY = { LOW: 0, MEDIUM: 1, HIGH: 2 };
|
||||||
|
let _enabled = true;
|
||||||
|
let _muted = false;
|
||||||
|
let _queue = [];
|
||||||
|
let _speaking = false;
|
||||||
|
let _sources = {};
|
||||||
|
const STORAGE_KEY = 'intercept-voice-muted';
|
||||||
|
const CONFIG_KEY = 'intercept-voice-config';
|
||||||
|
const RATE_MIN = 0.5;
|
||||||
|
const RATE_MAX = 2.0;
|
||||||
|
const PITCH_MIN = 0.5;
|
||||||
|
const PITCH_MAX = 2.0;
|
||||||
|
|
||||||
|
// Default config
|
||||||
|
let _config = {
|
||||||
|
rate: 1.1,
|
||||||
|
pitch: 0.9,
|
||||||
|
voiceName: '',
|
||||||
|
streams: { pager: true, tscm: true, bluetooth: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
function _toNumberInRange(value, fallback, min, max) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _normalizeConfig() {
|
||||||
|
_config.rate = _toNumberInRange(_config.rate, 1.1, RATE_MIN, RATE_MAX);
|
||||||
|
_config.pitch = _toNumberInRange(_config.pitch, 0.9, PITCH_MIN, PITCH_MAX);
|
||||||
|
_config.voiceName = typeof _config.voiceName === 'string' ? _config.voiceName : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isSpeechSupported() {
|
||||||
|
return !!(window.speechSynthesis && typeof window.SpeechSynthesisUtterance !== 'undefined');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showVoiceToast(title, message, type) {
|
||||||
|
if (typeof window.showAppToast === 'function') {
|
||||||
|
window.showAppToast(title, message, type || 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _loadConfig() {
|
||||||
|
_muted = localStorage.getItem(STORAGE_KEY) === 'true';
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(CONFIG_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
_config.rate = parsed.rate ?? _config.rate;
|
||||||
|
_config.pitch = parsed.pitch ?? _config.pitch;
|
||||||
|
_config.voiceName = parsed.voiceName ?? _config.voiceName;
|
||||||
|
if (parsed.streams) {
|
||||||
|
Object.assign(_config.streams, parsed.streams);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
_normalizeConfig();
|
||||||
|
_updateMuteButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateMuteButton() {
|
||||||
|
const btn = document.getElementById('voiceMuteBtn');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.classList.toggle('voice-muted', _muted);
|
||||||
|
btn.title = _muted ? 'Unmute voice alerts' : 'Mute voice alerts';
|
||||||
|
btn.style.opacity = _muted ? '0.4' : '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getVoice() {
|
||||||
|
if (!_config.voiceName) return null;
|
||||||
|
const voices = window.speechSynthesis ? speechSynthesis.getVoices() : [];
|
||||||
|
return voices.find(v => v.name === _config.voiceName) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _createUtterance(text) {
|
||||||
|
const utt = new SpeechSynthesisUtterance(text);
|
||||||
|
utt.rate = _toNumberInRange(_config.rate, 1.1, RATE_MIN, RATE_MAX);
|
||||||
|
utt.pitch = _toNumberInRange(_config.pitch, 0.9, PITCH_MIN, PITCH_MAX);
|
||||||
|
const voice = _getVoice();
|
||||||
|
if (voice) utt.voice = voice;
|
||||||
|
return utt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function speak(text, priority) {
|
||||||
|
if (priority === undefined) priority = PRIORITY.MEDIUM;
|
||||||
|
if (!_enabled || _muted) return;
|
||||||
|
if (!window.speechSynthesis) return;
|
||||||
|
if (priority === PRIORITY.LOW && _speaking) return;
|
||||||
|
if (priority === PRIORITY.HIGH && _speaking) {
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
_queue = [];
|
||||||
|
_speaking = false;
|
||||||
|
}
|
||||||
|
_queue.push({ text, priority });
|
||||||
|
if (!_speaking) _dequeue();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _dequeue() {
|
||||||
|
if (_queue.length === 0) { _speaking = false; return; }
|
||||||
|
_speaking = true;
|
||||||
|
const item = _queue.shift();
|
||||||
|
const utt = _createUtterance(item.text);
|
||||||
|
utt.onend = () => { _speaking = false; _dequeue(); };
|
||||||
|
utt.onerror = () => { _speaking = false; _dequeue(); };
|
||||||
|
window.speechSynthesis.speak(utt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMute() {
|
||||||
|
_muted = !_muted;
|
||||||
|
localStorage.setItem(STORAGE_KEY, _muted ? 'true' : 'false');
|
||||||
|
_updateMuteButton();
|
||||||
|
if (_muted && window.speechSynthesis) window.speechSynthesis.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _openStream(url, handler, key) {
|
||||||
|
if (_sources[key]) return;
|
||||||
|
const es = new EventSource(url);
|
||||||
|
es.onmessage = handler;
|
||||||
|
es.onerror = () => { es.close(); delete _sources[key]; };
|
||||||
|
_sources[key] = es;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _startStreams() {
|
||||||
|
if (!_enabled) return;
|
||||||
|
|
||||||
|
// Pager stream
|
||||||
|
if (_config.streams.pager) {
|
||||||
|
_openStream('/stream', (ev) => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(ev.data);
|
||||||
|
if (d.address && d.message) {
|
||||||
|
speak(`Pager message to ${d.address}: ${String(d.message).slice(0, 60)}`, PRIORITY.MEDIUM);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}, 'pager');
|
||||||
|
}
|
||||||
|
|
||||||
|
// TSCM stream
|
||||||
|
if (_config.streams.tscm) {
|
||||||
|
_openStream('/tscm/sweep/stream', (ev) => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(ev.data);
|
||||||
|
if (d.threat_level && d.description) {
|
||||||
|
speak(`TSCM alert: ${d.threat_level} — ${d.description}`, PRIORITY.HIGH);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}, 'tscm');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bluetooth stream — tracker detection only
|
||||||
|
if (_config.streams.bluetooth) {
|
||||||
|
_openStream('/api/bluetooth/stream', (ev) => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(ev.data);
|
||||||
|
if (d.service_data && d.service_data.tracker_type) {
|
||||||
|
speak(`Tracker detected: ${d.service_data.tracker_type}`, PRIORITY.HIGH);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}, 'bluetooth');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stopStreams() {
|
||||||
|
Object.values(_sources).forEach(es => { try { es.close(); } catch (_) {} });
|
||||||
|
_sources = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
_loadConfig();
|
||||||
|
if (_isSpeechSupported()) {
|
||||||
|
// Prime voices list early so user-triggered test calls are less likely to be silent.
|
||||||
|
speechSynthesis.getVoices();
|
||||||
|
}
|
||||||
|
_startStreams();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEnabled(val) {
|
||||||
|
_enabled = val;
|
||||||
|
if (!val) {
|
||||||
|
_stopStreams();
|
||||||
|
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
||||||
|
} else {
|
||||||
|
_startStreams();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config API (used by Ops Center voice config panel) ─────────────
|
||||||
|
|
||||||
|
function getConfig() {
|
||||||
|
return JSON.parse(JSON.stringify(_config));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConfig(cfg) {
|
||||||
|
if (cfg.rate !== undefined) _config.rate = _toNumberInRange(cfg.rate, _config.rate, RATE_MIN, RATE_MAX);
|
||||||
|
if (cfg.pitch !== undefined) _config.pitch = _toNumberInRange(cfg.pitch, _config.pitch, PITCH_MIN, PITCH_MAX);
|
||||||
|
if (cfg.voiceName !== undefined) _config.voiceName = cfg.voiceName;
|
||||||
|
if (cfg.streams) Object.assign(_config.streams, cfg.streams);
|
||||||
|
_normalizeConfig();
|
||||||
|
localStorage.setItem(CONFIG_KEY, JSON.stringify(_config));
|
||||||
|
// Restart streams to apply per-stream toggle changes
|
||||||
|
_stopStreams();
|
||||||
|
_startStreams();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvailableVoices() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (!window.speechSynthesis) { resolve([]); return; }
|
||||||
|
let voices = speechSynthesis.getVoices();
|
||||||
|
if (voices.length > 0) { resolve(voices); return; }
|
||||||
|
speechSynthesis.onvoiceschanged = () => {
|
||||||
|
resolve(speechSynthesis.getVoices());
|
||||||
|
};
|
||||||
|
// Timeout fallback
|
||||||
|
setTimeout(() => resolve(speechSynthesis.getVoices()), 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function testVoice(text) {
|
||||||
|
if (!_isSpeechSupported()) {
|
||||||
|
_showVoiceToast('Voice Unavailable', 'This browser does not support speech synthesis.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make the test immediate and recover from a paused/stalled synthesis engine.
|
||||||
|
try {
|
||||||
|
speechSynthesis.getVoices();
|
||||||
|
if (speechSynthesis.paused) speechSynthesis.resume();
|
||||||
|
speechSynthesis.cancel();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
const utt = _createUtterance(text || 'Voice alert test. All systems nominal.');
|
||||||
|
let started = false;
|
||||||
|
utt.onstart = () => { started = true; };
|
||||||
|
utt.onerror = () => {
|
||||||
|
_showVoiceToast('Voice Test Failed', 'Speech synthesis failed to start. Check browser audio output.', 'warning');
|
||||||
|
};
|
||||||
|
speechSynthesis.speak(utt);
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (!started && !speechSynthesis.speaking && !speechSynthesis.pending) {
|
||||||
|
_showVoiceToast('No Voice Output', 'Test speech did not play. Verify browser audio and selected voice.', 'warning');
|
||||||
|
}
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init, speak, toggleMute, setEnabled, getConfig, setConfig, getAvailableVoices, testVoice, PRIORITY };
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.VoiceAlerts = VoiceAlerts;
|
||||||
@@ -1,549 +0,0 @@
|
|||||||
/**
|
|
||||||
* Analytics Dashboard Module
|
|
||||||
* Cross-mode summary, sparklines, alerts, correlations, target view, and replay.
|
|
||||||
*/
|
|
||||||
const Analytics = (function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
let refreshTimer = null;
|
|
||||||
let replayTimer = null;
|
|
||||||
let replaySessions = [];
|
|
||||||
let replayEvents = [];
|
|
||||||
let replayIndex = 0;
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
refresh();
|
|
||||||
loadReplaySessions();
|
|
||||||
if (!refreshTimer) {
|
|
||||||
refreshTimer = setInterval(refresh, 5000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function destroy() {
|
|
||||||
if (refreshTimer) {
|
|
||||||
clearInterval(refreshTimer);
|
|
||||||
refreshTimer = null;
|
|
||||||
}
|
|
||||||
pauseReplay();
|
|
||||||
}
|
|
||||||
|
|
||||||
function refresh() {
|
|
||||||
Promise.all([
|
|
||||||
fetch('/analytics/summary').then(r => r.json()).catch(() => null),
|
|
||||||
fetch('/analytics/activity').then(r => r.json()).catch(() => null),
|
|
||||||
fetch('/analytics/insights').then(r => r.json()).catch(() => null),
|
|
||||||
fetch('/analytics/patterns').then(r => r.json()).catch(() => null),
|
|
||||||
fetch('/alerts/events?limit=20').then(r => r.json()).catch(() => null),
|
|
||||||
fetch('/correlation').then(r => r.json()).catch(() => null),
|
|
||||||
fetch('/analytics/geofences').then(r => r.json()).catch(() => null),
|
|
||||||
]).then(([summary, activity, insights, patterns, alerts, correlations, geofences]) => {
|
|
||||||
if (summary) renderSummary(summary);
|
|
||||||
if (activity) renderSparklines(activity.sparklines || {});
|
|
||||||
if (insights) renderInsights(insights);
|
|
||||||
if (patterns) renderPatterns(patterns.patterns || []);
|
|
||||||
if (alerts) renderAlerts(alerts.events || []);
|
|
||||||
if (correlations) renderCorrelations(correlations);
|
|
||||||
if (geofences) renderGeofences(geofences.zones || []);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSummary(data) {
|
|
||||||
const counts = data.counts || {};
|
|
||||||
_setText('analyticsCountAdsb', counts.adsb || 0);
|
|
||||||
_setText('analyticsCountAis', counts.ais || 0);
|
|
||||||
_setText('analyticsCountWifi', counts.wifi || 0);
|
|
||||||
_setText('analyticsCountBt', counts.bluetooth || 0);
|
|
||||||
_setText('analyticsCountDsc', counts.dsc || 0);
|
|
||||||
_setText('analyticsCountAcars', counts.acars || 0);
|
|
||||||
_setText('analyticsCountVdl2', counts.vdl2 || 0);
|
|
||||||
_setText('analyticsCountAprs', counts.aprs || 0);
|
|
||||||
_setText('analyticsCountMesh', counts.meshtastic || 0);
|
|
||||||
|
|
||||||
const health = data.health || {};
|
|
||||||
const container = document.getElementById('analyticsHealth');
|
|
||||||
if (container) {
|
|
||||||
let html = '';
|
|
||||||
const modeLabels = {
|
|
||||||
pager: 'Pager', sensor: '433MHz', adsb: 'ADS-B', ais: 'AIS',
|
|
||||||
acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', wifi: 'WiFi',
|
|
||||||
bluetooth: 'BT', dsc: 'DSC', meshtastic: 'Mesh'
|
|
||||||
};
|
|
||||||
for (const [mode, info] of Object.entries(health)) {
|
|
||||||
if (mode === 'sdr_devices') continue;
|
|
||||||
const running = info && info.running;
|
|
||||||
const label = modeLabels[mode] || mode;
|
|
||||||
html += '<div class="health-item"><span class="health-dot' + (running ? ' running' : '') + '"></span>' + _esc(label) + '</div>';
|
|
||||||
}
|
|
||||||
container.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
const squawks = data.squawks || [];
|
|
||||||
const sqSection = document.getElementById('analyticsSquawkSection');
|
|
||||||
const sqList = document.getElementById('analyticsSquawkList');
|
|
||||||
if (sqSection && sqList) {
|
|
||||||
if (squawks.length > 0) {
|
|
||||||
sqSection.style.display = '';
|
|
||||||
sqList.innerHTML = squawks.map(s =>
|
|
||||||
'<div class="squawk-item"><strong>' + _esc(s.squawk) + '</strong> ' +
|
|
||||||
_esc(s.meaning) + ' - ' + _esc(s.callsign || s.icao) + '</div>'
|
|
||||||
).join('');
|
|
||||||
} else {
|
|
||||||
sqSection.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSparklines(sparklines) {
|
|
||||||
const map = {
|
|
||||||
adsb: 'analyticsSparkAdsb',
|
|
||||||
ais: 'analyticsSparkAis',
|
|
||||||
wifi: 'analyticsSparkWifi',
|
|
||||||
bluetooth: 'analyticsSparkBt',
|
|
||||||
dsc: 'analyticsSparkDsc',
|
|
||||||
acars: 'analyticsSparkAcars',
|
|
||||||
vdl2: 'analyticsSparkVdl2',
|
|
||||||
aprs: 'analyticsSparkAprs',
|
|
||||||
meshtastic: 'analyticsSparkMesh',
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const [mode, elId] of Object.entries(map)) {
|
|
||||||
const el = document.getElementById(elId);
|
|
||||||
if (!el) continue;
|
|
||||||
const data = sparklines[mode] || [];
|
|
||||||
if (data.length < 2) {
|
|
||||||
el.innerHTML = '';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const max = Math.max(...data, 1);
|
|
||||||
const w = 100;
|
|
||||||
const h = 24;
|
|
||||||
const step = w / (data.length - 1);
|
|
||||||
const points = data.map((v, i) =>
|
|
||||||
(i * step).toFixed(1) + ',' + (h - (v / max) * (h - 2)).toFixed(1)
|
|
||||||
).join(' ');
|
|
||||||
el.innerHTML = '<svg viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none"><polyline points="' + points + '"/></svg>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderInsights(data) {
|
|
||||||
const cards = data.cards || [];
|
|
||||||
const topChanges = data.top_changes || [];
|
|
||||||
const cardsEl = document.getElementById('analyticsInsights');
|
|
||||||
const changesEl = document.getElementById('analyticsTopChanges');
|
|
||||||
|
|
||||||
if (cardsEl) {
|
|
||||||
if (!cards.length) {
|
|
||||||
cardsEl.innerHTML = '<div class="analytics-empty">No insight data available</div>';
|
|
||||||
} else {
|
|
||||||
cardsEl.innerHTML = cards.map(c => {
|
|
||||||
const sev = _esc(c.severity || 'low');
|
|
||||||
const title = _esc(c.title || 'Insight');
|
|
||||||
const value = _esc(c.value || '--');
|
|
||||||
const label = _esc(c.label || '');
|
|
||||||
const detail = _esc(c.detail || '');
|
|
||||||
return '<div class="analytics-insight-card ' + sev + '">' +
|
|
||||||
'<div class="insight-title">' + title + '</div>' +
|
|
||||||
'<div class="insight-value">' + value + '</div>' +
|
|
||||||
'<div class="insight-label">' + label + '</div>' +
|
|
||||||
'<div class="insight-detail">' + detail + '</div>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changesEl) {
|
|
||||||
if (!topChanges.length) {
|
|
||||||
changesEl.innerHTML = '<div class="analytics-empty">No change signals yet</div>';
|
|
||||||
} else {
|
|
||||||
changesEl.innerHTML = topChanges.map(item => {
|
|
||||||
const mode = _esc(item.mode_label || item.mode || '');
|
|
||||||
const deltaRaw = Number(item.delta || 0);
|
|
||||||
const trendClass = deltaRaw > 0 ? 'up' : (deltaRaw < 0 ? 'down' : 'flat');
|
|
||||||
const delta = _esc(item.signed_delta || String(deltaRaw));
|
|
||||||
const recentAvg = _esc(item.recent_avg);
|
|
||||||
const prevAvg = _esc(item.previous_avg);
|
|
||||||
return '<div class="analytics-change-row">' +
|
|
||||||
'<span class="mode">' + mode + '</span>' +
|
|
||||||
'<span class="delta ' + trendClass + '">' + delta + '</span>' +
|
|
||||||
'<span class="avg">avg ' + recentAvg + ' vs ' + prevAvg + '</span>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPatterns(patterns) {
|
|
||||||
const container = document.getElementById('analyticsPatternList');
|
|
||||||
if (!container) return;
|
|
||||||
if (!patterns || patterns.length === 0) {
|
|
||||||
container.innerHTML = '<div class="analytics-empty">No recurring patterns detected</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const modeLabels = {
|
|
||||||
adsb: 'ADS-B', ais: 'AIS', wifi: 'WiFi', bluetooth: 'Bluetooth',
|
|
||||||
dsc: 'DSC', acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', meshtastic: 'Meshtastic',
|
|
||||||
};
|
|
||||||
|
|
||||||
const sorted = patterns
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => (b.confidence || 0) - (a.confidence || 0))
|
|
||||||
.slice(0, 20);
|
|
||||||
|
|
||||||
container.innerHTML = sorted.map(p => {
|
|
||||||
const confidencePct = Math.round((Number(p.confidence || 0)) * 100);
|
|
||||||
const mode = modeLabels[p.mode] || (p.mode || '--').toUpperCase();
|
|
||||||
const period = _humanPeriod(Number(p.period_seconds || 0));
|
|
||||||
const occurrences = Number(p.occurrences || 0);
|
|
||||||
const deviceId = _shortId(p.device_id || '--');
|
|
||||||
return '<div class="analytics-pattern-item">' +
|
|
||||||
'<div class="pattern-main">' +
|
|
||||||
'<span class="pattern-mode">' + _esc(mode) + '</span>' +
|
|
||||||
'<span class="pattern-device">' + _esc(deviceId) + '</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="pattern-meta">' +
|
|
||||||
'<span>Period: ' + _esc(period) + '</span>' +
|
|
||||||
'<span>Hits: ' + _esc(occurrences) + '</span>' +
|
|
||||||
'<span class="pattern-confidence">' + _esc(confidencePct) + '%</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAlerts(events) {
|
|
||||||
const container = document.getElementById('analyticsAlertFeed');
|
|
||||||
if (!container) return;
|
|
||||||
if (!events || events.length === 0) {
|
|
||||||
container.innerHTML = '<div class="analytics-empty">No recent alerts</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.innerHTML = events.slice(0, 20).map(e => {
|
|
||||||
const sev = e.severity || 'medium';
|
|
||||||
const title = e.title || e.event_type || 'Alert';
|
|
||||||
const time = e.created_at ? new Date(e.created_at).toLocaleTimeString() : '';
|
|
||||||
return '<div class="analytics-alert-item">' +
|
|
||||||
'<span class="alert-severity ' + _esc(sev) + '">' + _esc(sev) + '</span>' +
|
|
||||||
'<span>' + _esc(title) + '</span>' +
|
|
||||||
'<span style="margin-left:auto;color:var(--text-dim)">' + _esc(time) + '</span>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCorrelations(data) {
|
|
||||||
const container = document.getElementById('analyticsCorrelations');
|
|
||||||
if (!container) return;
|
|
||||||
const pairs = (data && data.correlations) || [];
|
|
||||||
if (pairs.length === 0) {
|
|
||||||
container.innerHTML = '<div class="analytics-empty">No correlations detected</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.innerHTML = pairs.slice(0, 20).map(p => {
|
|
||||||
const conf = Math.round((p.confidence || 0) * 100);
|
|
||||||
return '<div class="analytics-correlation-pair">' +
|
|
||||||
'<span>' + _esc(p.wifi_mac || '') + '</span>' +
|
|
||||||
'<span style="color:var(--text-dim)">↔</span>' +
|
|
||||||
'<span>' + _esc(p.bt_mac || '') + '</span>' +
|
|
||||||
'<div class="confidence-bar"><div class="confidence-fill" style="width:' + conf + '%"></div></div>' +
|
|
||||||
'<span style="color:var(--text-dim)">' + conf + '%</span>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderGeofences(zones) {
|
|
||||||
const container = document.getElementById('analyticsGeofenceList');
|
|
||||||
if (!container) return;
|
|
||||||
if (!zones || zones.length === 0) {
|
|
||||||
container.innerHTML = '<div class="analytics-empty">No geofence zones defined</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.innerHTML = zones.map(z =>
|
|
||||||
'<div class="geofence-zone-item">' +
|
|
||||||
'<span class="zone-name">' + _esc(z.name) + '</span>' +
|
|
||||||
'<span class="zone-radius">' + z.radius_m + 'm</span>' +
|
|
||||||
'<button class="zone-delete" onclick="Analytics.deleteGeofence(' + z.id + ')">DEL</button>' +
|
|
||||||
'</div>'
|
|
||||||
).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function addGeofence() {
|
|
||||||
const name = prompt('Zone name:');
|
|
||||||
if (!name) return;
|
|
||||||
const lat = parseFloat(prompt('Latitude:', '0'));
|
|
||||||
const lon = parseFloat(prompt('Longitude:', '0'));
|
|
||||||
const radius = parseFloat(prompt('Radius (meters):', '1000'));
|
|
||||||
if (isNaN(lat) || isNaN(lon) || isNaN(radius)) {
|
|
||||||
alert('Invalid input');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fetch('/analytics/geofences', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name, lat, lon, radius_m: radius }),
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(() => refresh());
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteGeofence(id) {
|
|
||||||
if (!confirm('Delete this geofence zone?')) return;
|
|
||||||
fetch('/analytics/geofences/' + id, { method: 'DELETE' })
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(() => refresh());
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportData(mode) {
|
|
||||||
const m = mode || (document.getElementById('exportMode') || {}).value || 'adsb';
|
|
||||||
const f = (document.getElementById('exportFormat') || {}).value || 'json';
|
|
||||||
window.open('/analytics/export/' + encodeURIComponent(m) + '?format=' + encodeURIComponent(f), '_blank');
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchTarget() {
|
|
||||||
const input = document.getElementById('analyticsTargetQuery');
|
|
||||||
const summaryEl = document.getElementById('analyticsTargetSummary');
|
|
||||||
const q = (input && input.value || '').trim();
|
|
||||||
if (!q) {
|
|
||||||
if (summaryEl) summaryEl.textContent = 'Enter a search value to correlate entities';
|
|
||||||
renderTargetResults([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch('/analytics/target?q=' + encodeURIComponent(q) + '&limit=120')
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
const results = data.results || [];
|
|
||||||
if (summaryEl) {
|
|
||||||
const modeCounts = data.mode_counts || {};
|
|
||||||
const bits = Object.entries(modeCounts).map(([mode, count]) => `${mode}: ${count}`).join(' | ');
|
|
||||||
summaryEl.textContent = `${results.length} results${bits ? ' | ' + bits : ''}`;
|
|
||||||
}
|
|
||||||
renderTargetResults(results);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (summaryEl) summaryEl.textContent = 'Search failed';
|
|
||||||
if (typeof reportActionableError === 'function') {
|
|
||||||
reportActionableError('Target View Search', err, { onRetry: searchTarget });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTargetResults(results) {
|
|
||||||
const container = document.getElementById('analyticsTargetResults');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
if (!results || !results.length) {
|
|
||||||
container.innerHTML = '<div class="analytics-empty">No matching entities</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = results.map((item) => {
|
|
||||||
const title = _esc(item.title || item.id || 'Entity');
|
|
||||||
const subtitle = _esc(item.subtitle || '');
|
|
||||||
const mode = _esc(item.mode || 'unknown');
|
|
||||||
const confidence = item.confidence != null ? `Confidence ${_esc(Math.round(Number(item.confidence) * 100))}%` : '';
|
|
||||||
const lastSeen = _esc(item.last_seen || '');
|
|
||||||
return '<div class="analytics-target-item">' +
|
|
||||||
'<div class="title"><span class="mode">' + mode + '</span><span>' + title + '</span></div>' +
|
|
||||||
'<div class="meta"><span>' + subtitle + '</span>' +
|
|
||||||
(lastSeen ? '<span>Last seen ' + lastSeen + '</span>' : '') +
|
|
||||||
(confidence ? '<span>' + confidence + '</span>' : '') +
|
|
||||||
'</div>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadReplaySessions() {
|
|
||||||
const select = document.getElementById('analyticsReplaySelect');
|
|
||||||
if (!select) return;
|
|
||||||
|
|
||||||
fetch('/recordings?limit=60')
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
replaySessions = (data.recordings || []).filter((rec) => Number(rec.event_count || 0) > 0);
|
|
||||||
|
|
||||||
if (!replaySessions.length) {
|
|
||||||
select.innerHTML = '<option value="">No recordings</option>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
select.innerHTML = replaySessions.map((rec) => {
|
|
||||||
const label = `${rec.mode} | ${(rec.label || 'session')} | ${new Date(rec.started_at).toLocaleString()}`;
|
|
||||||
return `<option value="${_esc(rec.id)}">${_esc(label)}</option>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
const pendingReplay = localStorage.getItem('analyticsReplaySession');
|
|
||||||
if (pendingReplay && replaySessions.some((rec) => rec.id === pendingReplay)) {
|
|
||||||
select.value = pendingReplay;
|
|
||||||
localStorage.removeItem('analyticsReplaySession');
|
|
||||||
loadReplay();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (typeof reportActionableError === 'function') {
|
|
||||||
reportActionableError('Load Replay Sessions', err, { onRetry: loadReplaySessions });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadReplay() {
|
|
||||||
pauseReplay();
|
|
||||||
replayEvents = [];
|
|
||||||
replayIndex = 0;
|
|
||||||
|
|
||||||
const select = document.getElementById('analyticsReplaySelect');
|
|
||||||
const meta = document.getElementById('analyticsReplayMeta');
|
|
||||||
const timeline = document.getElementById('analyticsReplayTimeline');
|
|
||||||
if (!select || !meta || !timeline) return;
|
|
||||||
|
|
||||||
const id = select.value;
|
|
||||||
if (!id) {
|
|
||||||
meta.textContent = 'Select a recording';
|
|
||||||
timeline.innerHTML = '<div class="analytics-empty">No recording selected</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
meta.textContent = 'Loading replay events...';
|
|
||||||
|
|
||||||
fetch('/recordings/' + encodeURIComponent(id) + '/events?limit=600')
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
replayEvents = data.events || [];
|
|
||||||
replayIndex = 0;
|
|
||||||
if (!replayEvents.length) {
|
|
||||||
meta.textContent = 'No events found in selected recording';
|
|
||||||
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rec = replaySessions.find((s) => s.id === id);
|
|
||||||
const mode = rec ? rec.mode : (data.recording && data.recording.mode) || 'unknown';
|
|
||||||
meta.textContent = `${replayEvents.length} events loaded | mode ${mode}`;
|
|
||||||
renderReplayWindow();
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
meta.textContent = 'Replay load failed';
|
|
||||||
if (typeof reportActionableError === 'function') {
|
|
||||||
reportActionableError('Load Replay', err, { onRetry: loadReplay });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function playReplay() {
|
|
||||||
if (!replayEvents.length) {
|
|
||||||
loadReplay();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (replayTimer) return;
|
|
||||||
|
|
||||||
replayTimer = setInterval(() => {
|
|
||||||
if (replayIndex >= replayEvents.length - 1) {
|
|
||||||
pauseReplay();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
replayIndex += 1;
|
|
||||||
renderReplayWindow();
|
|
||||||
}, 260);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pauseReplay() {
|
|
||||||
if (replayTimer) {
|
|
||||||
clearInterval(replayTimer);
|
|
||||||
replayTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stepReplay() {
|
|
||||||
if (!replayEvents.length) {
|
|
||||||
loadReplay();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
pauseReplay();
|
|
||||||
replayIndex = Math.min(replayIndex + 1, replayEvents.length - 1);
|
|
||||||
renderReplayWindow();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderReplayWindow() {
|
|
||||||
const timeline = document.getElementById('analyticsReplayTimeline');
|
|
||||||
const meta = document.getElementById('analyticsReplayMeta');
|
|
||||||
if (!timeline || !meta) return;
|
|
||||||
|
|
||||||
const total = replayEvents.length;
|
|
||||||
if (!total) {
|
|
||||||
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = Math.max(0, replayIndex - 15);
|
|
||||||
const end = Math.min(total, replayIndex + 20);
|
|
||||||
const windowed = replayEvents.slice(start, end);
|
|
||||||
|
|
||||||
timeline.innerHTML = windowed.map((row, i) => {
|
|
||||||
const absolute = start + i;
|
|
||||||
const active = absolute === replayIndex;
|
|
||||||
const eventType = _esc(row.event_type || 'event');
|
|
||||||
const mode = _esc(row.mode || '--');
|
|
||||||
const ts = _esc(row.timestamp ? new Date(row.timestamp).toLocaleTimeString() : '--');
|
|
||||||
const detail = summarizeReplayEvent(row.event || {});
|
|
||||||
return '<div class="analytics-replay-item" style="opacity:' + (active ? '1' : '0.65') + ';">' +
|
|
||||||
'<div class="title"><span class="mode">' + mode + '</span><span>' + eventType + '</span></div>' +
|
|
||||||
'<div class="meta"><span>' + ts + '</span><span>' + _esc(detail) + '</span></div>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
meta.textContent = `Event ${replayIndex + 1}/${total}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeReplayEvent(event) {
|
|
||||||
if (!event || typeof event !== 'object') return 'No details';
|
|
||||||
if (event.callsign) return `Callsign ${event.callsign}`;
|
|
||||||
if (event.icao) return `ICAO ${event.icao}`;
|
|
||||||
if (event.ssid) return `SSID ${event.ssid}`;
|
|
||||||
if (event.bssid) return `BSSID ${event.bssid}`;
|
|
||||||
if (event.address) return `Address ${event.address}`;
|
|
||||||
if (event.name) return `Name ${event.name}`;
|
|
||||||
const keys = Object.keys(event);
|
|
||||||
if (!keys.length) return 'No fields';
|
|
||||||
return `${keys[0]}=${String(event[keys[0]]).slice(0, 40)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _setText(id, val) {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (el) el.textContent = val;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _esc(s) {
|
|
||||||
if (typeof s !== 'string') s = String(s == null ? '' : s);
|
|
||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
function _shortId(value) {
|
|
||||||
const text = String(value || '');
|
|
||||||
if (text.length <= 18) return text;
|
|
||||||
return text.slice(0, 8) + '...' + text.slice(-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _humanPeriod(seconds) {
|
|
||||||
if (!isFinite(seconds) || seconds <= 0) return '--';
|
|
||||||
if (seconds < 60) return Math.round(seconds) + 's';
|
|
||||||
const mins = seconds / 60;
|
|
||||||
if (mins < 60) return mins.toFixed(mins < 10 ? 1 : 0) + 'm';
|
|
||||||
const hours = mins / 60;
|
|
||||||
return hours.toFixed(hours < 10 ? 1 : 0) + 'h';
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
init,
|
|
||||||
destroy,
|
|
||||||
refresh,
|
|
||||||
addGeofence,
|
|
||||||
deleteGeofence,
|
|
||||||
exportData,
|
|
||||||
searchTarget,
|
|
||||||
loadReplay,
|
|
||||||
playReplay,
|
|
||||||
pauseReplay,
|
|
||||||
stepReplay,
|
|
||||||
loadReplaySessions,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -946,17 +946,32 @@ const BluetoothMode = (function() {
|
|||||||
|
|
||||||
async function stopScan() {
|
async function stopScan() {
|
||||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
|
const timeoutMs = isAgentMode ? 8000 : 2200;
|
||||||
|
const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
||||||
|
const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
||||||
|
|
||||||
|
// Optimistic UI teardown keeps mode changes responsive.
|
||||||
|
setScanning(false);
|
||||||
|
stopEventStream();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isAgentMode) {
|
if (isAgentMode) {
|
||||||
await fetch(`/controller/agents/${currentAgent}/bluetooth/stop`, { method: 'POST' });
|
await fetch(`/controller/agents/${currentAgent}/bluetooth/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
...(controller ? { signal: controller.signal } : {}),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await fetch('/api/bluetooth/scan/stop', { method: 'POST' });
|
await fetch('/api/bluetooth/scan/stop', {
|
||||||
|
method: 'POST',
|
||||||
|
...(controller ? { signal: controller.signal } : {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setScanning(false);
|
|
||||||
stopEventStream();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to stop scan:', err);
|
console.error('Failed to stop scan:', err);
|
||||||
|
} finally {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const BtLocate = (function() {
|
|||||||
let queuedDetectionTimer = null;
|
let queuedDetectionTimer = null;
|
||||||
let lastDetectionRenderAt = 0;
|
let lastDetectionRenderAt = 0;
|
||||||
let startRequestInFlight = false;
|
let startRequestInFlight = false;
|
||||||
|
let crosshairResetTimer = null;
|
||||||
|
|
||||||
const MAX_HEAT_POINTS = 1200;
|
const MAX_HEAT_POINTS = 1200;
|
||||||
const MAX_TRAIL_POINTS = 1200;
|
const MAX_TRAIL_POINTS = 1200;
|
||||||
@@ -226,11 +227,11 @@ const BtLocate = (function() {
|
|||||||
map.on('resize moveend zoomend', () => {
|
map.on('resize moveend zoomend', () => {
|
||||||
flushPendingHeatSync();
|
flushPendingHeatSync();
|
||||||
});
|
});
|
||||||
setTimeout(() => {
|
requestAnimationFrame(() => {
|
||||||
safeInvalidateMap();
|
safeInvalidateMap();
|
||||||
flushPendingHeatSync();
|
flushPendingHeatSync();
|
||||||
}, 100);
|
scheduleMapStabilization();
|
||||||
scheduleMapStabilization();
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init RSSI chart canvas
|
// Init RSSI chart canvas
|
||||||
@@ -349,19 +350,18 @@ const BtLocate = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stop() {
|
function stop() {
|
||||||
|
// Update UI immediately — don't wait for the backend response.
|
||||||
|
if (queuedDetectionTimer) {
|
||||||
|
clearTimeout(queuedDetectionTimer);
|
||||||
|
queuedDetectionTimer = null;
|
||||||
|
}
|
||||||
|
queuedDetection = null;
|
||||||
|
queuedDetectionOptions = null;
|
||||||
|
showIdleUI();
|
||||||
|
disconnectSSE();
|
||||||
|
stopAudio();
|
||||||
|
// Notify backend asynchronously.
|
||||||
fetch('/bt_locate/stop', { method: 'POST' })
|
fetch('/bt_locate/stop', { method: 'POST' })
|
||||||
.then(r => r.json())
|
|
||||||
.then(() => {
|
|
||||||
if (queuedDetectionTimer) {
|
|
||||||
clearTimeout(queuedDetectionTimer);
|
|
||||||
queuedDetectionTimer = null;
|
|
||||||
}
|
|
||||||
queuedDetection = null;
|
|
||||||
queuedDetectionOptions = null;
|
|
||||||
showIdleUI();
|
|
||||||
disconnectSSE();
|
|
||||||
stopAudio();
|
|
||||||
})
|
|
||||||
.catch(err => console.error('[BtLocate] Stop error:', err));
|
.catch(err => console.error('[BtLocate] Stop error:', err));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -703,6 +703,32 @@ const BtLocate = (function() {
|
|||||||
if (gpsCountEl) gpsCountEl.textContent = gpsPoints || 0;
|
if (gpsCountEl) gpsCountEl.textContent = gpsPoints || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function triggerCrosshairAnimation(lat, lon) {
|
||||||
|
if (!map) return;
|
||||||
|
const overlay = document.getElementById('btLocateCrosshairOverlay');
|
||||||
|
if (!overlay) return;
|
||||||
|
const size = map.getSize();
|
||||||
|
const point = map.latLngToContainerPoint([lat, lon]);
|
||||||
|
const targetX = Math.max(0, Math.min(size.x, point.x));
|
||||||
|
const targetY = Math.max(0, Math.min(size.y, point.y));
|
||||||
|
const startX = size.x + 8;
|
||||||
|
const startY = size.y + 8;
|
||||||
|
const duration = 1500;
|
||||||
|
overlay.style.setProperty('--btl-crosshair-x-start', `${startX}px`);
|
||||||
|
overlay.style.setProperty('--btl-crosshair-y-start', `${startY}px`);
|
||||||
|
overlay.style.setProperty('--btl-crosshair-x-end', `${targetX}px`);
|
||||||
|
overlay.style.setProperty('--btl-crosshair-y-end', `${targetY}px`);
|
||||||
|
overlay.style.setProperty('--btl-crosshair-duration', `${duration}ms`);
|
||||||
|
overlay.classList.remove('active');
|
||||||
|
void overlay.offsetWidth;
|
||||||
|
overlay.classList.add('active');
|
||||||
|
if (crosshairResetTimer) clearTimeout(crosshairResetTimer);
|
||||||
|
crosshairResetTimer = setTimeout(() => {
|
||||||
|
overlay.classList.remove('active');
|
||||||
|
crosshairResetTimer = null;
|
||||||
|
}, duration + 100);
|
||||||
|
}
|
||||||
|
|
||||||
function addMapMarker(point, options = {}) {
|
function addMapMarker(point, options = {}) {
|
||||||
if (!map || point.lat == null || point.lon == null) return false;
|
if (!map || point.lat == null || point.lon == null) return false;
|
||||||
const lat = Number(point.lat);
|
const lat = Number(point.lat);
|
||||||
@@ -737,6 +763,7 @@ const BtLocate = (function() {
|
|||||||
'Time: ' + formatPointTimestamp(trailPoint.timestamp) +
|
'Time: ' + formatPointTimestamp(trailPoint.timestamp) +
|
||||||
'</div>'
|
'</div>'
|
||||||
);
|
);
|
||||||
|
marker.on('click', () => triggerCrosshairAnimation(lat, lon));
|
||||||
|
|
||||||
trailPoints.push(trailPoint);
|
trailPoints.push(trailPoint);
|
||||||
mapMarkers.push(marker);
|
mapMarkers.push(marker);
|
||||||
|
|||||||
+671
-28
@@ -9,6 +9,9 @@ const GPS = (function() {
|
|||||||
let lastPosition = null;
|
let lastPosition = null;
|
||||||
let lastSky = null;
|
let lastSky = null;
|
||||||
let skyPollTimer = null;
|
let skyPollTimer = null;
|
||||||
|
let themeObserver = null;
|
||||||
|
let skyRenderer = null;
|
||||||
|
let skyRendererInitAttempted = false;
|
||||||
|
|
||||||
// Constellation color map
|
// Constellation color map
|
||||||
const CONST_COLORS = {
|
const CONST_COLORS = {
|
||||||
@@ -21,18 +24,43 @@ const GPS = (function() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
|
initSkyRenderer();
|
||||||
drawEmptySkyView();
|
drawEmptySkyView();
|
||||||
connect();
|
if (!connected) connect();
|
||||||
|
|
||||||
// Redraw sky view when theme changes
|
// Redraw sky view when theme changes
|
||||||
const observer = new MutationObserver(() => {
|
if (!themeObserver) {
|
||||||
if (lastSky) {
|
themeObserver = new MutationObserver(() => {
|
||||||
drawSkyView(lastSky.satellites || []);
|
if (skyRenderer && typeof skyRenderer.requestRender === 'function') {
|
||||||
} else {
|
skyRenderer.requestRender();
|
||||||
drawEmptySkyView();
|
}
|
||||||
}
|
if (lastSky) {
|
||||||
});
|
drawSkyView(lastSky.satellites || []);
|
||||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
} else {
|
||||||
|
drawEmptySkyView();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastPosition) updatePositionUI(lastPosition);
|
||||||
|
if (lastSky) updateSkyUI(lastSky);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSkyRenderer() {
|
||||||
|
if (skyRendererInitAttempted) return;
|
||||||
|
skyRendererInitAttempted = true;
|
||||||
|
|
||||||
|
const canvas = document.getElementById('gpsSkyCanvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const overlay = document.getElementById('gpsSkyOverlay');
|
||||||
|
try {
|
||||||
|
skyRenderer = createWebGlSkyRenderer(canvas, overlay);
|
||||||
|
} catch (err) {
|
||||||
|
skyRenderer = null;
|
||||||
|
console.warn('GPS sky WebGL renderer failed, falling back to 2D', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
@@ -253,41 +281,61 @@ const GPS = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ========================
|
// ========================
|
||||||
// Sky View Polar Plot
|
// Sky View Globe (WebGL with 2D fallback)
|
||||||
// ========================
|
// ========================
|
||||||
|
|
||||||
function drawEmptySkyView() {
|
function drawEmptySkyView() {
|
||||||
|
if (!skyRendererInitAttempted) {
|
||||||
|
initSkyRenderer();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skyRenderer) {
|
||||||
|
skyRenderer.setSatellites([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const canvas = document.getElementById('gpsSkyCanvas');
|
const canvas = document.getElementById('gpsSkyCanvas');
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
drawSkyViewBase(canvas);
|
drawSkyViewBase2D(canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawSkyView(satellites) {
|
function drawSkyView(satellites) {
|
||||||
|
if (!skyRendererInitAttempted) {
|
||||||
|
initSkyRenderer();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sats = Array.isArray(satellites) ? satellites : [];
|
||||||
|
|
||||||
|
if (skyRenderer) {
|
||||||
|
skyRenderer.setSatellites(sats);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const canvas = document.getElementById('gpsSkyCanvas');
|
const canvas = document.getElementById('gpsSkyCanvas');
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
|
drawSkyViewBase2D(canvas);
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
const w = canvas.width;
|
const w = canvas.width;
|
||||||
const h = canvas.height;
|
const h = canvas.height;
|
||||||
const cx = w / 2;
|
const cx = w / 2;
|
||||||
const cy = h / 2;
|
const cy = h / 2;
|
||||||
const r = Math.min(cx, cy) - 24;
|
const r = Math.min(cx, cy) - 24;
|
||||||
|
|
||||||
drawSkyViewBase(canvas);
|
sats.forEach(sat => {
|
||||||
|
|
||||||
// Plot satellites
|
|
||||||
satellites.forEach(sat => {
|
|
||||||
if (sat.elevation == null || sat.azimuth == null) return;
|
if (sat.elevation == null || sat.azimuth == null) return;
|
||||||
|
|
||||||
const elRad = (90 - sat.elevation) / 90;
|
const elRad = (90 - sat.elevation) / 90;
|
||||||
const azRad = (sat.azimuth - 90) * Math.PI / 180; // N = up
|
const azRad = (sat.azimuth - 90) * Math.PI / 180;
|
||||||
const px = cx + r * elRad * Math.cos(azRad);
|
const px = cx + r * elRad * Math.cos(azRad);
|
||||||
const py = cy + r * elRad * Math.sin(azRad);
|
const py = cy + r * elRad * Math.sin(azRad);
|
||||||
|
|
||||||
const color = CONST_COLORS[sat.constellation] || CONST_COLORS['GPS'];
|
const color = CONST_COLORS[sat.constellation] || CONST_COLORS.GPS;
|
||||||
const dotSize = sat.used ? 6 : 4;
|
const dotSize = sat.used ? 6 : 4;
|
||||||
|
|
||||||
// Draw dot
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
|
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
|
||||||
if (sat.used) {
|
if (sat.used) {
|
||||||
@@ -299,14 +347,12 @@ const GPS = (function() {
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
// PRN label
|
|
||||||
ctx.fillStyle = color;
|
ctx.fillStyle = color;
|
||||||
ctx.font = '8px Roboto Condensed, monospace';
|
ctx.font = '8px Roboto Condensed, monospace';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'bottom';
|
ctx.textBaseline = 'bottom';
|
||||||
ctx.fillText(sat.prn, px, py - dotSize - 2);
|
ctx.fillText(sat.prn, px, py - dotSize - 2);
|
||||||
|
|
||||||
// SNR value
|
|
||||||
if (sat.snr != null) {
|
if (sat.snr != null) {
|
||||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||||
ctx.font = '7px Roboto Condensed, monospace';
|
ctx.font = '7px Roboto Condensed, monospace';
|
||||||
@@ -316,8 +362,10 @@ const GPS = (function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawSkyViewBase(canvas) {
|
function drawSkyViewBase2D(canvas) {
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
const w = canvas.width;
|
const w = canvas.width;
|
||||||
const h = canvas.height;
|
const h = canvas.height;
|
||||||
const cx = w / 2;
|
const cx = w / 2;
|
||||||
@@ -332,11 +380,9 @@ const GPS = (function() {
|
|||||||
const dimColor = cs.getPropertyValue('--text-dim').trim() || '#555';
|
const dimColor = cs.getPropertyValue('--text-dim').trim() || '#555';
|
||||||
const secondaryColor = cs.getPropertyValue('--text-secondary').trim() || '#888';
|
const secondaryColor = cs.getPropertyValue('--text-secondary').trim() || '#888';
|
||||||
|
|
||||||
// Background
|
|
||||||
ctx.fillStyle = bgColor;
|
ctx.fillStyle = bgColor;
|
||||||
ctx.fillRect(0, 0, w, h);
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
// Elevation rings (0, 30, 60, 90)
|
|
||||||
ctx.strokeStyle = gridColor;
|
ctx.strokeStyle = gridColor;
|
||||||
ctx.lineWidth = 0.5;
|
ctx.lineWidth = 0.5;
|
||||||
[90, 60, 30].forEach(el => {
|
[90, 60, 30].forEach(el => {
|
||||||
@@ -344,7 +390,7 @@ const GPS = (function() {
|
|||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(cx, cy, gr, 0, Math.PI * 2);
|
ctx.arc(cx, cy, gr, 0, Math.PI * 2);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
// Label
|
|
||||||
ctx.fillStyle = dimColor;
|
ctx.fillStyle = dimColor;
|
||||||
ctx.font = '9px Roboto Condensed, monospace';
|
ctx.font = '9px Roboto Condensed, monospace';
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
@@ -352,14 +398,12 @@ const GPS = (function() {
|
|||||||
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
|
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Horizon circle
|
|
||||||
ctx.strokeStyle = gridColor;
|
ctx.strokeStyle = gridColor;
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Cardinal directions
|
|
||||||
ctx.fillStyle = secondaryColor;
|
ctx.fillStyle = secondaryColor;
|
||||||
ctx.font = 'bold 11px Roboto Condensed, monospace';
|
ctx.font = 'bold 11px Roboto Condensed, monospace';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
@@ -369,7 +413,6 @@ const GPS = (function() {
|
|||||||
ctx.fillText('E', cx + r + 12, cy);
|
ctx.fillText('E', cx + r + 12, cy);
|
||||||
ctx.fillText('W', cx - r - 12, cy);
|
ctx.fillText('W', cx - r - 12, cy);
|
||||||
|
|
||||||
// Crosshairs
|
|
||||||
ctx.strokeStyle = gridColor;
|
ctx.strokeStyle = gridColor;
|
||||||
ctx.lineWidth = 0.5;
|
ctx.lineWidth = 0.5;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -379,13 +422,604 @@ const GPS = (function() {
|
|||||||
ctx.lineTo(cx + r, cy);
|
ctx.lineTo(cx + r, cy);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Zenith dot
|
|
||||||
ctx.fillStyle = dimColor;
|
ctx.fillStyle = dimColor;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(cx, cy, 2, 0, Math.PI * 2);
|
ctx.arc(cx, cy, 2, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createWebGlSkyRenderer(canvas, overlay) {
|
||||||
|
const gl = canvas.getContext('webgl', { antialias: true, alpha: false, depth: true });
|
||||||
|
if (!gl) return null;
|
||||||
|
|
||||||
|
const lineProgram = createProgram(
|
||||||
|
gl,
|
||||||
|
[
|
||||||
|
'attribute vec3 aPosition;',
|
||||||
|
'uniform mat4 uMVP;',
|
||||||
|
'void main(void) {',
|
||||||
|
' gl_Position = uMVP * vec4(aPosition, 1.0);',
|
||||||
|
'}',
|
||||||
|
].join('\n'),
|
||||||
|
[
|
||||||
|
'precision mediump float;',
|
||||||
|
'uniform vec4 uColor;',
|
||||||
|
'void main(void) {',
|
||||||
|
' gl_FragColor = uColor;',
|
||||||
|
'}',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pointProgram = createProgram(
|
||||||
|
gl,
|
||||||
|
[
|
||||||
|
'attribute vec3 aPosition;',
|
||||||
|
'attribute vec4 aColor;',
|
||||||
|
'attribute float aSize;',
|
||||||
|
'attribute float aUsed;',
|
||||||
|
'uniform mat4 uMVP;',
|
||||||
|
'uniform float uDevicePixelRatio;',
|
||||||
|
'uniform vec3 uCameraDir;',
|
||||||
|
'varying vec4 vColor;',
|
||||||
|
'varying float vUsed;',
|
||||||
|
'varying float vFacing;',
|
||||||
|
'void main(void) {',
|
||||||
|
' vec3 normPos = normalize(aPosition);',
|
||||||
|
' vFacing = dot(normPos, normalize(uCameraDir));',
|
||||||
|
' gl_Position = uMVP * vec4(aPosition, 1.0);',
|
||||||
|
' gl_PointSize = aSize * uDevicePixelRatio;',
|
||||||
|
' vColor = aColor;',
|
||||||
|
' vUsed = aUsed;',
|
||||||
|
'}',
|
||||||
|
].join('\n'),
|
||||||
|
[
|
||||||
|
'precision mediump float;',
|
||||||
|
'varying vec4 vColor;',
|
||||||
|
'varying float vUsed;',
|
||||||
|
'varying float vFacing;',
|
||||||
|
'void main(void) {',
|
||||||
|
' if (vFacing <= 0.0) discard;',
|
||||||
|
' vec2 c = gl_PointCoord * 2.0 - 1.0;',
|
||||||
|
' float d = dot(c, c);',
|
||||||
|
' if (d > 1.0) discard;',
|
||||||
|
' if (vUsed < 0.5 && d < 0.45) discard;',
|
||||||
|
' float edge = smoothstep(1.0, 0.75, d);',
|
||||||
|
' gl_FragColor = vec4(vColor.rgb, vColor.a * edge);',
|
||||||
|
'}',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!lineProgram || !pointProgram) return null;
|
||||||
|
|
||||||
|
const lineLoc = {
|
||||||
|
position: gl.getAttribLocation(lineProgram, 'aPosition'),
|
||||||
|
mvp: gl.getUniformLocation(lineProgram, 'uMVP'),
|
||||||
|
color: gl.getUniformLocation(lineProgram, 'uColor'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const pointLoc = {
|
||||||
|
position: gl.getAttribLocation(pointProgram, 'aPosition'),
|
||||||
|
color: gl.getAttribLocation(pointProgram, 'aColor'),
|
||||||
|
size: gl.getAttribLocation(pointProgram, 'aSize'),
|
||||||
|
used: gl.getAttribLocation(pointProgram, 'aUsed'),
|
||||||
|
mvp: gl.getUniformLocation(pointProgram, 'uMVP'),
|
||||||
|
dpr: gl.getUniformLocation(pointProgram, 'uDevicePixelRatio'),
|
||||||
|
cameraDir: gl.getUniformLocation(pointProgram, 'uCameraDir'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const gridVertices = buildSkyGridVertices();
|
||||||
|
const horizonVertices = buildSkyRingVertices(0, 4);
|
||||||
|
|
||||||
|
const gridBuffer = gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, gridBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, gridVertices, gl.STATIC_DRAW);
|
||||||
|
|
||||||
|
const horizonBuffer = gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, horizonBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, horizonVertices, gl.STATIC_DRAW);
|
||||||
|
|
||||||
|
const satPosBuffer = gl.createBuffer();
|
||||||
|
const satColorBuffer = gl.createBuffer();
|
||||||
|
const satSizeBuffer = gl.createBuffer();
|
||||||
|
const satUsedBuffer = gl.createBuffer();
|
||||||
|
|
||||||
|
let satCount = 0;
|
||||||
|
let satLabels = [];
|
||||||
|
let cssWidth = 0;
|
||||||
|
let cssHeight = 0;
|
||||||
|
let devicePixelRatio = 1;
|
||||||
|
let mvpMatrix = identityMat4();
|
||||||
|
let cameraDir = [0, 1, 0];
|
||||||
|
let yaw = 0.8;
|
||||||
|
let pitch = 0.6;
|
||||||
|
let distance = 2.7;
|
||||||
|
let rafId = null;
|
||||||
|
let destroyed = false;
|
||||||
|
let activePointerId = null;
|
||||||
|
let lastPointerX = 0;
|
||||||
|
let lastPointerY = 0;
|
||||||
|
|
||||||
|
const resizeObserver = (typeof ResizeObserver !== 'undefined')
|
||||||
|
? new ResizeObserver(() => {
|
||||||
|
requestRender();
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
if (resizeObserver) resizeObserver.observe(canvas);
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerdown', onPointerDown);
|
||||||
|
canvas.addEventListener('pointermove', onPointerMove);
|
||||||
|
canvas.addEventListener('pointerup', onPointerUp);
|
||||||
|
canvas.addEventListener('pointercancel', onPointerUp);
|
||||||
|
canvas.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
|
||||||
|
requestRender();
|
||||||
|
|
||||||
|
function onPointerDown(evt) {
|
||||||
|
activePointerId = evt.pointerId;
|
||||||
|
lastPointerX = evt.clientX;
|
||||||
|
lastPointerY = evt.clientY;
|
||||||
|
if (canvas.setPointerCapture) canvas.setPointerCapture(evt.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(evt) {
|
||||||
|
if (activePointerId == null || evt.pointerId !== activePointerId) return;
|
||||||
|
|
||||||
|
const dx = evt.clientX - lastPointerX;
|
||||||
|
const dy = evt.clientY - lastPointerY;
|
||||||
|
lastPointerX = evt.clientX;
|
||||||
|
lastPointerY = evt.clientY;
|
||||||
|
|
||||||
|
yaw += dx * 0.01;
|
||||||
|
pitch += dy * 0.01;
|
||||||
|
pitch = Math.max(0.1, Math.min(1.45, pitch));
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(evt) {
|
||||||
|
if (activePointerId == null || evt.pointerId !== activePointerId) return;
|
||||||
|
if (canvas.releasePointerCapture) {
|
||||||
|
try {
|
||||||
|
canvas.releasePointerCapture(evt.pointerId);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
activePointerId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWheel(evt) {
|
||||||
|
evt.preventDefault();
|
||||||
|
distance += evt.deltaY * 0.002;
|
||||||
|
distance = Math.max(2.0, Math.min(5.0, distance));
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSatellites(satellites) {
|
||||||
|
const positions = [];
|
||||||
|
const colors = [];
|
||||||
|
const sizes = [];
|
||||||
|
const usedFlags = [];
|
||||||
|
const labels = [];
|
||||||
|
|
||||||
|
(satellites || []).forEach(sat => {
|
||||||
|
if (sat.elevation == null || sat.azimuth == null) return;
|
||||||
|
|
||||||
|
const xyz = skyToCartesian(sat.azimuth, sat.elevation);
|
||||||
|
const hex = CONST_COLORS[sat.constellation] || CONST_COLORS.GPS;
|
||||||
|
const rgb = hexToRgb01(hex);
|
||||||
|
|
||||||
|
positions.push(xyz[0], xyz[1], xyz[2]);
|
||||||
|
colors.push(rgb[0], rgb[1], rgb[2], sat.used ? 1 : 0.85);
|
||||||
|
sizes.push(sat.used ? 8 : 7);
|
||||||
|
usedFlags.push(sat.used ? 1 : 0);
|
||||||
|
|
||||||
|
labels.push({
|
||||||
|
text: String(sat.prn),
|
||||||
|
point: xyz,
|
||||||
|
color: hex,
|
||||||
|
used: !!sat.used,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
satLabels = labels;
|
||||||
|
satCount = positions.length / 3;
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, satPosBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.DYNAMIC_DRAW);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, satColorBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(colors), gl.DYNAMIC_DRAW);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, satSizeBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(sizes), gl.DYNAMIC_DRAW);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, satUsedBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(usedFlags), gl.DYNAMIC_DRAW);
|
||||||
|
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestRender() {
|
||||||
|
if (destroyed || rafId != null) return;
|
||||||
|
rafId = requestAnimationFrame(render);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
rafId = null;
|
||||||
|
if (destroyed) return;
|
||||||
|
|
||||||
|
resizeCanvas();
|
||||||
|
updateCameraMatrices();
|
||||||
|
|
||||||
|
const palette = getThemePalette();
|
||||||
|
|
||||||
|
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||||
|
gl.clearColor(palette.bg[0], palette.bg[1], palette.bg[2], 1);
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||||
|
|
||||||
|
gl.enable(gl.DEPTH_TEST);
|
||||||
|
gl.depthFunc(gl.LEQUAL);
|
||||||
|
gl.enable(gl.BLEND);
|
||||||
|
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||||
|
|
||||||
|
gl.useProgram(lineProgram);
|
||||||
|
gl.uniformMatrix4fv(lineLoc.mvp, false, mvpMatrix);
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, gridBuffer);
|
||||||
|
gl.enableVertexAttribArray(lineLoc.position);
|
||||||
|
gl.vertexAttribPointer(lineLoc.position, 3, gl.FLOAT, false, 0, 0);
|
||||||
|
gl.uniform4fv(lineLoc.color, palette.grid);
|
||||||
|
gl.drawArrays(gl.LINES, 0, gridVertices.length / 3);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, horizonBuffer);
|
||||||
|
gl.vertexAttribPointer(lineLoc.position, 3, gl.FLOAT, false, 0, 0);
|
||||||
|
gl.uniform4fv(lineLoc.color, palette.horizon);
|
||||||
|
gl.drawArrays(gl.LINES, 0, horizonVertices.length / 3);
|
||||||
|
|
||||||
|
if (satCount > 0) {
|
||||||
|
gl.useProgram(pointProgram);
|
||||||
|
gl.uniformMatrix4fv(pointLoc.mvp, false, mvpMatrix);
|
||||||
|
gl.uniform1f(pointLoc.dpr, devicePixelRatio);
|
||||||
|
gl.uniform3fv(pointLoc.cameraDir, new Float32Array(cameraDir));
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, satPosBuffer);
|
||||||
|
gl.enableVertexAttribArray(pointLoc.position);
|
||||||
|
gl.vertexAttribPointer(pointLoc.position, 3, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, satColorBuffer);
|
||||||
|
gl.enableVertexAttribArray(pointLoc.color);
|
||||||
|
gl.vertexAttribPointer(pointLoc.color, 4, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, satSizeBuffer);
|
||||||
|
gl.enableVertexAttribArray(pointLoc.size);
|
||||||
|
gl.vertexAttribPointer(pointLoc.size, 1, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, satUsedBuffer);
|
||||||
|
gl.enableVertexAttribArray(pointLoc.used);
|
||||||
|
gl.vertexAttribPointer(pointLoc.used, 1, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
gl.drawArrays(gl.POINTS, 0, satCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawOverlayLabels();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeCanvas() {
|
||||||
|
cssWidth = Math.max(1, Math.floor(canvas.clientWidth || 400));
|
||||||
|
cssHeight = Math.max(1, Math.floor(canvas.clientHeight || 400));
|
||||||
|
devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
|
||||||
|
const renderWidth = Math.floor(cssWidth * devicePixelRatio);
|
||||||
|
const renderHeight = Math.floor(cssHeight * devicePixelRatio);
|
||||||
|
if (canvas.width !== renderWidth || canvas.height !== renderHeight) {
|
||||||
|
canvas.width = renderWidth;
|
||||||
|
canvas.height = renderHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCameraMatrices() {
|
||||||
|
const cosPitch = Math.cos(pitch);
|
||||||
|
const eye = [
|
||||||
|
distance * Math.sin(yaw) * cosPitch,
|
||||||
|
distance * Math.sin(pitch),
|
||||||
|
distance * Math.cos(yaw) * cosPitch,
|
||||||
|
];
|
||||||
|
|
||||||
|
const eyeLen = Math.hypot(eye[0], eye[1], eye[2]) || 1;
|
||||||
|
cameraDir = [eye[0] / eyeLen, eye[1] / eyeLen, eye[2] / eyeLen];
|
||||||
|
|
||||||
|
const view = mat4LookAt(eye, [0, 0, 0], [0, 1, 0]);
|
||||||
|
const proj = mat4Perspective(degToRad(48), Math.max(cssWidth / cssHeight, 0.01), 0.1, 20);
|
||||||
|
mvpMatrix = mat4Multiply(proj, view);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawOverlayLabels() {
|
||||||
|
if (!overlay) return;
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
const cardinals = [
|
||||||
|
{ text: 'N', point: [0, 0, 1] },
|
||||||
|
{ text: 'E', point: [1, 0, 0] },
|
||||||
|
{ text: 'S', point: [0, 0, -1] },
|
||||||
|
{ text: 'W', point: [-1, 0, 0] },
|
||||||
|
{ text: 'Z', point: [0, 1, 0] },
|
||||||
|
];
|
||||||
|
|
||||||
|
cardinals.forEach(entry => {
|
||||||
|
addLabel(fragment, entry.text, entry.point, 'gps-sky-label gps-sky-label-cardinal');
|
||||||
|
});
|
||||||
|
|
||||||
|
satLabels.forEach(sat => {
|
||||||
|
const cls = 'gps-sky-label gps-sky-label-sat' + (sat.used ? '' : ' unused');
|
||||||
|
addLabel(fragment, sat.text, sat.point, cls, sat.color);
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.replaceChildren(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLabel(fragment, text, point, className, color) {
|
||||||
|
const facing = point[0] * cameraDir[0] + point[1] * cameraDir[1] + point[2] * cameraDir[2];
|
||||||
|
if (facing <= 0.02) return;
|
||||||
|
|
||||||
|
const projected = projectPoint(point, mvpMatrix, cssWidth, cssHeight);
|
||||||
|
if (!projected) return;
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = className;
|
||||||
|
label.textContent = text;
|
||||||
|
label.style.left = projected.x.toFixed(1) + 'px';
|
||||||
|
label.style.top = projected.y.toFixed(1) + 'px';
|
||||||
|
if (color) label.style.color = color;
|
||||||
|
fragment.appendChild(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThemePalette() {
|
||||||
|
const cs = getComputedStyle(document.documentElement);
|
||||||
|
const bg = parseCssColor(cs.getPropertyValue('--bg-card').trim(), '#0d1117');
|
||||||
|
const grid = parseCssColor(cs.getPropertyValue('--border-color').trim(), '#3a4254');
|
||||||
|
const accent = parseCssColor(cs.getPropertyValue('--accent-cyan').trim(), '#4aa3ff');
|
||||||
|
|
||||||
|
return {
|
||||||
|
bg: bg,
|
||||||
|
grid: [grid[0], grid[1], grid[2], 0.42],
|
||||||
|
horizon: [accent[0], accent[1], accent[2], 0.56],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
destroyed = true;
|
||||||
|
if (rafId != null) cancelAnimationFrame(rafId);
|
||||||
|
canvas.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
canvas.removeEventListener('pointermove', onPointerMove);
|
||||||
|
canvas.removeEventListener('pointerup', onPointerUp);
|
||||||
|
canvas.removeEventListener('pointercancel', onPointerUp);
|
||||||
|
canvas.removeEventListener('wheel', onWheel);
|
||||||
|
if (resizeObserver) {
|
||||||
|
try {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (overlay) overlay.replaceChildren();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
setSatellites: setSatellites,
|
||||||
|
requestRender: requestRender,
|
||||||
|
destroy: destroy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSkyGridVertices() {
|
||||||
|
const vertices = [];
|
||||||
|
|
||||||
|
[15, 30, 45, 60, 75].forEach(el => {
|
||||||
|
appendLineStrip(vertices, buildRingPoints(el, 6));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let az = 0; az < 360; az += 30) {
|
||||||
|
appendLineStrip(vertices, buildMeridianPoints(az, 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Float32Array(vertices);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSkyRingVertices(elevation, stepAz) {
|
||||||
|
const vertices = [];
|
||||||
|
appendLineStrip(vertices, buildRingPoints(elevation, stepAz));
|
||||||
|
return new Float32Array(vertices);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRingPoints(elevation, stepAz) {
|
||||||
|
const points = [];
|
||||||
|
for (let az = 0; az <= 360; az += stepAz) {
|
||||||
|
points.push(skyToCartesian(az, elevation));
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMeridianPoints(azimuth, stepEl) {
|
||||||
|
const points = [];
|
||||||
|
for (let el = 0; el <= 90; el += stepEl) {
|
||||||
|
points.push(skyToCartesian(azimuth, el));
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLineStrip(target, points) {
|
||||||
|
for (let i = 1; i < points.length; i += 1) {
|
||||||
|
const a = points[i - 1];
|
||||||
|
const b = points[i];
|
||||||
|
target.push(a[0], a[1], a[2], b[0], b[1], b[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function skyToCartesian(azimuthDeg, elevationDeg) {
|
||||||
|
const az = degToRad(azimuthDeg);
|
||||||
|
const el = degToRad(elevationDeg);
|
||||||
|
const cosEl = Math.cos(el);
|
||||||
|
return [
|
||||||
|
cosEl * Math.sin(az),
|
||||||
|
Math.sin(el),
|
||||||
|
cosEl * Math.cos(az),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function degToRad(deg) {
|
||||||
|
return deg * Math.PI / 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProgram(gl, vertexSource, fragmentSource) {
|
||||||
|
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, vertexSource);
|
||||||
|
const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
|
||||||
|
if (!vertexShader || !fragmentShader) return null;
|
||||||
|
|
||||||
|
const program = gl.createProgram();
|
||||||
|
gl.attachShader(program, vertexShader);
|
||||||
|
gl.attachShader(program, fragmentShader);
|
||||||
|
gl.linkProgram(program);
|
||||||
|
|
||||||
|
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||||
|
console.warn('WebGL program link failed:', gl.getProgramInfoLog(program));
|
||||||
|
gl.deleteProgram(program);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileShader(gl, type, source) {
|
||||||
|
const shader = gl.createShader(type);
|
||||||
|
gl.shaderSource(shader, source);
|
||||||
|
gl.compileShader(shader);
|
||||||
|
|
||||||
|
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||||
|
console.warn('WebGL shader compile failed:', gl.getShaderInfoLog(shader));
|
||||||
|
gl.deleteShader(shader);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return shader;
|
||||||
|
}
|
||||||
|
|
||||||
|
function identityMat4() {
|
||||||
|
return new Float32Array([
|
||||||
|
1, 0, 0, 0,
|
||||||
|
0, 1, 0, 0,
|
||||||
|
0, 0, 1, 0,
|
||||||
|
0, 0, 0, 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mat4Perspective(fovy, aspect, near, far) {
|
||||||
|
const f = 1 / Math.tan(fovy / 2);
|
||||||
|
const nf = 1 / (near - far);
|
||||||
|
|
||||||
|
return new Float32Array([
|
||||||
|
f / aspect, 0, 0, 0,
|
||||||
|
0, f, 0, 0,
|
||||||
|
0, 0, (far + near) * nf, -1,
|
||||||
|
0, 0, (2 * far * near) * nf, 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mat4LookAt(eye, center, up) {
|
||||||
|
const zx = eye[0] - center[0];
|
||||||
|
const zy = eye[1] - center[1];
|
||||||
|
const zz = eye[2] - center[2];
|
||||||
|
const zLen = Math.hypot(zx, zy, zz) || 1;
|
||||||
|
const znx = zx / zLen;
|
||||||
|
const zny = zy / zLen;
|
||||||
|
const znz = zz / zLen;
|
||||||
|
|
||||||
|
const xx = up[1] * znz - up[2] * zny;
|
||||||
|
const xy = up[2] * znx - up[0] * znz;
|
||||||
|
const xz = up[0] * zny - up[1] * znx;
|
||||||
|
const xLen = Math.hypot(xx, xy, xz) || 1;
|
||||||
|
const xnx = xx / xLen;
|
||||||
|
const xny = xy / xLen;
|
||||||
|
const xnz = xz / xLen;
|
||||||
|
|
||||||
|
const ynx = zny * xnz - znz * xny;
|
||||||
|
const yny = znz * xnx - znx * xnz;
|
||||||
|
const ynz = znx * xny - zny * xnx;
|
||||||
|
|
||||||
|
return new Float32Array([
|
||||||
|
xnx, ynx, znx, 0,
|
||||||
|
xny, yny, zny, 0,
|
||||||
|
xnz, ynz, znz, 0,
|
||||||
|
-(xnx * eye[0] + xny * eye[1] + xnz * eye[2]),
|
||||||
|
-(ynx * eye[0] + yny * eye[1] + ynz * eye[2]),
|
||||||
|
-(znx * eye[0] + zny * eye[1] + znz * eye[2]),
|
||||||
|
1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mat4Multiply(a, b) {
|
||||||
|
const out = new Float32Array(16);
|
||||||
|
for (let col = 0; col < 4; col += 1) {
|
||||||
|
for (let row = 0; row < 4; row += 1) {
|
||||||
|
out[col * 4 + row] =
|
||||||
|
a[row] * b[col * 4] +
|
||||||
|
a[4 + row] * b[col * 4 + 1] +
|
||||||
|
a[8 + row] * b[col * 4 + 2] +
|
||||||
|
a[12 + row] * b[col * 4 + 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectPoint(point, matrix, width, height) {
|
||||||
|
const x = point[0];
|
||||||
|
const y = point[1];
|
||||||
|
const z = point[2];
|
||||||
|
|
||||||
|
const clipX = matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12];
|
||||||
|
const clipY = matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13];
|
||||||
|
const clipW = matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15];
|
||||||
|
if (clipW <= 0.0001) return null;
|
||||||
|
|
||||||
|
const ndcX = clipX / clipW;
|
||||||
|
const ndcY = clipY / clipW;
|
||||||
|
if (Math.abs(ndcX) > 1.2 || Math.abs(ndcY) > 1.2) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: (ndcX * 0.5 + 0.5) * width,
|
||||||
|
y: (1 - (ndcY * 0.5 + 0.5)) * height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCssColor(raw, fallbackHex) {
|
||||||
|
const value = (raw || '').trim();
|
||||||
|
|
||||||
|
if (value.startsWith('#')) {
|
||||||
|
return hexToRgb01(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = value.match(/rgba?\(([^)]+)\)/i);
|
||||||
|
if (match) {
|
||||||
|
const parts = match[1].split(',').map(part => parseFloat(part.trim()));
|
||||||
|
if (parts.length >= 3 && parts.every(n => Number.isFinite(n))) {
|
||||||
|
return [parts[0] / 255, parts[1] / 255, parts[2] / 255];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hexToRgb01(fallbackHex || '#0d1117');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb01(hex) {
|
||||||
|
let clean = (hex || '').trim().replace('#', '');
|
||||||
|
if (clean.length === 3) {
|
||||||
|
clean = clean.split('').map(ch => ch + ch).join('');
|
||||||
|
}
|
||||||
|
if (!/^[0-9a-fA-F]{6}$/.test(clean)) {
|
||||||
|
return [0, 0, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = parseInt(clean, 16);
|
||||||
|
return [
|
||||||
|
((num >> 16) & 255) / 255,
|
||||||
|
((num >> 8) & 255) / 255,
|
||||||
|
(num & 255) / 255,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// ========================
|
// ========================
|
||||||
// Signal Strength Bars
|
// Signal Strength Bars
|
||||||
// ========================
|
// ========================
|
||||||
@@ -442,6 +1076,15 @@ const GPS = (function() {
|
|||||||
function destroy() {
|
function destroy() {
|
||||||
unsubscribeFromStream();
|
unsubscribeFromStream();
|
||||||
stopSkyPolling();
|
stopSkyPolling();
|
||||||
|
if (themeObserver) {
|
||||||
|
themeObserver.disconnect();
|
||||||
|
themeObserver = null;
|
||||||
|
}
|
||||||
|
if (skyRenderer) {
|
||||||
|
skyRenderer.destroy();
|
||||||
|
skyRenderer = null;
|
||||||
|
}
|
||||||
|
skyRendererInitAttempted = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -269,12 +269,10 @@ const SpyStations = (function() {
|
|||||||
*/
|
*/
|
||||||
function tuneToStation(stationId, freqKhz) {
|
function tuneToStation(stationId, freqKhz) {
|
||||||
const freqMhz = freqKhz / 1000;
|
const freqMhz = freqKhz / 1000;
|
||||||
sessionStorage.setItem('tuneFrequency', freqMhz.toString());
|
|
||||||
|
|
||||||
// Find the station and determine mode
|
// Find the station and determine mode
|
||||||
const station = stations.find(s => s.id === stationId);
|
const station = stations.find(s => s.id === stationId);
|
||||||
const tuneMode = station ? getModeFromStation(station.mode) : 'usb';
|
const tuneMode = station ? getModeFromStation(station.mode) : 'usb';
|
||||||
sessionStorage.setItem('tuneMode', tuneMode);
|
|
||||||
|
|
||||||
const stationName = station ? station.name : 'Station';
|
const stationName = station ? station.name : 'Station';
|
||||||
|
|
||||||
@@ -282,12 +280,18 @@ const SpyStations = (function() {
|
|||||||
showNotification('Tuning to ' + stationName, formatFrequency(freqKhz) + ' (' + tuneMode.toUpperCase() + ')');
|
showNotification('Tuning to ' + stationName, formatFrequency(freqKhz) + ' (' + tuneMode.toUpperCase() + ')');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch to listening post mode
|
// Switch to spectrum waterfall mode and tune after mode init.
|
||||||
if (typeof selectMode === 'function') {
|
if (typeof switchMode === 'function') {
|
||||||
selectMode('listening');
|
switchMode('waterfall');
|
||||||
} else if (typeof switchMode === 'function') {
|
} else if (typeof selectMode === 'function') {
|
||||||
switchMode('listening');
|
selectMode('waterfall');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (typeof Waterfall !== 'undefined' && typeof Waterfall.quickTune === 'function') {
|
||||||
|
Waterfall.quickTune(freqMhz, tuneMode);
|
||||||
|
}
|
||||||
|
}, 220);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -305,7 +309,7 @@ const SpyStations = (function() {
|
|||||||
* Check if we arrived from another page with a tune request
|
* Check if we arrived from another page with a tune request
|
||||||
*/
|
*/
|
||||||
function checkTuneFrequency() {
|
function checkTuneFrequency() {
|
||||||
// This is for the listening post to check - spy stations sets, listening post reads
|
// Reserved for cross-mode tune handoff behavior.
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -445,7 +449,7 @@ const SpyStations = (function() {
|
|||||||
<div class="signal-details-section">
|
<div class="signal-details-section">
|
||||||
<div class="signal-details-title">How to Listen</div>
|
<div class="signal-details-title">How to Listen</div>
|
||||||
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
|
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
|
||||||
Click "Tune In" on any station to open the Listening Post with the frequency pre-configured.
|
Click "Tune In" on any station to open Spectrum Waterfall with the frequency pre-configured.
|
||||||
Most number stations use USB (Upper Sideband) mode. You'll need an SDR capable of receiving
|
Most number stations use USB (Upper Sideband) mode. You'll need an SDR capable of receiving
|
||||||
HF frequencies (typically 3-30 MHz) and an appropriate antenna.
|
HF frequencies (typically 3-30 MHz) and an appropriate antenna.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+141
-23
@@ -15,13 +15,21 @@ const SSTVGeneral = (function() {
|
|||||||
let sstvGeneralScopeCtx = null;
|
let sstvGeneralScopeCtx = null;
|
||||||
let sstvGeneralScopeAnim = null;
|
let sstvGeneralScopeAnim = null;
|
||||||
let sstvGeneralScopeHistory = [];
|
let sstvGeneralScopeHistory = [];
|
||||||
|
let sstvGeneralScopeWaveBuffer = [];
|
||||||
|
let sstvGeneralScopeDisplayWave = [];
|
||||||
const SSTV_GENERAL_SCOPE_LEN = 200;
|
const SSTV_GENERAL_SCOPE_LEN = 200;
|
||||||
|
const SSTV_GENERAL_SCOPE_WAVE_BUFFER_LEN = 2048;
|
||||||
|
const SSTV_GENERAL_SCOPE_WAVE_INPUT_SMOOTH_ALPHA = 0.55;
|
||||||
|
const SSTV_GENERAL_SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA = 0.22;
|
||||||
|
const SSTV_GENERAL_SCOPE_WAVE_IDLE_DECAY = 0.96;
|
||||||
let sstvGeneralScopeRms = 0;
|
let sstvGeneralScopeRms = 0;
|
||||||
let sstvGeneralScopePeak = 0;
|
let sstvGeneralScopePeak = 0;
|
||||||
let sstvGeneralScopeTargetRms = 0;
|
let sstvGeneralScopeTargetRms = 0;
|
||||||
let sstvGeneralScopeTargetPeak = 0;
|
let sstvGeneralScopeTargetPeak = 0;
|
||||||
let sstvGeneralScopeMsgBurst = 0;
|
let sstvGeneralScopeMsgBurst = 0;
|
||||||
let sstvGeneralScopeTone = null;
|
let sstvGeneralScopeTone = null;
|
||||||
|
let sstvGeneralScopeLastWaveAt = 0;
|
||||||
|
let sstvGeneralScopeLastInputSample = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the SSTV General mode
|
* Initialize the SSTV General mode
|
||||||
@@ -205,20 +213,64 @@ const SSTVGeneral = (function() {
|
|||||||
/**
|
/**
|
||||||
* Initialize signal scope canvas
|
* Initialize signal scope canvas
|
||||||
*/
|
*/
|
||||||
|
function resizeSstvGeneralScopeCanvas(canvas) {
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const width = Math.max(1, Math.floor(rect.width * dpr));
|
||||||
|
const height = Math.max(1, Math.floor(rect.height * dpr));
|
||||||
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySstvGeneralScopeData(scopeData) {
|
||||||
|
if (!scopeData || typeof scopeData !== 'object') return;
|
||||||
|
|
||||||
|
sstvGeneralScopeTargetRms = Number(scopeData.rms) || 0;
|
||||||
|
sstvGeneralScopeTargetPeak = Number(scopeData.peak) || 0;
|
||||||
|
if (scopeData.tone !== undefined) {
|
||||||
|
sstvGeneralScopeTone = scopeData.tone;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(scopeData.waveform) && scopeData.waveform.length) {
|
||||||
|
for (const packedSample of scopeData.waveform) {
|
||||||
|
const sample = Number(packedSample);
|
||||||
|
if (!Number.isFinite(sample)) continue;
|
||||||
|
const normalized = Math.max(-127, Math.min(127, sample)) / 127;
|
||||||
|
sstvGeneralScopeLastInputSample += (normalized - sstvGeneralScopeLastInputSample) * SSTV_GENERAL_SCOPE_WAVE_INPUT_SMOOTH_ALPHA;
|
||||||
|
sstvGeneralScopeWaveBuffer.push(sstvGeneralScopeLastInputSample);
|
||||||
|
}
|
||||||
|
if (sstvGeneralScopeWaveBuffer.length > SSTV_GENERAL_SCOPE_WAVE_BUFFER_LEN) {
|
||||||
|
sstvGeneralScopeWaveBuffer.splice(0, sstvGeneralScopeWaveBuffer.length - SSTV_GENERAL_SCOPE_WAVE_BUFFER_LEN);
|
||||||
|
}
|
||||||
|
sstvGeneralScopeLastWaveAt = performance.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initSstvGeneralScope() {
|
function initSstvGeneralScope() {
|
||||||
const canvas = document.getElementById('sstvGeneralScopeCanvas');
|
const canvas = document.getElementById('sstvGeneralScopeCanvas');
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
canvas.width = rect.width * (window.devicePixelRatio || 1);
|
if (sstvGeneralScopeAnim) {
|
||||||
canvas.height = rect.height * (window.devicePixelRatio || 1);
|
cancelAnimationFrame(sstvGeneralScopeAnim);
|
||||||
|
sstvGeneralScopeAnim = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeSstvGeneralScopeCanvas(canvas);
|
||||||
sstvGeneralScopeCtx = canvas.getContext('2d');
|
sstvGeneralScopeCtx = canvas.getContext('2d');
|
||||||
sstvGeneralScopeHistory = new Array(SSTV_GENERAL_SCOPE_LEN).fill(0);
|
sstvGeneralScopeHistory = new Array(SSTV_GENERAL_SCOPE_LEN).fill(0);
|
||||||
|
sstvGeneralScopeWaveBuffer = [];
|
||||||
|
sstvGeneralScopeDisplayWave = [];
|
||||||
sstvGeneralScopeRms = 0;
|
sstvGeneralScopeRms = 0;
|
||||||
sstvGeneralScopePeak = 0;
|
sstvGeneralScopePeak = 0;
|
||||||
sstvGeneralScopeTargetRms = 0;
|
sstvGeneralScopeTargetRms = 0;
|
||||||
sstvGeneralScopeTargetPeak = 0;
|
sstvGeneralScopeTargetPeak = 0;
|
||||||
sstvGeneralScopeMsgBurst = 0;
|
sstvGeneralScopeMsgBurst = 0;
|
||||||
sstvGeneralScopeTone = null;
|
sstvGeneralScopeTone = null;
|
||||||
|
sstvGeneralScopeLastWaveAt = 0;
|
||||||
|
sstvGeneralScopeLastInputSample = 0;
|
||||||
drawSstvGeneralScope();
|
drawSstvGeneralScope();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,12 +280,14 @@ const SSTVGeneral = (function() {
|
|||||||
function drawSstvGeneralScope() {
|
function drawSstvGeneralScope() {
|
||||||
const ctx = sstvGeneralScopeCtx;
|
const ctx = sstvGeneralScopeCtx;
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
|
resizeSstvGeneralScopeCanvas(ctx.canvas);
|
||||||
const W = ctx.canvas.width;
|
const W = ctx.canvas.width;
|
||||||
const H = ctx.canvas.height;
|
const H = ctx.canvas.height;
|
||||||
const midY = H / 2;
|
const midY = H / 2;
|
||||||
|
|
||||||
// Phosphor persistence
|
// Phosphor persistence
|
||||||
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
|
ctx.fillStyle = 'rgba(5, 5, 16, 0.26)';
|
||||||
ctx.fillRect(0, 0, W, H);
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
// Smooth towards target
|
// Smooth towards target
|
||||||
@@ -256,32 +310,84 @@ const SSTVGeneral = (function() {
|
|||||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Waveform
|
// Envelope
|
||||||
const stepX = W / (SSTV_GENERAL_SCOPE_LEN - 1);
|
const envStepX = W / (SSTV_GENERAL_SCOPE_LEN - 1);
|
||||||
ctx.strokeStyle = '#c080ff';
|
ctx.strokeStyle = 'rgba(168, 110, 255, 0.45)';
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1;
|
||||||
ctx.shadowColor = '#c080ff';
|
|
||||||
ctx.shadowBlur = 4;
|
|
||||||
|
|
||||||
// Upper half
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
||||||
const x = i * stepX;
|
const x = i * envStepX;
|
||||||
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
|
const amp = sstvGeneralScopeHistory[i] * midY * 0.85;
|
||||||
const y = midY - amp;
|
const y = midY - amp;
|
||||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Lower half (mirror)
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
||||||
const x = i * stepX;
|
const x = i * envStepX;
|
||||||
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
|
const amp = sstvGeneralScopeHistory[i] * midY * 0.85;
|
||||||
const y = midY + amp;
|
const y = midY + amp;
|
||||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Actual waveform trace
|
||||||
|
const waveformPointCount = Math.min(Math.max(120, Math.floor(W / 3.2)), 420);
|
||||||
|
if (sstvGeneralScopeWaveBuffer.length > 1) {
|
||||||
|
const waveIsFresh = (performance.now() - sstvGeneralScopeLastWaveAt) < 1000;
|
||||||
|
const sourceLen = sstvGeneralScopeWaveBuffer.length;
|
||||||
|
const sourceWindow = Math.min(sourceLen, 1536);
|
||||||
|
const sourceStart = sourceLen - sourceWindow;
|
||||||
|
|
||||||
|
if (sstvGeneralScopeDisplayWave.length !== waveformPointCount) {
|
||||||
|
sstvGeneralScopeDisplayWave = new Array(waveformPointCount).fill(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < waveformPointCount; i++) {
|
||||||
|
const a = sourceStart + Math.floor((i / waveformPointCount) * sourceWindow);
|
||||||
|
const b = sourceStart + Math.floor(((i + 1) / waveformPointCount) * sourceWindow);
|
||||||
|
const start = Math.max(sourceStart, Math.min(sourceLen - 1, a));
|
||||||
|
const end = Math.max(start + 1, Math.min(sourceLen, b));
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (let j = start; j < end; j++) {
|
||||||
|
sum += sstvGeneralScopeWaveBuffer[j];
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
const targetSample = count > 0 ? (sum / count) : 0;
|
||||||
|
sstvGeneralScopeDisplayWave[i] += (targetSample - sstvGeneralScopeDisplayWave[i]) * SSTV_GENERAL_SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.strokeStyle = waveIsFresh ? '#c080ff' : 'rgba(192, 128, 255, 0.45)';
|
||||||
|
ctx.lineWidth = 1.7;
|
||||||
|
ctx.shadowColor = '#c080ff';
|
||||||
|
ctx.shadowBlur = waveIsFresh ? 6 : 2;
|
||||||
|
|
||||||
|
const stepX = waveformPointCount > 1 ? (W / (waveformPointCount - 1)) : W;
|
||||||
|
ctx.beginPath();
|
||||||
|
const firstY = midY - (sstvGeneralScopeDisplayWave[0] * midY * 0.9);
|
||||||
|
ctx.moveTo(0, firstY);
|
||||||
|
for (let i = 1; i < waveformPointCount - 1; i++) {
|
||||||
|
const x = i * stepX;
|
||||||
|
const y = midY - (sstvGeneralScopeDisplayWave[i] * midY * 0.9);
|
||||||
|
const nx = (i + 1) * stepX;
|
||||||
|
const ny = midY - (sstvGeneralScopeDisplayWave[i + 1] * midY * 0.9);
|
||||||
|
const cx = (x + nx) / 2;
|
||||||
|
const cy = (y + ny) / 2;
|
||||||
|
ctx.quadraticCurveTo(x, y, cx, cy);
|
||||||
|
}
|
||||||
|
const lastX = (waveformPointCount - 1) * stepX;
|
||||||
|
const lastY = midY - (sstvGeneralScopeDisplayWave[waveformPointCount - 1] * midY * 0.9);
|
||||||
|
ctx.lineTo(lastX, lastY);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
if (!waveIsFresh) {
|
||||||
|
for (let i = 0; i < sstvGeneralScopeDisplayWave.length; i++) {
|
||||||
|
sstvGeneralScopeDisplayWave[i] *= SSTV_GENERAL_SCOPE_WAVE_IDLE_DECAY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
ctx.shadowBlur = 0;
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
// Peak indicator
|
// Peak indicator
|
||||||
@@ -317,8 +423,17 @@ const SSTVGeneral = (function() {
|
|||||||
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
|
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
|
||||||
}
|
}
|
||||||
if (statusLabel) {
|
if (statusLabel) {
|
||||||
if (sstvGeneralScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
|
const waveIsFresh = (performance.now() - sstvGeneralScopeLastWaveAt) < 1000;
|
||||||
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
|
if (sstvGeneralScopeRms > 900 && waveIsFresh) {
|
||||||
|
statusLabel.textContent = 'DEMODULATING';
|
||||||
|
statusLabel.style.color = '#c080ff';
|
||||||
|
} else if (sstvGeneralScopeRms > 500) {
|
||||||
|
statusLabel.textContent = 'CARRIER';
|
||||||
|
statusLabel.style.color = '#e0b8ff';
|
||||||
|
} else {
|
||||||
|
statusLabel.textContent = 'QUIET';
|
||||||
|
statusLabel.style.color = '#555';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sstvGeneralScopeAnim = requestAnimationFrame(drawSstvGeneralScope);
|
sstvGeneralScopeAnim = requestAnimationFrame(drawSstvGeneralScope);
|
||||||
@@ -330,6 +445,11 @@ const SSTVGeneral = (function() {
|
|||||||
function stopSstvGeneralScope() {
|
function stopSstvGeneralScope() {
|
||||||
if (sstvGeneralScopeAnim) { cancelAnimationFrame(sstvGeneralScopeAnim); sstvGeneralScopeAnim = null; }
|
if (sstvGeneralScopeAnim) { cancelAnimationFrame(sstvGeneralScopeAnim); sstvGeneralScopeAnim = null; }
|
||||||
sstvGeneralScopeCtx = null;
|
sstvGeneralScopeCtx = null;
|
||||||
|
sstvGeneralScopeWaveBuffer = [];
|
||||||
|
sstvGeneralScopeDisplayWave = [];
|
||||||
|
sstvGeneralScopeHistory = [];
|
||||||
|
sstvGeneralScopeLastWaveAt = 0;
|
||||||
|
sstvGeneralScopeLastInputSample = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -353,9 +473,7 @@ const SSTVGeneral = (function() {
|
|||||||
if (data.type === 'sstv_progress') {
|
if (data.type === 'sstv_progress') {
|
||||||
handleProgress(data);
|
handleProgress(data);
|
||||||
} else if (data.type === 'sstv_scope') {
|
} else if (data.type === 'sstv_scope') {
|
||||||
sstvGeneralScopeTargetRms = data.rms;
|
applySstvGeneralScopeData(data);
|
||||||
sstvGeneralScopeTargetPeak = data.peak;
|
|
||||||
if (data.tone !== undefined) sstvGeneralScopeTone = data.tone;
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse SSE message:', err);
|
console.error('Failed to parse SSE message:', err);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+442
-39
@@ -9,6 +9,20 @@ let websdrMarkers = [];
|
|||||||
let websdrReceivers = [];
|
let websdrReceivers = [];
|
||||||
let websdrInitialized = false;
|
let websdrInitialized = false;
|
||||||
let websdrSpyStationsLoaded = false;
|
let websdrSpyStationsLoaded = false;
|
||||||
|
let websdrMapType = null;
|
||||||
|
let websdrGlobe = null;
|
||||||
|
let websdrGlobePopup = null;
|
||||||
|
let websdrSelectedReceiverIndex = null;
|
||||||
|
let websdrGlobeScriptPromise = null;
|
||||||
|
let websdrResizeObserver = null;
|
||||||
|
let websdrResizeHooked = false;
|
||||||
|
let websdrGlobeFallbackNotified = false;
|
||||||
|
|
||||||
|
const WEBSDR_GLOBE_SCRIPT_URLS = [
|
||||||
|
'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js',
|
||||||
|
'https://cdn.jsdelivr.net/npm/globe.gl@2.33.1/dist/globe.gl.min.js',
|
||||||
|
];
|
||||||
|
const WEBSDR_GLOBE_TEXTURE_URL = '/static/images/globe/earth-dark.jpg';
|
||||||
|
|
||||||
// KiwiSDR audio state
|
// KiwiSDR audio state
|
||||||
let kiwiWebSocket = null;
|
let kiwiWebSocket = null;
|
||||||
@@ -29,54 +43,50 @@ const KIWI_SAMPLE_RATE = 12000;
|
|||||||
|
|
||||||
async function initWebSDR() {
|
async function initWebSDR() {
|
||||||
if (websdrInitialized) {
|
if (websdrInitialized) {
|
||||||
if (websdrMap) {
|
setTimeout(invalidateWebSDRViewport, 100);
|
||||||
setTimeout(() => websdrMap.invalidateSize(), 100);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapEl = document.getElementById('websdrMap');
|
const mapEl = document.getElementById('websdrMap');
|
||||||
if (!mapEl || typeof L === 'undefined') return;
|
if (!mapEl) return;
|
||||||
|
|
||||||
// Calculate minimum zoom so tiles fill the container vertically
|
const globeReady = await ensureWebsdrGlobeLibrary();
|
||||||
const mapHeight = mapEl.clientHeight || 500;
|
|
||||||
const minZoom = Math.ceil(Math.log2(mapHeight / 256));
|
|
||||||
|
|
||||||
websdrMap = L.map('websdrMap', {
|
// Wait for a paint frame so the browser computes layout after the
|
||||||
center: [20, 0],
|
// display:flex change in switchMode. Without this, Globe()(mapEl) can
|
||||||
zoom: Math.max(minZoom, 2),
|
// run before clientWidth/clientHeight are non-zero (especially when
|
||||||
minZoom: Math.max(minZoom, 2),
|
// scripts are served from cache and resolve before the first layout pass).
|
||||||
zoomControl: true,
|
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||||
maxBounds: [[-85, -360], [85, 360]],
|
|
||||||
maxBoundsViscosity: 1.0,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
|
// If the mode was switched away while scripts were loading, abort so
|
||||||
await Settings.init();
|
// websdrInitialized stays false and we retry cleanly next time.
|
||||||
Settings.createTileLayer().addTo(websdrMap);
|
if (!mapEl.clientWidth || !mapEl.clientHeight) return;
|
||||||
Settings.registerMap(websdrMap);
|
|
||||||
|
if (globeReady && initWebsdrGlobe(mapEl)) {
|
||||||
|
websdrMapType = 'globe';
|
||||||
|
} else if (typeof L !== 'undefined' && await initWebsdrLeaflet(mapEl)) {
|
||||||
|
websdrMapType = 'leaflet';
|
||||||
|
if (!websdrGlobeFallbackNotified && typeof showNotification === 'function') {
|
||||||
|
showNotification('WebSDR', '3D globe unavailable, using fallback map');
|
||||||
|
websdrGlobeFallbackNotified = true;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
console.error('[WEBSDR] Unable to initialize globe or map renderer');
|
||||||
attribution: '© OpenStreetMap contributors © CARTO',
|
return;
|
||||||
subdomains: 'abcd',
|
|
||||||
maxZoom: 19,
|
|
||||||
className: 'tile-layer-cyan',
|
|
||||||
}).addTo(websdrMap);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match background to tile ocean color so any remaining edge is seamless
|
|
||||||
mapEl.style.background = '#1a1d29';
|
|
||||||
|
|
||||||
websdrInitialized = true;
|
websdrInitialized = true;
|
||||||
|
|
||||||
if (!websdrSpyStationsLoaded) {
|
if (!websdrSpyStationsLoaded) {
|
||||||
loadSpyStationPresets();
|
loadSpyStationPresets();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupWebsdrResizeHandling(mapEl);
|
||||||
|
if (websdrReceivers.length > 0) {
|
||||||
|
plotReceiversOnMap(websdrReceivers);
|
||||||
|
}
|
||||||
[100, 300, 600, 1000].forEach(delay => {
|
[100, 300, 600, 1000].forEach(delay => {
|
||||||
setTimeout(() => {
|
setTimeout(invalidateWebSDRViewport, delay);
|
||||||
if (websdrMap) websdrMap.invalidateSize();
|
|
||||||
}, delay);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +104,8 @@ function searchReceivers(refresh) {
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
websdrReceivers = data.receivers || [];
|
websdrReceivers = data.receivers || [];
|
||||||
|
websdrSelectedReceiverIndex = null;
|
||||||
|
hideWebsdrGlobePopup();
|
||||||
renderReceiverList(websdrReceivers);
|
renderReceiverList(websdrReceivers);
|
||||||
plotReceiversOnMap(websdrReceivers);
|
plotReceiversOnMap(websdrReceivers);
|
||||||
|
|
||||||
@@ -107,6 +119,11 @@ function searchReceivers(refresh) {
|
|||||||
// ============== MAP ==============
|
// ============== MAP ==============
|
||||||
|
|
||||||
function plotReceiversOnMap(receivers) {
|
function plotReceiversOnMap(receivers) {
|
||||||
|
if (websdrMapType === 'globe' && websdrGlobe) {
|
||||||
|
plotReceiversOnGlobe(receivers);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!websdrMap) return;
|
if (!websdrMap) return;
|
||||||
|
|
||||||
websdrMarkers.forEach(m => websdrMap.removeLayer(m));
|
websdrMarkers.forEach(m => websdrMap.removeLayer(m));
|
||||||
@@ -144,6 +161,369 @@ function plotReceiversOnMap(receivers) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureWebsdrGlobeLibrary() {
|
||||||
|
if (typeof window.Globe === 'function') return true;
|
||||||
|
if (!isWebglSupported()) return false;
|
||||||
|
|
||||||
|
if (!websdrGlobeScriptPromise) {
|
||||||
|
websdrGlobeScriptPromise = WEBSDR_GLOBE_SCRIPT_URLS
|
||||||
|
.reduce(
|
||||||
|
(promise, src) => promise.then(() => loadWebsdrScript(src)),
|
||||||
|
Promise.resolve()
|
||||||
|
)
|
||||||
|
.then(() => typeof window.Globe === 'function')
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn('[WEBSDR] Failed to load globe scripts:', error);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = await websdrGlobeScriptPromise;
|
||||||
|
if (!loaded) {
|
||||||
|
websdrGlobeScriptPromise = null;
|
||||||
|
}
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadWebsdrScript(src) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const selector = `script[data-websdr-src="${src}"]`;
|
||||||
|
const existing = document.querySelector(selector);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.dataset.loaded === 'true') {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (existing.dataset.failed === 'true') {
|
||||||
|
existing.remove();
|
||||||
|
} else {
|
||||||
|
existing.addEventListener('load', () => resolve(), { once: true });
|
||||||
|
existing.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)), { once: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = src;
|
||||||
|
script.async = true;
|
||||||
|
script.crossOrigin = 'anonymous';
|
||||||
|
script.dataset.websdrSrc = src;
|
||||||
|
script.onload = () => {
|
||||||
|
script.dataset.loaded = 'true';
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
script.onerror = () => {
|
||||||
|
script.dataset.failed = 'true';
|
||||||
|
reject(new Error(`Failed to load ${src}`));
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWebglSupported() {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
return !!(canvas.getContext('webgl') || canvas.getContext('experimental-webgl'));
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initWebsdrGlobe(mapEl) {
|
||||||
|
if (typeof window.Globe !== 'function' || !isWebglSupported()) return false;
|
||||||
|
|
||||||
|
mapEl.innerHTML = '';
|
||||||
|
mapEl.style.background = 'radial-gradient(circle at 30% 20%, rgba(14, 42, 68, 0.9), rgba(4, 9, 16, 0.95) 58%, rgba(2, 4, 9, 0.98) 100%)';
|
||||||
|
mapEl.style.cursor = 'grab';
|
||||||
|
|
||||||
|
websdrGlobe = window.Globe()(mapEl)
|
||||||
|
.backgroundColor('rgba(0,0,0,0)')
|
||||||
|
.globeImageUrl(WEBSDR_GLOBE_TEXTURE_URL)
|
||||||
|
.showAtmosphere(true)
|
||||||
|
.atmosphereColor('#3bb9ff')
|
||||||
|
.atmosphereAltitude(0.17)
|
||||||
|
.pointRadius('radius')
|
||||||
|
.pointAltitude('altitude')
|
||||||
|
.pointColor('color')
|
||||||
|
.pointsTransitionDuration(250)
|
||||||
|
.pointLabel(point => point.label || '')
|
||||||
|
.onPointHover(point => {
|
||||||
|
mapEl.style.cursor = point ? 'pointer' : 'grab';
|
||||||
|
})
|
||||||
|
.onPointClick((point, event) => {
|
||||||
|
if (!point) return;
|
||||||
|
showWebsdrGlobePopup(point, event);
|
||||||
|
});
|
||||||
|
|
||||||
|
const controls = websdrGlobe.controls();
|
||||||
|
if (controls) {
|
||||||
|
controls.autoRotate = true;
|
||||||
|
controls.autoRotateSpeed = 0.25;
|
||||||
|
controls.enablePan = false;
|
||||||
|
controls.minDistance = 140;
|
||||||
|
controls.maxDistance = 380;
|
||||||
|
controls.rotateSpeed = 0.7;
|
||||||
|
controls.zoomSpeed = 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureWebsdrGlobePopup(mapEl);
|
||||||
|
resizeWebsdrGlobe();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initWebsdrLeaflet(mapEl) {
|
||||||
|
if (typeof L === 'undefined') return false;
|
||||||
|
|
||||||
|
mapEl.innerHTML = '';
|
||||||
|
const mapHeight = mapEl.clientHeight || 500;
|
||||||
|
const minZoom = Math.ceil(Math.log2(mapHeight / 256));
|
||||||
|
|
||||||
|
websdrMap = L.map('websdrMap', {
|
||||||
|
center: [20, 0],
|
||||||
|
zoom: Math.max(minZoom, 2),
|
||||||
|
minZoom: Math.max(minZoom, 2),
|
||||||
|
zoomControl: true,
|
||||||
|
maxBounds: [[-85, -360], [85, 360]],
|
||||||
|
maxBoundsViscosity: 1.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
|
||||||
|
await Settings.init();
|
||||||
|
Settings.createTileLayer().addTo(websdrMap);
|
||||||
|
Settings.registerMap(websdrMap);
|
||||||
|
} else {
|
||||||
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||||
|
attribution: '© OpenStreetMap contributors © CARTO',
|
||||||
|
subdomains: 'abcd',
|
||||||
|
maxZoom: 19,
|
||||||
|
className: 'tile-layer-cyan',
|
||||||
|
}).addTo(websdrMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
mapEl.style.background = '#1a1d29';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupWebsdrResizeHandling(mapEl) {
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
if (websdrResizeObserver) {
|
||||||
|
websdrResizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
websdrResizeObserver = new ResizeObserver(() => invalidateWebSDRViewport());
|
||||||
|
websdrResizeObserver.observe(mapEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!websdrResizeHooked) {
|
||||||
|
window.addEventListener('resize', invalidateWebSDRViewport);
|
||||||
|
window.addEventListener('orientationchange', () => setTimeout(invalidateWebSDRViewport, 120));
|
||||||
|
websdrResizeHooked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateWebSDRViewport() {
|
||||||
|
if (websdrMapType === 'globe') {
|
||||||
|
resizeWebsdrGlobe();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (websdrMap && typeof websdrMap.invalidateSize === 'function') {
|
||||||
|
websdrMap.invalidateSize({ pan: false, animate: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeWebsdrGlobe() {
|
||||||
|
if (!websdrGlobe) return;
|
||||||
|
const mapEl = document.getElementById('websdrMap');
|
||||||
|
if (!mapEl) return;
|
||||||
|
|
||||||
|
const width = mapEl.clientWidth;
|
||||||
|
const height = mapEl.clientHeight;
|
||||||
|
if (!width || !height) return;
|
||||||
|
|
||||||
|
websdrGlobe.width(width);
|
||||||
|
websdrGlobe.height(height);
|
||||||
|
}
|
||||||
|
|
||||||
|
function plotReceiversOnGlobe(receivers) {
|
||||||
|
if (!websdrGlobe) return;
|
||||||
|
|
||||||
|
const points = [];
|
||||||
|
receivers.forEach((rx, idx) => {
|
||||||
|
const lat = Number(rx.lat);
|
||||||
|
const lon = Number(rx.lon);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
|
||||||
|
|
||||||
|
const selected = idx === websdrSelectedReceiverIndex;
|
||||||
|
points.push({
|
||||||
|
lat: lat,
|
||||||
|
lng: lon,
|
||||||
|
receiverIndex: idx,
|
||||||
|
radius: selected ? 0.52 : 0.38,
|
||||||
|
altitude: selected ? 0.1 : 0.04,
|
||||||
|
color: selected ? '#00ff88' : (rx.available ? '#00d4ff' : '#5f6976'),
|
||||||
|
label: buildWebsdrPointLabel(rx, idx),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
websdrGlobe.pointsData(points);
|
||||||
|
|
||||||
|
if (points.length > 0) {
|
||||||
|
if (websdrSelectedReceiverIndex != null) {
|
||||||
|
const selectedPoint = points.find(point => point.receiverIndex === websdrSelectedReceiverIndex);
|
||||||
|
if (selectedPoint) {
|
||||||
|
websdrGlobe.pointOfView({ lat: selectedPoint.lat, lng: selectedPoint.lng, altitude: 1.45 }, 900);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = computeWebsdrGlobeCenter(points);
|
||||||
|
websdrGlobe.pointOfView(center, 900);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeWebsdrGlobeCenter(points) {
|
||||||
|
if (!points.length) return { lat: 20, lng: 0, altitude: 2.1 };
|
||||||
|
|
||||||
|
let x = 0;
|
||||||
|
let y = 0;
|
||||||
|
let z = 0;
|
||||||
|
points.forEach(point => {
|
||||||
|
const latRad = point.lat * Math.PI / 180;
|
||||||
|
const lonRad = point.lng * Math.PI / 180;
|
||||||
|
x += Math.cos(latRad) * Math.cos(lonRad);
|
||||||
|
y += Math.cos(latRad) * Math.sin(lonRad);
|
||||||
|
z += Math.sin(latRad);
|
||||||
|
});
|
||||||
|
|
||||||
|
const count = points.length;
|
||||||
|
x /= count;
|
||||||
|
y /= count;
|
||||||
|
z /= count;
|
||||||
|
|
||||||
|
const hyp = Math.sqrt((x * x) + (y * y));
|
||||||
|
const centerLat = Math.atan2(z, hyp) * 180 / Math.PI;
|
||||||
|
const centerLng = Math.atan2(y, x) * 180 / Math.PI;
|
||||||
|
|
||||||
|
let meanAngularDistance = 0;
|
||||||
|
const centerLatRad = centerLat * Math.PI / 180;
|
||||||
|
const centerLngRad = centerLng * Math.PI / 180;
|
||||||
|
points.forEach(point => {
|
||||||
|
const latRad = point.lat * Math.PI / 180;
|
||||||
|
const lonRad = point.lng * Math.PI / 180;
|
||||||
|
const cosAngle = (
|
||||||
|
(Math.sin(centerLatRad) * Math.sin(latRad)) +
|
||||||
|
(Math.cos(centerLatRad) * Math.cos(latRad) * Math.cos(lonRad - centerLngRad))
|
||||||
|
);
|
||||||
|
const safeCos = Math.max(-1, Math.min(1, cosAngle));
|
||||||
|
meanAngularDistance += Math.acos(safeCos) * 180 / Math.PI;
|
||||||
|
});
|
||||||
|
meanAngularDistance /= count;
|
||||||
|
|
||||||
|
const altitude = Math.min(2.9, Math.max(1.35, 1.35 + (meanAngularDistance / 45)));
|
||||||
|
return { lat: centerLat, lng: centerLng, altitude: altitude };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureWebsdrGlobePopup(mapEl) {
|
||||||
|
if (websdrGlobePopup) {
|
||||||
|
if (websdrGlobePopup.parentElement !== mapEl) {
|
||||||
|
mapEl.appendChild(websdrGlobePopup);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
websdrGlobePopup = document.createElement('div');
|
||||||
|
websdrGlobePopup.id = 'websdrGlobePopup';
|
||||||
|
websdrGlobePopup.style.position = 'absolute';
|
||||||
|
websdrGlobePopup.style.minWidth = '220px';
|
||||||
|
websdrGlobePopup.style.maxWidth = '260px';
|
||||||
|
websdrGlobePopup.style.padding = '10px';
|
||||||
|
websdrGlobePopup.style.borderRadius = '8px';
|
||||||
|
websdrGlobePopup.style.border = '1px solid rgba(0, 212, 255, 0.35)';
|
||||||
|
websdrGlobePopup.style.background = 'rgba(5, 13, 20, 0.92)';
|
||||||
|
websdrGlobePopup.style.backdropFilter = 'blur(4px)';
|
||||||
|
websdrGlobePopup.style.boxShadow = '0 8px 24px rgba(0, 0, 0, 0.4)';
|
||||||
|
websdrGlobePopup.style.color = 'var(--text-primary)';
|
||||||
|
websdrGlobePopup.style.display = 'none';
|
||||||
|
websdrGlobePopup.style.zIndex = '20';
|
||||||
|
mapEl.appendChild(websdrGlobePopup);
|
||||||
|
|
||||||
|
if (!mapEl.dataset.websdrPopupHooked) {
|
||||||
|
mapEl.addEventListener('click', (event) => {
|
||||||
|
if (!websdrGlobePopup || websdrGlobePopup.style.display === 'none') return;
|
||||||
|
if (event.target.closest('#websdrGlobePopup')) return;
|
||||||
|
hideWebsdrGlobePopup();
|
||||||
|
});
|
||||||
|
mapEl.dataset.websdrPopupHooked = 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWebsdrGlobePopup(point, event) {
|
||||||
|
if (!websdrGlobePopup || !point || point.receiverIndex == null) return;
|
||||||
|
const rx = websdrReceivers[point.receiverIndex];
|
||||||
|
if (!rx) return;
|
||||||
|
|
||||||
|
const mapEl = document.getElementById('websdrMap');
|
||||||
|
if (!mapEl) return;
|
||||||
|
|
||||||
|
websdrSelectedReceiverIndex = point.receiverIndex;
|
||||||
|
renderReceiverList(websdrReceivers);
|
||||||
|
plotReceiversOnGlobe(websdrReceivers);
|
||||||
|
|
||||||
|
websdrGlobePopup.innerHTML = `
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: start; gap: 10px; margin-bottom: 6px;">
|
||||||
|
<strong style="font-size: 12px; color: var(--accent-cyan);">${escapeHtmlWebsdr(rx.name)}</strong>
|
||||||
|
<button type="button" data-websdr-popup-close style="border: none; background: transparent; color: var(--text-muted); cursor: pointer; font-size: 14px; line-height: 1;">×</button>
|
||||||
|
</div>
|
||||||
|
${rx.location ? `<div style="font-size: 10px; color: var(--text-secondary); margin-bottom: 3px;">${escapeHtmlWebsdr(rx.location)}</div>` : ''}
|
||||||
|
<div style="font-size: 10px; color: var(--text-muted); margin-bottom: 2px;">Antenna: ${escapeHtmlWebsdr(rx.antenna || 'Unknown')}</div>
|
||||||
|
<div style="font-size: 10px; color: var(--text-muted); margin-bottom: 10px;">Users: ${rx.users}/${rx.users_max}</div>
|
||||||
|
<button type="button" data-websdr-listen style="width: 100%; padding: 5px 10px; background: #00d4ff; color: #041018; border: none; border-radius: 4px; cursor: pointer; font-weight: 700;">Listen</button>
|
||||||
|
`;
|
||||||
|
websdrGlobePopup.style.display = 'block';
|
||||||
|
|
||||||
|
const rect = mapEl.getBoundingClientRect();
|
||||||
|
const x = event && Number.isFinite(event.clientX) ? (event.clientX - rect.left) : (rect.width / 2);
|
||||||
|
const y = event && Number.isFinite(event.clientY) ? (event.clientY - rect.top) : (rect.height / 2);
|
||||||
|
const popupWidth = 260;
|
||||||
|
const popupHeight = 155;
|
||||||
|
const left = Math.max(12, Math.min(rect.width - popupWidth - 12, x + 12));
|
||||||
|
const top = Math.max(12, Math.min(rect.height - popupHeight - 12, y + 12));
|
||||||
|
websdrGlobePopup.style.left = `${left}px`;
|
||||||
|
websdrGlobePopup.style.top = `${top}px`;
|
||||||
|
|
||||||
|
const closeBtn = websdrGlobePopup.querySelector('[data-websdr-popup-close]');
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.onclick = () => hideWebsdrGlobePopup();
|
||||||
|
}
|
||||||
|
const listenBtn = websdrGlobePopup.querySelector('[data-websdr-listen]');
|
||||||
|
if (listenBtn) {
|
||||||
|
listenBtn.onclick = () => selectReceiver(point.receiverIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event && typeof event.stopPropagation === 'function') {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideWebsdrGlobePopup() {
|
||||||
|
if (websdrGlobePopup) {
|
||||||
|
websdrGlobePopup.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWebsdrPointLabel(rx, idx) {
|
||||||
|
const location = rx.location ? escapeHtmlWebsdr(rx.location) : 'Unknown location';
|
||||||
|
const antenna = escapeHtmlWebsdr(rx.antenna || 'Unknown antenna');
|
||||||
|
return `
|
||||||
|
<div style="padding: 4px 6px; font-size: 11px; background: rgba(4, 12, 19, 0.9); border: 1px solid rgba(0,212,255,0.28); border-radius: 4px;">
|
||||||
|
<div style="color: #00d4ff; font-weight: 600;">${escapeHtmlWebsdr(rx.name)}</div>
|
||||||
|
<div style="color: #a5b1c3;">${location}</div>
|
||||||
|
<div style="color: #8f9fb3;">${antenna} · ${rx.users}/${rx.users_max}</div>
|
||||||
|
<div style="color: #7a899b; margin-top: 2px;">Receiver #${idx + 1}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
// ============== RECEIVER LIST ==============
|
// ============== RECEIVER LIST ==============
|
||||||
|
|
||||||
function renderReceiverList(receivers) {
|
function renderReceiverList(receivers) {
|
||||||
@@ -155,12 +535,16 @@ function renderReceiverList(receivers) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = receivers.slice(0, 50).map((rx, idx) => `
|
container.innerHTML = receivers.slice(0, 50).map((rx, idx) => {
|
||||||
<div style="padding: 8px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; transition: background 0.2s;"
|
const selected = idx === websdrSelectedReceiverIndex;
|
||||||
onmouseover="this.style.background='rgba(0,212,255,0.05)'" onmouseout="this.style.background='transparent'"
|
const baseBg = selected ? 'rgba(0,212,255,0.14)' : 'transparent';
|
||||||
|
const hoverBg = selected ? 'rgba(0,212,255,0.18)' : 'rgba(0,212,255,0.05)';
|
||||||
|
return `
|
||||||
|
<div style="padding: 8px 8px 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; transition: background 0.2s; border-left: 2px solid ${selected ? 'var(--accent-cyan)' : 'transparent'}; background: ${baseBg};"
|
||||||
|
onmouseover="this.style.background='${hoverBg}'" onmouseout="this.style.background='${baseBg}'"
|
||||||
onclick="selectReceiver(${idx})">
|
onclick="selectReceiver(${idx})">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
<strong style="font-size: 11px; color: var(--text-primary);">${escapeHtmlWebsdr(rx.name)}</strong>
|
<strong style="font-size: 11px; color: ${selected ? 'var(--accent-cyan)' : 'var(--text-primary)'};">${escapeHtmlWebsdr(rx.name)}</strong>
|
||||||
<span style="font-size: 9px; padding: 1px 6px; background: ${rx.available ? 'rgba(0,230,118,0.15)' : 'rgba(158,158,158,0.15)'}; color: ${rx.available ? '#00e676' : '#9e9e9e'}; border-radius: 3px;">${rx.users}/${rx.users_max}</span>
|
<span style="font-size: 9px; padding: 1px 6px; background: ${rx.available ? 'rgba(0,230,118,0.15)' : 'rgba(158,158,158,0.15)'}; color: ${rx.available ? '#00e676' : '#9e9e9e'}; border-radius: 3px;">${rx.users}/${rx.users_max}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size: 9px; color: var(--text-muted); margin-top: 2px;">
|
<div style="font-size: 9px; color: var(--text-muted); margin-top: 2px;">
|
||||||
@@ -168,7 +552,8 @@ function renderReceiverList(receivers) {
|
|||||||
${rx.distance_km !== undefined ? ` · ${rx.distance_km} km` : ''}
|
${rx.distance_km !== undefined ? ` · ${rx.distance_km} km` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============== SELECT RECEIVER ==============
|
// ============== SELECT RECEIVER ==============
|
||||||
@@ -180,14 +565,30 @@ function selectReceiver(index) {
|
|||||||
const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 7000);
|
const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 7000);
|
||||||
const mode = document.getElementById('websdrMode_select')?.value || 'am';
|
const mode = document.getElementById('websdrMode_select')?.value || 'am';
|
||||||
|
|
||||||
|
websdrSelectedReceiverIndex = index;
|
||||||
|
renderReceiverList(websdrReceivers);
|
||||||
|
focusReceiverOnMap(rx);
|
||||||
|
hideWebsdrGlobePopup();
|
||||||
|
|
||||||
kiwiReceiverName = rx.name;
|
kiwiReceiverName = rx.name;
|
||||||
|
|
||||||
// Connect via backend proxy
|
// Connect via backend proxy
|
||||||
connectToReceiver(rx.url, freqKhz, mode);
|
connectToReceiver(rx.url, freqKhz, mode);
|
||||||
|
}
|
||||||
|
|
||||||
// Highlight on map
|
function focusReceiverOnMap(rx) {
|
||||||
if (websdrMap && rx.lat != null && rx.lon != null) {
|
const lat = Number(rx.lat);
|
||||||
websdrMap.setView([rx.lat, rx.lon], 6);
|
const lon = Number(rx.lon);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
|
||||||
|
|
||||||
|
if (websdrMapType === 'globe' && websdrGlobe) {
|
||||||
|
plotReceiversOnGlobe(websdrReceivers);
|
||||||
|
websdrGlobe.pointOfView({ lat: lat, lng: lon, altitude: 1.4 }, 900);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (websdrMap) {
|
||||||
|
websdrMap.setView([lat, lon], 6);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,6 +952,8 @@ function tuneToSpyStation(stationId, freqKhz) {
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
websdrReceivers = data.receivers || [];
|
websdrReceivers = data.receivers || [];
|
||||||
|
websdrSelectedReceiverIndex = null;
|
||||||
|
hideWebsdrGlobePopup();
|
||||||
renderReceiverList(websdrReceivers);
|
renderReceiverList(websdrReceivers);
|
||||||
plotReceiversOnMap(websdrReceivers);
|
plotReceiversOnMap(websdrReceivers);
|
||||||
|
|
||||||
|
|||||||
+19
-4
@@ -590,20 +590,35 @@ const WiFiMode = (function() {
|
|||||||
eventSource = null;
|
eventSource = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update UI immediately so mode transitions are responsive even if the
|
||||||
|
// backend needs extra time to terminate subprocesses.
|
||||||
|
setScanning(false);
|
||||||
|
|
||||||
// Stop scan on server (local or agent)
|
// Stop scan on server (local or agent)
|
||||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
|
const timeoutMs = isAgentMode ? 8000 : 2200;
|
||||||
|
const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
||||||
|
const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isAgentMode) {
|
if (isAgentMode) {
|
||||||
await fetch(`/controller/agents/${currentAgent}/wifi/stop`, { method: 'POST' });
|
await fetch(`/controller/agents/${currentAgent}/wifi/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
...(controller ? { signal: controller.signal } : {}),
|
||||||
|
});
|
||||||
} else if (scanMode === 'deep') {
|
} else if (scanMode === 'deep') {
|
||||||
await fetch(`${CONFIG.apiBase}/scan/stop`, { method: 'POST' });
|
await fetch(`${CONFIG.apiBase}/scan/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
...(controller ? { signal: controller.signal } : {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[WiFiMode] Error stopping scan:', error);
|
console.warn('[WiFiMode] Error stopping scan:', error);
|
||||||
|
} finally {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setScanning(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setScanning(scanning, mode = null) {
|
function setScanning(scanning, mode = null) {
|
||||||
|
|||||||
Vendored
+297
@@ -0,0 +1,297 @@
|
|||||||
|
/*
|
||||||
|
* Leaflet.heat — a tiny, fast Leaflet heatmap plugin
|
||||||
|
* https://github.com/Leaflet/Leaflet.heat
|
||||||
|
* (c) 2014, Vladimir Agafonkin
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Bundled local copy for INTERCEPT — avoids CDN dependency.
|
||||||
|
* Includes simpleheat (https://github.com/mourner/simpleheat), MIT License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---- simpleheat ----
|
||||||
|
(function (global, factory) {
|
||||||
|
typeof define === 'function' && define.amd ? define(factory) :
|
||||||
|
typeof exports !== 'undefined' ? module.exports = factory() :
|
||||||
|
global.simpleheat = factory();
|
||||||
|
}(this, function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function simpleheat(canvas) {
|
||||||
|
if (!(this instanceof simpleheat)) return new simpleheat(canvas);
|
||||||
|
this._canvas = canvas = typeof canvas === 'string' ? document.getElementById(canvas) : canvas;
|
||||||
|
this._ctx = canvas.getContext('2d');
|
||||||
|
this._width = canvas.width;
|
||||||
|
this._height = canvas.height;
|
||||||
|
this._max = 1;
|
||||||
|
this._data = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
simpleheat.prototype = {
|
||||||
|
defaultRadius: 25,
|
||||||
|
defaultGradient: {
|
||||||
|
0.4: 'blue',
|
||||||
|
0.6: 'cyan',
|
||||||
|
0.7: 'lime',
|
||||||
|
0.8: 'yellow',
|
||||||
|
1.0: 'red'
|
||||||
|
},
|
||||||
|
|
||||||
|
data: function (data) {
|
||||||
|
this._data = data;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
max: function (max) {
|
||||||
|
this._max = max;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
add: function (point) {
|
||||||
|
this._data.push(point);
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
clear: function () {
|
||||||
|
this._data = [];
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
radius: function (r, blur) {
|
||||||
|
blur = blur === undefined ? 15 : blur;
|
||||||
|
var circle = this._circle = this._createCanvas(),
|
||||||
|
ctx = circle.getContext('2d'),
|
||||||
|
r2 = this._r = r + blur;
|
||||||
|
circle.width = circle.height = r2 * 2;
|
||||||
|
ctx.shadowOffsetX = ctx.shadowOffsetY = r2 * 2;
|
||||||
|
ctx.shadowBlur = blur;
|
||||||
|
ctx.shadowColor = 'black';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(-r2, -r2, r, 0, Math.PI * 2, true);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
resize: function () {
|
||||||
|
this._width = this._canvas.width;
|
||||||
|
this._height = this._canvas.height;
|
||||||
|
},
|
||||||
|
|
||||||
|
gradient: function (grad) {
|
||||||
|
var canvas = this._createCanvas(),
|
||||||
|
ctx = canvas.getContext('2d'),
|
||||||
|
gradient = ctx.createLinearGradient(0, 0, 0, 256);
|
||||||
|
canvas.width = 1;
|
||||||
|
canvas.height = 256;
|
||||||
|
for (var i in grad) {
|
||||||
|
gradient.addColorStop(+i, grad[i]);
|
||||||
|
}
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, 1, 256);
|
||||||
|
this._grad = ctx.getImageData(0, 0, 1, 256).data;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
draw: function (minOpacity) {
|
||||||
|
if (!this._circle) this.radius(this.defaultRadius);
|
||||||
|
if (!this._grad) this.gradient(this.defaultGradient);
|
||||||
|
|
||||||
|
var ctx = this._ctx;
|
||||||
|
ctx.clearRect(0, 0, this._width, this._height);
|
||||||
|
|
||||||
|
for (var i = 0, len = this._data.length, p; i < len; i++) {
|
||||||
|
p = this._data[i];
|
||||||
|
ctx.globalAlpha = Math.min(Math.max(p[2] / this._max, minOpacity === undefined ? 0.05 : minOpacity), 1);
|
||||||
|
ctx.drawImage(this._circle, p[0] - this._r, p[1] - this._r);
|
||||||
|
}
|
||||||
|
|
||||||
|
var colored = ctx.getImageData(0, 0, this._width, this._height);
|
||||||
|
this._colorize(colored.data, this._grad);
|
||||||
|
ctx.putImageData(colored, 0, 0);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
_colorize: function (pixels, gradient) {
|
||||||
|
for (var i = 3, len = pixels.length, j; i < len; i += 4) {
|
||||||
|
j = pixels[i] * 4;
|
||||||
|
if (j) {
|
||||||
|
pixels[i - 3] = gradient[j];
|
||||||
|
pixels[i - 2] = gradient[j + 1];
|
||||||
|
pixels[i - 1] = gradient[j + 2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_createCanvas: function () {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
return document.createElement('canvas');
|
||||||
|
}
|
||||||
|
return { getContext: function () {} };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return simpleheat;
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---- Leaflet.heat plugin ----
|
||||||
|
(function () {
|
||||||
|
if (typeof L === 'undefined') return;
|
||||||
|
|
||||||
|
L.HeatLayer = (L.Layer ? L.Layer : L.Class).extend({
|
||||||
|
initialize: function (latlngs, options) {
|
||||||
|
this._latlngs = latlngs;
|
||||||
|
L.setOptions(this, options);
|
||||||
|
},
|
||||||
|
|
||||||
|
setLatLngs: function (latlngs) {
|
||||||
|
this._latlngs = latlngs;
|
||||||
|
return this.redraw();
|
||||||
|
},
|
||||||
|
|
||||||
|
addLatLng: function (latlng) {
|
||||||
|
this._latlngs.push(latlng);
|
||||||
|
return this.redraw();
|
||||||
|
},
|
||||||
|
|
||||||
|
setOptions: function (options) {
|
||||||
|
L.setOptions(this, options);
|
||||||
|
if (this._heat) this._updateOptions();
|
||||||
|
return this.redraw();
|
||||||
|
},
|
||||||
|
|
||||||
|
redraw: function () {
|
||||||
|
if (this._heat && !this._frame && this._map && !this._map._animating) {
|
||||||
|
this._frame = L.Util.requestAnimFrame(this._redraw, this);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
onAdd: function (map) {
|
||||||
|
this._map = map;
|
||||||
|
if (!this._canvas) this._initCanvas();
|
||||||
|
if (this.options.pane) this.getPane().appendChild(this._canvas);
|
||||||
|
else map._panes.overlayPane.appendChild(this._canvas);
|
||||||
|
map.on('moveend', this._reset, this);
|
||||||
|
if (map.options.zoomAnimation && L.Browser.any3d) {
|
||||||
|
map.on('zoomanim', this._animateZoom, this);
|
||||||
|
}
|
||||||
|
this._reset();
|
||||||
|
},
|
||||||
|
|
||||||
|
onRemove: function (map) {
|
||||||
|
if (this.options.pane) this.getPane().removeChild(this._canvas);
|
||||||
|
else map.getPanes().overlayPane.removeChild(this._canvas);
|
||||||
|
map.off('moveend', this._reset, this);
|
||||||
|
if (map.options.zoomAnimation) {
|
||||||
|
map.off('zoomanim', this._animateZoom, this);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addTo: function (map) {
|
||||||
|
map.addLayer(this);
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
_initCanvas: function () {
|
||||||
|
var canvas = this._canvas = L.DomUtil.create('canvas', 'leaflet-heatmap-layer leaflet-layer');
|
||||||
|
var originProp = L.DomUtil.testProp(['transformOrigin', 'WebkitTransformOrigin', 'msTransformOrigin']);
|
||||||
|
canvas.style[originProp] = '50% 50%';
|
||||||
|
var size = this._map.getSize();
|
||||||
|
canvas.width = size.x;
|
||||||
|
canvas.height = size.y;
|
||||||
|
var animated = this._map.options.zoomAnimation && L.Browser.any3d;
|
||||||
|
L.DomUtil.addClass(canvas, 'leaflet-zoom-' + (animated ? 'animated' : 'hide'));
|
||||||
|
this._heat = simpleheat(canvas);
|
||||||
|
this._updateOptions();
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateOptions: function () {
|
||||||
|
this._heat.radius(this.options.radius || this._heat.defaultRadius, this.options.blur);
|
||||||
|
if (this.options.gradient) this._heat.gradient(this.options.gradient);
|
||||||
|
if (this.options.minOpacity) this._heat.minOpacity = this.options.minOpacity;
|
||||||
|
},
|
||||||
|
|
||||||
|
_reset: function () {
|
||||||
|
var topLeft = this._map.containerPointToLayerPoint([0, 0]);
|
||||||
|
L.DomUtil.setPosition(this._canvas, topLeft);
|
||||||
|
var size = this._map.getSize();
|
||||||
|
if (this._heat._width !== size.x) {
|
||||||
|
this._canvas.width = this._heat._width = size.x;
|
||||||
|
}
|
||||||
|
if (this._heat._height !== size.y) {
|
||||||
|
this._canvas.height = this._heat._height = size.y;
|
||||||
|
}
|
||||||
|
this._redraw();
|
||||||
|
},
|
||||||
|
|
||||||
|
_redraw: function () {
|
||||||
|
this._frame = null;
|
||||||
|
if (!this._map) return;
|
||||||
|
var data = [],
|
||||||
|
r = this._heat._r,
|
||||||
|
size = this._map.getSize(),
|
||||||
|
bounds = new L.Bounds(L.point([-r, -r]), size.add([r, r])),
|
||||||
|
max = this.options.max === undefined ? 1 : this.options.max,
|
||||||
|
maxZoom = this.options.maxZoom === undefined ? this._map.getMaxZoom() : this.options.maxZoom,
|
||||||
|
v = 1 / Math.pow(2, Math.max(0, Math.min(maxZoom - this._map.getZoom(), 12))),
|
||||||
|
cellSize = r / 2,
|
||||||
|
grid = [],
|
||||||
|
panePos = this._map._getMapPanePos(),
|
||||||
|
offsetX = panePos.x % cellSize,
|
||||||
|
offsetY = panePos.y % cellSize,
|
||||||
|
i, len, p, cell, x, y, j, len2, k;
|
||||||
|
|
||||||
|
for (i = 0, len = this._latlngs.length; i < len; i++) {
|
||||||
|
p = this._map.latLngToContainerPoint(this._latlngs[i]);
|
||||||
|
if (bounds.contains(p)) {
|
||||||
|
x = Math.floor((p.x - offsetX) / cellSize) + 2;
|
||||||
|
y = Math.floor((p.y - offsetY) / cellSize) + 2;
|
||||||
|
var alt = this._latlngs[i].alt !== undefined ? this._latlngs[i].alt :
|
||||||
|
this._latlngs[i][2] !== undefined ? +this._latlngs[i][2] : 1;
|
||||||
|
k = alt * v;
|
||||||
|
grid[y] = grid[y] || [];
|
||||||
|
cell = grid[y][x];
|
||||||
|
if (!cell) {
|
||||||
|
grid[y][x] = [p.x, p.y, k];
|
||||||
|
} else {
|
||||||
|
cell[0] = (cell[0] * cell[2] + p.x * k) / (cell[2] + k);
|
||||||
|
cell[1] = (cell[1] * cell[2] + p.y * k) / (cell[2] + k);
|
||||||
|
cell[2] += k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i = 0, len = grid.length; i < len; i++) {
|
||||||
|
if (grid[i]) {
|
||||||
|
for (j = 0, len2 = grid[i].length; j < len2; j++) {
|
||||||
|
cell = grid[i][j];
|
||||||
|
if (cell) {
|
||||||
|
data.push([
|
||||||
|
Math.round(cell[0]),
|
||||||
|
Math.round(cell[1]),
|
||||||
|
Math.min(cell[2], max)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._heat.data(data).draw(this.options.minOpacity);
|
||||||
|
},
|
||||||
|
|
||||||
|
_animateZoom: function (e) {
|
||||||
|
var scale = this._map.getZoomScale(e.zoom),
|
||||||
|
offset = this._map._getCenterOffset(e.center)._multiplyBy(-scale).subtract(this._map._getMapPanePos());
|
||||||
|
if (L.DomUtil.setTransform) {
|
||||||
|
L.DomUtil.setTransform(this._canvas, offset, scale);
|
||||||
|
} else {
|
||||||
|
this._canvas.style[L.DomUtil.TRANSFORM] = L.DomUtil.getTranslateString(offset) + ' scale(' + scale + ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
L.heatLayer = function (latlngs, options) {
|
||||||
|
return new L.HeatLayer(latlngs, options);
|
||||||
|
};
|
||||||
|
}());
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "INTERCEPT Signal Intelligence",
|
||||||
|
"short_name": "INTERCEPT",
|
||||||
|
"description": "Unified SIGINT platform for software-defined radio analysis",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0b1118",
|
||||||
|
"theme_color": "#0b1118",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
/* INTERCEPT Service Worker — cache-first static, network-only for API/SSE/WS */
|
||||||
|
const CACHE_NAME = 'intercept-v3';
|
||||||
|
|
||||||
|
const NETWORK_ONLY_PREFIXES = [
|
||||||
|
'/stream', '/ws/', '/api/', '/gps/', '/wifi/', '/bluetooth/',
|
||||||
|
'/adsb/', '/ais/', '/acars/', '/aprs/', '/tscm/', '/satellite/',
|
||||||
|
'/meshtastic/', '/bt_locate/', '/receiver/', '/sensor/', '/pager/',
|
||||||
|
'/sstv/', '/weather-sat/', '/subghz/', '/rtlamr/', '/dsc/', '/vdl2/',
|
||||||
|
'/spy/', '/space-weather/', '/websdr/', '/analytics/', '/correlation/',
|
||||||
|
'/recordings/', '/controller/', '/ops/',
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATIC_PREFIXES = [
|
||||||
|
'/static/css/',
|
||||||
|
'/static/js/',
|
||||||
|
'/static/icons/',
|
||||||
|
'/static/fonts/',
|
||||||
|
];
|
||||||
|
|
||||||
|
const CACHE_EXACT = ['/manifest.json'];
|
||||||
|
|
||||||
|
function isHttpRequest(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNetworkOnly(req) {
|
||||||
|
if (req.method !== 'GET') return true;
|
||||||
|
const accept = req.headers.get('Accept') || '';
|
||||||
|
if (accept.includes('text/event-stream')) return true;
|
||||||
|
const url = new URL(req.url);
|
||||||
|
return NETWORK_ONLY_PREFIXES.some(p => url.pathname.startsWith(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStaticAsset(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (CACHE_EXACT.includes(url.pathname)) return true;
|
||||||
|
return STATIC_PREFIXES.some(p => url.pathname.startsWith(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackResponse(req, status = 503) {
|
||||||
|
const accept = req.headers.get('Accept') || '';
|
||||||
|
if (accept.includes('application/json')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ status: 'error', message: 'Network unavailable' }),
|
||||||
|
{
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accept.includes('text/event-stream')) {
|
||||||
|
return new Response('', {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'text/event-stream' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Offline', {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('install', (e) => {
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (e) => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches.keys().then(keys =>
|
||||||
|
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
|
||||||
|
).then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (e) => {
|
||||||
|
const req = e.request;
|
||||||
|
|
||||||
|
// Ignore non-HTTP(S) requests so extensions/browser-internal URLs are untouched.
|
||||||
|
if (!isHttpRequest(req)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always bypass service worker for non-GET and streaming routes
|
||||||
|
if (isNetworkOnly(req)) {
|
||||||
|
e.respondWith(
|
||||||
|
fetch(req).catch(() => fallbackResponse(req, 503))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache-first for static assets
|
||||||
|
if (isStaticAsset(req)) {
|
||||||
|
e.respondWith(
|
||||||
|
caches.open(CACHE_NAME).then(cache =>
|
||||||
|
cache.match(req).then(cached => {
|
||||||
|
if (cached) {
|
||||||
|
// Revalidate in background
|
||||||
|
fetch(req).then(res => {
|
||||||
|
if (res && res.status === 200) cache.put(req, res.clone());
|
||||||
|
}).catch(() => {});
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
return fetch(req).then(res => {
|
||||||
|
if (res && res.status === 200) cache.put(req, res.clone());
|
||||||
|
return res;
|
||||||
|
}).catch(() => fallbackResponse(req, 504));
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network-first for HTML pages
|
||||||
|
e.respondWith(
|
||||||
|
fetch(req).catch(() =>
|
||||||
|
caches.match(req).then(cached => cached || new Response('Offline', { status: 503 }))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
+158
-30
@@ -258,6 +258,10 @@
|
|||||||
<div class="display-container">
|
<div class="display-container">
|
||||||
<div id="radarMap">
|
<div id="radarMap">
|
||||||
</div>
|
</div>
|
||||||
|
<div id="mapCrosshairOverlay" class="map-crosshair-overlay" aria-hidden="true">
|
||||||
|
<div class="map-crosshair-line map-crosshair-vertical"></div>
|
||||||
|
<div class="map-crosshair-line map-crosshair-horizontal"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -429,6 +433,17 @@
|
|||||||
let alertsEnabled = true;
|
let alertsEnabled = true;
|
||||||
let detectionSoundEnabled = localStorage.getItem('adsb_detectionSound') !== 'false'; // Default on
|
let detectionSoundEnabled = localStorage.getItem('adsb_detectionSound') !== 'false'; // Default on
|
||||||
let soundedAircraft = {}; // Track aircraft we've played detection sound for
|
let soundedAircraft = {}; // Track aircraft we've played detection sound for
|
||||||
|
const MAP_CROSSHAIR_DURATION_MS = 1500;
|
||||||
|
const PANEL_SELECTION_BASE_ZOOM = 10;
|
||||||
|
const PANEL_SELECTION_MAX_ZOOM = 12;
|
||||||
|
const PANEL_SELECTION_ZOOM_INCREMENT = 1.4;
|
||||||
|
const PANEL_SELECTION_STAGE1_DURATION_SEC = 1.05;
|
||||||
|
const PANEL_SELECTION_STAGE2_DURATION_SEC = 1.15;
|
||||||
|
const PANEL_SELECTION_STAGE_GAP_MS = 180;
|
||||||
|
let mapCrosshairResetTimer = null;
|
||||||
|
let panelSelectionFallbackTimer = null;
|
||||||
|
let panelSelectionStageTimer = null;
|
||||||
|
let mapCrosshairRequestId = 0;
|
||||||
// Watchlist - persisted to localStorage
|
// Watchlist - persisted to localStorage
|
||||||
let watchlist = JSON.parse(localStorage.getItem('adsb_watchlist') || '[]');
|
let watchlist = JSON.parse(localStorage.getItem('adsb_watchlist') || '[]');
|
||||||
|
|
||||||
@@ -2620,7 +2635,7 @@ sudo make install</code>
|
|||||||
} else {
|
} else {
|
||||||
markers[icao] = L.marker([ac.lat, ac.lon], { icon: createMarkerIcon(rotation, color, iconType, isSelected) })
|
markers[icao] = L.marker([ac.lat, ac.lon], { icon: createMarkerIcon(rotation, color, iconType, isSelected) })
|
||||||
.addTo(radarMap)
|
.addTo(radarMap)
|
||||||
.on('click', () => selectAircraft(icao));
|
.on('click', () => selectAircraft(icao, 'map'));
|
||||||
markers[icao].bindTooltip(`${callsign}<br>${alt}`, {
|
markers[icao].bindTooltip(`${callsign}<br>${alt}`, {
|
||||||
permanent: false, direction: 'top', className: 'aircraft-tooltip'
|
permanent: false, direction: 'top', className: 'aircraft-tooltip'
|
||||||
});
|
});
|
||||||
@@ -2724,7 +2739,7 @@ sudo make install</code>
|
|||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = `aircraft-item ${selectedIcao === ac.icao ? 'selected' : ''} ${isOnWatchlist(ac) ? 'watched' : ''}`;
|
div.className = `aircraft-item ${selectedIcao === ac.icao ? 'selected' : ''} ${isOnWatchlist(ac) ? 'watched' : ''}`;
|
||||||
div.setAttribute('data-icao', ac.icao);
|
div.setAttribute('data-icao', ac.icao);
|
||||||
div.onclick = () => selectAircraft(ac.icao);
|
div.onclick = () => selectAircraft(ac.icao, 'panel');
|
||||||
div.innerHTML = buildAircraftItemHTML(ac);
|
div.innerHTML = buildAircraftItemHTML(ac);
|
||||||
fragment.appendChild(div);
|
fragment.appendChild(div);
|
||||||
});
|
});
|
||||||
@@ -2797,34 +2812,139 @@ sudo make install</code>
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectAircraft(icao) {
|
function triggerMapCrosshairAnimation(lat, lon, durationMs = MAP_CROSSHAIR_DURATION_MS, lockToMapCenter = false) {
|
||||||
// Toggle: clicking the same aircraft deselects it
|
if (!radarMap) return;
|
||||||
if (selectedIcao === icao) {
|
const overlay = document.getElementById('mapCrosshairOverlay');
|
||||||
const prev = selectedIcao;
|
if (!overlay) return;
|
||||||
selectedIcao = null;
|
|
||||||
// Reset previous marker icon
|
const size = radarMap.getSize();
|
||||||
if (prev && markers[prev] && aircraft[prev]) {
|
let targetX;
|
||||||
const ac = aircraft[prev];
|
let targetY;
|
||||||
const militaryInfo = isMilitaryAircraft(prev, ac.callsign);
|
|
||||||
const rotation = Math.round((ac.heading || 0) / 5) * 5;
|
if (lockToMapCenter) {
|
||||||
const color = militaryInfo.military ? '#556b2f' : getAltitudeColor(ac.altitude);
|
targetX = size.x / 2;
|
||||||
const iconType = getAircraftIconType(ac.type_code, militaryInfo.military);
|
targetY = size.y / 2;
|
||||||
markers[prev].setIcon(createMarkerIcon(rotation, color, iconType, false));
|
} else {
|
||||||
if (markerState[prev]) markerState[prev].isSelected = false;
|
const point = radarMap.latLngToContainerPoint([lat, lon]);
|
||||||
}
|
targetX = Math.max(0, Math.min(size.x, point.x));
|
||||||
renderAircraftList();
|
targetY = Math.max(0, Math.min(size.y, point.y));
|
||||||
showAircraftDetails(null);
|
}
|
||||||
updateFlightLookupBtn();
|
|
||||||
highlightSidebarMessages(null);
|
const startX = size.x + 8;
|
||||||
if (acarsMessageTimer) {
|
const startY = size.y + 8;
|
||||||
clearInterval(acarsMessageTimer);
|
|
||||||
acarsMessageTimer = null;
|
overlay.style.setProperty('--crosshair-x-start', `${startX}px`);
|
||||||
}
|
overlay.style.setProperty('--crosshair-y-start', `${startY}px`);
|
||||||
|
overlay.style.setProperty('--crosshair-x-end', `${targetX}px`);
|
||||||
|
overlay.style.setProperty('--crosshair-y-end', `${targetY}px`);
|
||||||
|
overlay.style.setProperty('--crosshair-duration', `${durationMs}ms`);
|
||||||
|
overlay.classList.remove('active');
|
||||||
|
void overlay.offsetWidth;
|
||||||
|
overlay.classList.add('active');
|
||||||
|
|
||||||
|
if (mapCrosshairResetTimer) {
|
||||||
|
clearTimeout(mapCrosshairResetTimer);
|
||||||
|
}
|
||||||
|
mapCrosshairResetTimer = setTimeout(() => {
|
||||||
|
overlay.classList.remove('active');
|
||||||
|
mapCrosshairResetTimer = null;
|
||||||
|
}, durationMs + 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPanelSelectionFinalZoom() {
|
||||||
|
if (!radarMap) return PANEL_SELECTION_BASE_ZOOM;
|
||||||
|
const currentZoom = radarMap.getZoom();
|
||||||
|
const maxZoom = typeof radarMap.getMaxZoom === 'function' ? radarMap.getMaxZoom() : PANEL_SELECTION_MAX_ZOOM;
|
||||||
|
return Math.min(
|
||||||
|
PANEL_SELECTION_MAX_ZOOM,
|
||||||
|
maxZoom,
|
||||||
|
Math.max(PANEL_SELECTION_BASE_ZOOM, currentZoom + PANEL_SELECTION_ZOOM_INCREMENT)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPanelSelectionIntermediateZoom(finalZoom) {
|
||||||
|
if (!radarMap) return finalZoom;
|
||||||
|
const currentZoom = radarMap.getZoom();
|
||||||
|
if (finalZoom - currentZoom < 0.8) {
|
||||||
|
return finalZoom;
|
||||||
|
}
|
||||||
|
const midpointZoom = currentZoom + ((finalZoom - currentZoom) * 0.55);
|
||||||
|
return Math.min(finalZoom - 0.45, midpointZoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPanelSelectionAnimation(lat, lon, requestId) {
|
||||||
|
if (!radarMap) return;
|
||||||
|
|
||||||
|
const finalZoom = getPanelSelectionFinalZoom();
|
||||||
|
const intermediateZoom = getPanelSelectionIntermediateZoom(finalZoom);
|
||||||
|
const sequenceDurationMs = Math.round(
|
||||||
|
((PANEL_SELECTION_STAGE1_DURATION_SEC + PANEL_SELECTION_STAGE2_DURATION_SEC) * 1000) +
|
||||||
|
PANEL_SELECTION_STAGE_GAP_MS + 260
|
||||||
|
);
|
||||||
|
const startSecondStage = () => {
|
||||||
|
if (requestId !== mapCrosshairRequestId) return;
|
||||||
|
radarMap.flyTo([lat, lon], finalZoom, {
|
||||||
|
animate: true,
|
||||||
|
duration: PANEL_SELECTION_STAGE2_DURATION_SEC,
|
||||||
|
easeLinearity: 0.2
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
triggerMapCrosshairAnimation(
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
Math.max(MAP_CROSSHAIR_DURATION_MS, sequenceDurationMs),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (intermediateZoom >= finalZoom - 0.1) {
|
||||||
|
radarMap.flyTo([lat, lon], finalZoom, {
|
||||||
|
animate: true,
|
||||||
|
duration: PANEL_SELECTION_STAGE2_DURATION_SEC,
|
||||||
|
easeLinearity: 0.2
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let stage1Handled = false;
|
||||||
|
const finishStage1 = () => {
|
||||||
|
if (stage1Handled || requestId !== mapCrosshairRequestId) return;
|
||||||
|
stage1Handled = true;
|
||||||
|
if (panelSelectionFallbackTimer) {
|
||||||
|
clearTimeout(panelSelectionFallbackTimer);
|
||||||
|
panelSelectionFallbackTimer = null;
|
||||||
|
}
|
||||||
|
panelSelectionStageTimer = setTimeout(() => {
|
||||||
|
panelSelectionStageTimer = null;
|
||||||
|
startSecondStage();
|
||||||
|
}, PANEL_SELECTION_STAGE_GAP_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
radarMap.once('moveend', finishStage1);
|
||||||
|
panelSelectionFallbackTimer = setTimeout(
|
||||||
|
finishStage1,
|
||||||
|
Math.round(PANEL_SELECTION_STAGE1_DURATION_SEC * 1000) + 160
|
||||||
|
);
|
||||||
|
|
||||||
|
radarMap.flyTo([lat, lon], intermediateZoom, {
|
||||||
|
animate: true,
|
||||||
|
duration: PANEL_SELECTION_STAGE1_DURATION_SEC,
|
||||||
|
easeLinearity: 0.2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAircraft(icao, source = 'map') {
|
||||||
const prevSelected = selectedIcao;
|
const prevSelected = selectedIcao;
|
||||||
selectedIcao = icao;
|
selectedIcao = icao;
|
||||||
|
mapCrosshairRequestId += 1;
|
||||||
|
if (panelSelectionFallbackTimer) {
|
||||||
|
clearTimeout(panelSelectionFallbackTimer);
|
||||||
|
panelSelectionFallbackTimer = null;
|
||||||
|
}
|
||||||
|
if (panelSelectionStageTimer) {
|
||||||
|
clearTimeout(panelSelectionStageTimer);
|
||||||
|
panelSelectionStageTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Update marker icons for both previous and new selection
|
// Update marker icons for both previous and new selection
|
||||||
[prevSelected, icao].forEach(targetIcao => {
|
[prevSelected, icao].forEach(targetIcao => {
|
||||||
@@ -2850,7 +2970,15 @@ sudo make install</code>
|
|||||||
|
|
||||||
const ac = aircraft[icao];
|
const ac = aircraft[icao];
|
||||||
if (ac && ac.lat !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) {
|
if (ac && ac.lat !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) {
|
||||||
radarMap.setView([ac.lat, ac.lon], 10);
|
const targetLat = ac.lat;
|
||||||
|
const targetLon = ac.lon;
|
||||||
|
|
||||||
|
if (source === 'panel' && radarMap) {
|
||||||
|
runPanelSelectionAnimation(targetLat, targetLon, mapCrosshairRequestId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
radarMap.setView([targetLat, targetLon], 10);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3264,7 +3392,7 @@ sudo make install</code>
|
|||||||
|
|
||||||
function initAirband() {
|
function initAirband() {
|
||||||
// Check if audio tools are available
|
// Check if audio tools are available
|
||||||
fetch('/listening/tools')
|
fetch('/receiver/tools')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
const missingTools = [];
|
const missingTools = [];
|
||||||
@@ -3414,7 +3542,7 @@ sudo make install</code>
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Start audio on backend
|
// Start audio on backend
|
||||||
const response = await fetch('/listening/audio/start', {
|
const response = await fetch('/receiver/audio/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -3451,7 +3579,7 @@ sudo make install</code>
|
|||||||
audioPlayer.load();
|
audioPlayer.load();
|
||||||
|
|
||||||
// Connect to stream
|
// Connect to stream
|
||||||
const streamUrl = `/listening/audio/stream?t=${Date.now()}`;
|
const streamUrl = `/receiver/audio/stream?t=${Date.now()}`;
|
||||||
console.log('[AIRBAND] Connecting to stream:', streamUrl);
|
console.log('[AIRBAND] Connecting to stream:', streamUrl);
|
||||||
audioPlayer.src = streamUrl;
|
audioPlayer.src = streamUrl;
|
||||||
|
|
||||||
@@ -3495,7 +3623,7 @@ sudo make install</code>
|
|||||||
audioPlayer.pause();
|
audioPlayer.pause();
|
||||||
audioPlayer.src = '';
|
audioPlayer.src = '';
|
||||||
|
|
||||||
fetch('/listening/audio/stop', { method: 'POST' })
|
fetch('/receiver/audio/stop', { method: 'POST' })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(() => {
|
.then(() => {
|
||||||
isAirbandPlaying = false;
|
isAirbandPlaying = false;
|
||||||
|
|||||||
+908
-708
File diff suppressed because it is too large
Load Diff
@@ -43,7 +43,7 @@
|
|||||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span><span class="desc">Aircraft - ADS-B tracking & history</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span><span class="desc">Aircraft - ADS-B tracking & history</span></div>
|
||||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg></span><span class="desc">Vessels - AIS & VHF DSC distress</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg></span><span class="desc">Vessels - AIS & VHF DSC distress</span></div>
|
||||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span><span class="desc">APRS - Amateur radio tracking</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span><span class="desc">APRS - Amateur radio tracking</span></div>
|
||||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span><span class="desc">Listening Post - SDR scanner</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M2 18h20" opacity="0.4"/><path d="M2 21h20" opacity="0.2"/></svg></span><span class="desc">Waterfall - SDR receiver + signal ID</span></div>
|
||||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span><span class="desc">Spy Stations - Number stations database</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span><span class="desc">Spy Stations - Number stations database</span></div>
|
||||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span><span class="desc">Meshtastic - LoRa mesh networking</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span><span class="desc">Meshtastic - LoRa mesh networking</span></div>
|
||||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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></span><span class="desc">WebSDR - Remote SDR receivers</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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></span><span class="desc">WebSDR - Remote SDR receivers</span></div>
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
<li>Interactive map shows station positions in real-time</li>
|
<li>Interactive map shows station positions in real-time</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Listening Post Mode</h3>
|
<h3>Spectrum Waterfall Mode</h3>
|
||||||
<ul class="tip-list">
|
<ul class="tip-list">
|
||||||
<li>Wideband SDR scanner with spectrum visualization</li>
|
<li>Wideband SDR scanner with spectrum visualization</li>
|
||||||
<li>Tune to any frequency supported by your SDR hardware</li>
|
<li>Tune to any frequency supported by your SDR hardware</li>
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
<li>Browse stations from priyom.org with frequencies and schedules</li>
|
<li>Browse stations from priyom.org with frequencies and schedules</li>
|
||||||
<li>Filter by type (number/diplomatic), country, and mode</li>
|
<li>Filter by type (number/diplomatic), country, and mode</li>
|
||||||
<li>Famous stations: UVB-76 "The Buzzer", Cuban HM01, Israeli E17z</li>
|
<li>Famous stations: UVB-76 "The Buzzer", Cuban HM01, Israeli E17z</li>
|
||||||
<li>Click "Tune" to listen via Listening Post mode</li>
|
<li>Click "Tune" to listen via Spectrum Waterfall mode</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Meshtastic Mode</h3>
|
<h3>Meshtastic Mode</h3>
|
||||||
@@ -166,11 +166,27 @@
|
|||||||
<li>View next pass predictions with elevation and duration</li>
|
<li>View next pass predictions with elevation and duration</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<h3>ACARS Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Decodes Aircraft Communications Addressing and Reporting System messages via acarsdec</li>
|
||||||
|
<li>Receives operational, weather, and position reports on 129-136 MHz</li>
|
||||||
|
<li>Supports North America, Europe, and Asia-Pacific regional frequency presets</li>
|
||||||
|
<li>Filter by message type, flight ID, or aircraft registration</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>VDL2 Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Decodes VHF Data Link Mode 2 aircraft datalink messages via dumpvdl2</li>
|
||||||
|
<li>Captures ACARS-over-AVLC frames with full signal analysis (SNR, burst length)</li>
|
||||||
|
<li>Monitor multiple VDL2 frequencies simultaneously (136.725, 136.775, 136.975 MHz)</li>
|
||||||
|
<li>Export captured messages to CSV or JSON for offline analysis</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<h3>ISS SSTV Mode</h3>
|
<h3>ISS SSTV Mode</h3>
|
||||||
<ul class="tip-list">
|
<ul class="tip-list">
|
||||||
<li>Decodes Slow Scan Television (SSTV) images from the International Space Station</li>
|
<li>Decodes Slow Scan Television (SSTV) images from the International Space Station</li>
|
||||||
<li>Automated ISS pass tracking with Doppler correction on 145.800 MHz</li>
|
<li>Automated ISS pass tracking with Doppler correction on 145.800 MHz</li>
|
||||||
<li>Images decoded in real-time using slowrx</li>
|
<li>Images decoded in real-time using the built-in pure Python decoder</li>
|
||||||
<li>Gallery view with timestamped decoded images</li>
|
<li>Gallery view with timestamped decoded images</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -330,15 +346,17 @@
|
|||||||
<li><strong>Aircraft (ACARS):</strong> Second RTL-SDR, acarsdec</li>
|
<li><strong>Aircraft (ACARS):</strong> Second RTL-SDR, acarsdec</li>
|
||||||
<li><strong>Vessels (AIS):</strong> RTL-SDR, AIS-catcher</li>
|
<li><strong>Vessels (AIS):</strong> RTL-SDR, AIS-catcher</li>
|
||||||
<li><strong>APRS:</strong> RTL-SDR, direwolf or multimon-ng</li>
|
<li><strong>APRS:</strong> RTL-SDR, direwolf or multimon-ng</li>
|
||||||
<li><strong>Listening Post:</strong> RTL-SDR or SoapySDR-compatible hardware</li>
|
<li><strong>Spectrum Waterfall:</strong> RTL-SDR or SoapySDR-compatible hardware</li>
|
||||||
<li><strong>Spy Stations:</strong> Internet connection (database lookup)</li>
|
<li><strong>Spy Stations:</strong> Internet connection (database lookup)</li>
|
||||||
<li><strong>Meshtastic:</strong> Meshtastic LoRa device, <code>pip install meshtastic</code></li>
|
<li><strong>Meshtastic:</strong> Meshtastic LoRa device, <code>pip install meshtastic</code></li>
|
||||||
<li><strong>WebSDR:</strong> Internet connection (remote receivers)</li>
|
<li><strong>WebSDR:</strong> Internet connection (remote receivers)</li>
|
||||||
<li><strong>SubGHz:</strong> RTL-SDR or compatible SDR hardware</li>
|
<li><strong>SubGHz:</strong> RTL-SDR or compatible SDR hardware</li>
|
||||||
<li><strong>Satellite:</strong> Internet for Celestrak (optional), skyfield</li>
|
<li><strong>Satellite:</strong> Internet for Celestrak (optional), skyfield</li>
|
||||||
<li><strong>ISS SSTV:</strong> RTL-SDR, slowrx</li>
|
<li><strong>ACARS:</strong> RTL-SDR, acarsdec</li>
|
||||||
|
<li><strong>VDL2:</strong> RTL-SDR, dumpvdl2</li>
|
||||||
|
<li><strong>ISS SSTV:</strong> RTL-SDR (pure Python decoder — no external tools needed)</li>
|
||||||
<li><strong>Weather Sat:</strong> RTL-SDR, SatDump</li>
|
<li><strong>Weather Sat:</strong> RTL-SDR, SatDump</li>
|
||||||
<li><strong>HF SSTV:</strong> RTL-SDR or SoapySDR-compatible hardware, slowrx</li>
|
<li><strong>HF SSTV:</strong> RTL-SDR or SoapySDR-compatible hardware (pure Python decoder)</li>
|
||||||
<li><strong>GPS:</strong> RTL-SDR or GPS-capable SDR</li>
|
<li><strong>GPS:</strong> RTL-SDR or GPS-capable SDR</li>
|
||||||
<li><strong>Space Weather:</strong> Internet connection (public APIs)</li>
|
<li><strong>Space Weather:</strong> Internet connection (public APIs)</li>
|
||||||
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>
|
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
<!-- ANALYTICS MODE -->
|
|
||||||
<div id="analyticsMode" class="mode-content">
|
|
||||||
{# Analytics Dashboard Sidebar Panel #}
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
|
||||||
<span>Summary</span>
|
|
||||||
<span class="collapse-icon">▼</span>
|
|
||||||
</h3>
|
|
||||||
<div class="section-content">
|
|
||||||
<div class="analytics-grid" id="analyticsSummaryCards">
|
|
||||||
<div class="analytics-card" data-mode="adsb">
|
|
||||||
<div class="card-count" id="analyticsCountAdsb">0</div>
|
|
||||||
<div class="card-label">Aircraft</div>
|
|
||||||
<div class="card-sparkline" id="analyticsSparkAdsb"></div>
|
|
||||||
</div>
|
|
||||||
<div class="analytics-card" data-mode="ais">
|
|
||||||
<div class="card-count" id="analyticsCountAis">0</div>
|
|
||||||
<div class="card-label">Vessels</div>
|
|
||||||
<div class="card-sparkline" id="analyticsSparkAis"></div>
|
|
||||||
</div>
|
|
||||||
<div class="analytics-card" data-mode="wifi">
|
|
||||||
<div class="card-count" id="analyticsCountWifi">0</div>
|
|
||||||
<div class="card-label">WiFi</div>
|
|
||||||
<div class="card-sparkline" id="analyticsSparkWifi"></div>
|
|
||||||
</div>
|
|
||||||
<div class="analytics-card" data-mode="bluetooth">
|
|
||||||
<div class="card-count" id="analyticsCountBt">0</div>
|
|
||||||
<div class="card-label">Bluetooth</div>
|
|
||||||
<div class="card-sparkline" id="analyticsSparkBt"></div>
|
|
||||||
</div>
|
|
||||||
<div class="analytics-card" data-mode="dsc">
|
|
||||||
<div class="card-count" id="analyticsCountDsc">0</div>
|
|
||||||
<div class="card-label">DSC</div>
|
|
||||||
<div class="card-sparkline" id="analyticsSparkDsc"></div>
|
|
||||||
</div>
|
|
||||||
<div class="analytics-card" data-mode="acars">
|
|
||||||
<div class="card-count" id="analyticsCountAcars">0</div>
|
|
||||||
<div class="card-label">ACARS</div>
|
|
||||||
<div class="card-sparkline" id="analyticsSparkAcars"></div>
|
|
||||||
</div>
|
|
||||||
<div class="analytics-card" data-mode="vdl2">
|
|
||||||
<div class="card-count" id="analyticsCountVdl2">0</div>
|
|
||||||
<div class="card-label">VDL2</div>
|
|
||||||
<div class="card-sparkline" id="analyticsSparkVdl2"></div>
|
|
||||||
</div>
|
|
||||||
<div class="analytics-card" data-mode="aprs">
|
|
||||||
<div class="card-count" id="analyticsCountAprs">0</div>
|
|
||||||
<div class="card-label">APRS</div>
|
|
||||||
<div class="card-sparkline" id="analyticsSparkAprs"></div>
|
|
||||||
</div>
|
|
||||||
<div class="analytics-card" data-mode="meshtastic">
|
|
||||||
<div class="card-count" id="analyticsCountMesh">0</div>
|
|
||||||
<div class="card-label">Mesh</div>
|
|
||||||
<div class="card-sparkline" id="analyticsSparkMesh"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
|
||||||
<span>Operational Insights</span>
|
|
||||||
<span class="collapse-icon">▼</span>
|
|
||||||
</h3>
|
|
||||||
<div class="section-content">
|
|
||||||
<div class="analytics-insight-grid" id="analyticsInsights">
|
|
||||||
<div class="analytics-empty">Insights loading...</div>
|
|
||||||
</div>
|
|
||||||
<div class="analytics-top-changes">
|
|
||||||
<div class="analytics-section-header">Top Changes</div>
|
|
||||||
<div id="analyticsTopChanges">
|
|
||||||
<div class="analytics-empty">No change signals yet</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
|
||||||
<span>Mode Health</span>
|
|
||||||
<span class="collapse-icon">▼</span>
|
|
||||||
</h3>
|
|
||||||
<div class="section-content">
|
|
||||||
<div class="analytics-health" id="analyticsHealth"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section" id="analyticsSquawkSection" style="display:none;">
|
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
|
||||||
<span>Emergency Squawks</span>
|
|
||||||
<span class="collapse-icon">▼</span>
|
|
||||||
</h3>
|
|
||||||
<div class="section-content">
|
|
||||||
<div class="squawk-emergency" id="analyticsSquawkPanel">
|
|
||||||
<div class="squawk-title">Active Emergency Codes</div>
|
|
||||||
<div id="analyticsSquawkList"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
|
||||||
<span>Temporal Patterns</span>
|
|
||||||
<span class="collapse-icon">▼</span>
|
|
||||||
</h3>
|
|
||||||
<div class="section-content">
|
|
||||||
<div id="analyticsPatternList">
|
|
||||||
<div class="analytics-empty">No recurring patterns detected</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
|
||||||
<span>Recent Alerts</span>
|
|
||||||
<span class="collapse-icon">▼</span>
|
|
||||||
</h3>
|
|
||||||
<div class="section-content">
|
|
||||||
<div class="analytics-alert-feed" id="analyticsAlertFeed">
|
|
||||||
<div class="analytics-empty">No recent alerts</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
|
||||||
<span>Correlations</span>
|
|
||||||
<span class="collapse-icon">▼</span>
|
|
||||||
</h3>
|
|
||||||
<div class="section-content">
|
|
||||||
<div id="analyticsCorrelations">
|
|
||||||
<div class="analytics-empty">No correlations detected</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
|
||||||
<span>Geofences</span>
|
|
||||||
<span class="collapse-icon">▼</span>
|
|
||||||
</h3>
|
|
||||||
<div class="section-content">
|
|
||||||
<div id="analyticsGeofenceList"></div>
|
|
||||||
<button class="btn btn-sm" onclick="Analytics.addGeofence()" style="margin-top:8px; font-size:10px; padding:4px 10px; background:var(--accent-cyan); color:#fff; border:none; border-radius:4px; cursor:pointer;">
|
|
||||||
+ Add Zone
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
|
||||||
<span>Target View</span>
|
|
||||||
<span class="collapse-icon">▼</span>
|
|
||||||
</h3>
|
|
||||||
<div class="section-content">
|
|
||||||
<div class="analytics-target-toolbar">
|
|
||||||
<input id="analyticsTargetQuery" type="text" placeholder="Search callsign, ICAO, MMSI, MAC, SSID, node..." onkeydown="if(event.key==='Enter'){Analytics.searchTarget();}">
|
|
||||||
<button onclick="Analytics.searchTarget()">Search</button>
|
|
||||||
</div>
|
|
||||||
<div id="analyticsTargetSummary" class="analytics-target-summary">Search to correlate entities across modes</div>
|
|
||||||
<div id="analyticsTargetResults">
|
|
||||||
<div class="analytics-empty">No target selected</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
|
||||||
<span>Session Replay</span>
|
|
||||||
<span class="collapse-icon">▼</span>
|
|
||||||
</h3>
|
|
||||||
<div class="section-content">
|
|
||||||
<div class="analytics-replay-toolbar">
|
|
||||||
<select id="analyticsReplaySelect"></select>
|
|
||||||
<button onclick="Analytics.loadReplay()">Load</button>
|
|
||||||
<button onclick="Analytics.playReplay()">Play</button>
|
|
||||||
<button onclick="Analytics.pauseReplay()">Pause</button>
|
|
||||||
<button onclick="Analytics.stepReplay()">Step</button>
|
|
||||||
</div>
|
|
||||||
<div id="analyticsReplayMeta" class="analytics-target-summary">No replay loaded</div>
|
|
||||||
<div id="analyticsReplayTimeline">
|
|
||||||
<div class="analytics-empty">Select a recording to replay key events</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
|
||||||
<span>Export Data</span>
|
|
||||||
<span class="collapse-icon">▼</span>
|
|
||||||
</h3>
|
|
||||||
<div class="section-content">
|
|
||||||
<div class="export-controls">
|
|
||||||
<select id="exportMode">
|
|
||||||
<option value="adsb">ADS-B</option>
|
|
||||||
<option value="ais">AIS</option>
|
|
||||||
<option value="wifi">WiFi</option>
|
|
||||||
<option value="bluetooth">Bluetooth</option>
|
|
||||||
<option value="dsc">DSC</option>
|
|
||||||
</select>
|
|
||||||
<select id="exportFormat">
|
|
||||||
<option value="json">JSON</option>
|
|
||||||
<option value="csv">CSV</option>
|
|
||||||
</select>
|
|
||||||
<button onclick="Analytics.exportData()">Export</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<!-- LISTENING POST MODE -->
|
|
||||||
<div id="listeningPostMode" class="mode-content">
|
|
||||||
<div class="section">
|
|
||||||
<h3>Status</h3>
|
|
||||||
|
|
||||||
<!-- Dependency Warning -->
|
|
||||||
<div id="scannerToolsWarning" style="display: none; background: rgba(255, 100, 100, 0.1); border: 1px solid var(--accent-red); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
|
||||||
<p style="color: var(--accent-red); margin: 0; font-size: 0.85em;">
|
|
||||||
<strong>Missing:</strong><br>
|
|
||||||
<span id="scannerToolsWarningText"></span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick Status -->
|
|
||||||
<div style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 12px;">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
|
||||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Status</span>
|
|
||||||
<span id="lpQuickStatus" style="font-size: 11px; color: var(--accent-cyan);">IDLE</span>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
|
||||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Frequency</span>
|
|
||||||
<span id="lpQuickFreq" style="font-size: 14px; font-family: var(--font-mono); color: var(--text-primary);">---.--- MHz</span>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
||||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Signals</span>
|
|
||||||
<span id="lpQuickSignals" style="font-size: 14px; font-weight: bold; color: var(--accent-green);">0</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3>Bookmarks</h3>
|
|
||||||
<div style="display: flex; gap: 4px; margin-bottom: 8px;">
|
|
||||||
<input type="text" id="bookmarkFreqInput" placeholder="Freq (MHz)" style="flex: 1; padding: 6px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
|
||||||
<button class="preset-btn" onclick="addFrequencyBookmark()" style="background: var(--accent-green); color: #000; padding: 6px 10px;">+</button>
|
|
||||||
</div>
|
|
||||||
<div id="bookmarksList" style="max-height: 150px; overflow-y: auto; font-size: 11px;">
|
|
||||||
<div style="color: var(--text-muted); text-align: center; padding: 10px;">No bookmarks saved</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3>Recent Signals</h3>
|
|
||||||
<div id="sidebarRecentSignals" style="max-height: 150px; overflow-y: auto; font-size: 11px;">
|
|
||||||
<div style="color: var(--text-muted); text-align: center; padding: 10px;">No signals yet</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Signal Identification -->
|
|
||||||
<div class="section">
|
|
||||||
<h3>Signal Identification</h3>
|
|
||||||
<div style="display: flex; gap: 4px; margin-bottom: 8px;">
|
|
||||||
<input type="text" id="signalGuessFreqInput" placeholder="Freq (MHz)" style="flex: 1; padding: 6px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
|
||||||
<button class="preset-btn" onclick="manualSignalGuess()" style="background: var(--accent-cyan); color: #000; padding: 6px 10px; font-weight: 600;">ID</button>
|
|
||||||
</div>
|
|
||||||
<div id="signalGuessPanel" style="display: none; background: rgba(0,0,0,0.3); border-radius: 6px; padding: 10px; font-size: 11px;">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
|
||||||
<span id="signalGuessLabel" style="font-weight: bold; color: var(--text-primary);"></span>
|
|
||||||
<span id="signalGuessBadge" style="padding: 2px 8px; border-radius: 3px; font-size: 9px; font-weight: bold;"></span>
|
|
||||||
</div>
|
|
||||||
<div id="signalGuessExplanation" style="color: var(--text-muted); font-size: 10px; margin-bottom: 6px;"></div>
|
|
||||||
<div id="signalGuessTags" style="display: flex; flex-wrap: wrap; gap: 3px;"></div>
|
|
||||||
<div id="signalGuessAlternatives" style="margin-top: 6px; font-size: 10px; color: var(--text-muted);"></div>
|
|
||||||
<div id="signalGuessSendTo" style="margin-top: 8px; display: none;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
<!-- WATERFALL MODE -->
|
||||||
|
<div id="waterfallMode" class="mode-content wf-side">
|
||||||
|
<div class="section wf-side-hero">
|
||||||
|
<div class="wf-side-hero-title-row">
|
||||||
|
<div class="wf-side-hero-title">Spectrum Waterfall</div>
|
||||||
|
<div class="wf-side-chip" id="wfHeroVisualStatus">CONNECTING</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-hero-subtext">
|
||||||
|
Click spectrum/waterfall to tune. Scroll to step-tune. Ctrl/Cmd + scroll to zoom span.
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-hero-stats">
|
||||||
|
<div class="wf-side-stat">
|
||||||
|
<div class="wf-side-stat-label">Tuned</div>
|
||||||
|
<div class="wf-side-stat-value" id="wfHeroFreq">100.0000 MHz</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-stat">
|
||||||
|
<div class="wf-side-stat-label">Mode</div>
|
||||||
|
<div class="wf-side-stat-value" id="wfHeroMode">WFM</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-stat">
|
||||||
|
<div class="wf-side-stat-label">Scan</div>
|
||||||
|
<div class="wf-side-stat-value" id="wfHeroScan">Idle</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-stat">
|
||||||
|
<div class="wf-side-stat-label">Hits</div>
|
||||||
|
<div class="wf-side-stat-value" id="wfHeroHits">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-hero-actions">
|
||||||
|
<button class="run-btn" id="wfStartBtn" onclick="Waterfall.start()">Start Waterfall</button>
|
||||||
|
<button class="stop-btn" id="wfStopBtn" onclick="Waterfall.stop()" style="display:none;">Stop Waterfall</button>
|
||||||
|
</div>
|
||||||
|
<div id="wfStatus" class="wf-side-status-line"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Device</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>SDR Device</label>
|
||||||
|
<select id="wfDevice" onchange="Waterfall && Waterfall.onDeviceChange && Waterfall.onDeviceChange()">
|
||||||
|
<option value="">Loading devices...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="wfDeviceInfo" class="wf-side-box" style="display:none;">
|
||||||
|
<div class="wf-side-kv">
|
||||||
|
<span class="wf-side-kv-label">Type</span>
|
||||||
|
<span id="wfDeviceType" class="wf-side-kv-value">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-kv">
|
||||||
|
<span class="wf-side-kv-label">Range</span>
|
||||||
|
<span id="wfDeviceRange" class="wf-side-kv-value">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-kv">
|
||||||
|
<span class="wf-side-kv-label">Capture SR</span>
|
||||||
|
<span id="wfDeviceBw" class="wf-side-kv-value">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Tuning</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Center Frequency (MHz)</label>
|
||||||
|
<input type="number" id="wfCenterFreq" value="100.0000" step="0.001" min="0.001" max="6000">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Span (MHz)</label>
|
||||||
|
<input type="number" id="wfSpanMhz" value="2.4" step="0.1" min="0.05" max="30">
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-grid-2">
|
||||||
|
<button class="preset-btn" onclick="Waterfall.applyPreset('fm')">FM Broadcast</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.applyPreset('air')">Airband</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.applyPreset('marine')">Marine</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.applyPreset('ham2m')">2m Ham</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Quick Tune & Bookmarks</h3>
|
||||||
|
<div class="wf-side-grid-2">
|
||||||
|
<button class="preset-btn" onclick="Waterfall.quickTune(121.5, 'am')">121.5 Guard</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.quickTune(156.8, 'fm')">156.8 CH16</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.quickTune(145.5, 'fm')">145.5 2m</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.quickTune(98.1, 'wfm')">98.1 FM</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.quickTune(462.5625, 'fm')">462.56 FRS</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.quickTune(446.0, 'fm')">446.0 PMR</button>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-divider"></div>
|
||||||
|
<div class="wf-bookmark-row">
|
||||||
|
<input type="number" id="wfBookmarkFreqInput" step="0.0001" min="0.001" max="6000" placeholder="Frequency MHz">
|
||||||
|
<select id="wfBookmarkMode">
|
||||||
|
<option value="auto" selected>Auto</option>
|
||||||
|
<option value="wfm">WFM</option>
|
||||||
|
<option value="fm">NFM</option>
|
||||||
|
<option value="am">AM</option>
|
||||||
|
<option value="usb">USB</option>
|
||||||
|
<option value="lsb">LSB</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-grid-2">
|
||||||
|
<button class="preset-btn" onclick="Waterfall.useTuneForBookmark()">Use Tuned</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.addBookmarkFromInput()">Save Bookmark</button>
|
||||||
|
</div>
|
||||||
|
<div id="wfBookmarkList" class="wf-bookmark-list">
|
||||||
|
<div class="wf-empty">No bookmarks saved</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-inline-label">Recent Hits</div>
|
||||||
|
<div id="wfRecentSignals" class="wf-recent-list">
|
||||||
|
<div class="wf-empty">No recent signal hits</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Handoff</h3>
|
||||||
|
<div class="wf-side-help">
|
||||||
|
Send current tuned frequency to another decoder/workflow.
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-grid-2">
|
||||||
|
<button class="preset-btn" onclick="Waterfall.handoff('pager')">To Pager</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.handoff('subghz')">To SubGHz</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.handoff('subghz433')">433 Profile</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.handoff('signalid')">Signal ID</button>
|
||||||
|
</div>
|
||||||
|
<div id="wfHandoffStatus" class="wf-side-status-line">Ready</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Signal Identification</h3>
|
||||||
|
<div class="wf-side-help">
|
||||||
|
Identify current frequency using local catalog and SigID Wiki matches.
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Frequency (MHz)</label>
|
||||||
|
<input type="number" id="wfSigIdFreq" value="100.0000" step="0.0001" min="0.001" max="6000">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Mode Hint</label>
|
||||||
|
<select id="wfSigIdMode">
|
||||||
|
<option value="auto" selected>Auto (Current Mode)</option>
|
||||||
|
<option value="wfm">WFM</option>
|
||||||
|
<option value="fm">NFM</option>
|
||||||
|
<option value="am">AM</option>
|
||||||
|
<option value="usb">USB</option>
|
||||||
|
<option value="lsb">LSB</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-grid-2">
|
||||||
|
<button class="preset-btn" onclick="Waterfall.useTuneForSignalId()">Use Tuned</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.identifySignal()">Identify</button>
|
||||||
|
</div>
|
||||||
|
<div id="wfSigIdStatus" class="wf-side-status-line">Ready</div>
|
||||||
|
<div id="wfSigIdResult" class="wf-side-box" style="display:none;"></div>
|
||||||
|
<div id="wfSigIdExternal" class="wf-side-box wf-side-box-muted" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Scan</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Range Start (MHz)</label>
|
||||||
|
<input type="number" id="wfScanStart" value="98.8000" step="0.001" min="0.001" max="6000">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Range End (MHz)</label>
|
||||||
|
<input type="number" id="wfScanEnd" value="101.2000" step="0.001" min="0.001" max="6000">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Step (kHz)</label>
|
||||||
|
<select id="wfScanStepKhz">
|
||||||
|
<option value="5">5</option>
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="12.5">12.5</option>
|
||||||
|
<option value="25">25</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100" selected>100</option>
|
||||||
|
<option value="200">200</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Dwell (ms)</label>
|
||||||
|
<input type="number" id="wfScanDwellMs" value="450" min="60" max="10000" step="10">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Signal Threshold <span id="wfScanThresholdValue" class="wf-inline-value">170</span></label>
|
||||||
|
<input type="range" id="wfScanThreshold" min="0" max="255" value="170">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Hold On Hit (ms)</label>
|
||||||
|
<input type="number" id="wfScanHoldMs" value="2500" min="0" max="30000" step="100">
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-group wf-scan-checkboxes">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="wfScanStopOnSignal" checked>
|
||||||
|
Pause scan when signal is above threshold
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-grid-2 wf-side-grid-gap-top">
|
||||||
|
<button class="preset-btn" onclick="Waterfall.setScanRangeFromView()">Use Current Span</button>
|
||||||
|
<button class="preset-btn" id="wfScanStartBtn" onclick="Waterfall.startScan()">Start Scan</button>
|
||||||
|
<button class="preset-btn" id="wfScanStopBtn" onclick="Waterfall.stopScan()" disabled>Stop Scan</button>
|
||||||
|
</div>
|
||||||
|
<div id="wfScanState" class="wf-side-status-line">Scan idle</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Scan Activity</h3>
|
||||||
|
<div class="wf-scan-metric-grid">
|
||||||
|
<div class="wf-scan-metric-card">
|
||||||
|
<div class="wf-scan-metric-label">Signals</div>
|
||||||
|
<div class="wf-scan-metric-value" id="wfScanSignalsCount">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-scan-metric-card">
|
||||||
|
<div class="wf-scan-metric-label">Scanned</div>
|
||||||
|
<div class="wf-scan-metric-value" id="wfScanStepsCount">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-scan-metric-card">
|
||||||
|
<div class="wf-scan-metric-label">Cycles</div>
|
||||||
|
<div class="wf-scan-metric-value" id="wfScanCyclesCount">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-grid-2 wf-side-grid-gap-top">
|
||||||
|
<button class="preset-btn" onclick="Waterfall.exportScanLog()">Export Log</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.clearScanHistory()">Clear History</button>
|
||||||
|
</div>
|
||||||
|
<div class="wf-hit-table-wrap">
|
||||||
|
<table class="wf-hit-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Frequency</th>
|
||||||
|
<th>Level</th>
|
||||||
|
<th>Mode</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="wfSignalHitsBody">
|
||||||
|
<tr><td colspan="5" class="wf-empty">No signals detected</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="wfSignalHitCount" class="wf-side-inline-label">0 signals found</div>
|
||||||
|
<div id="wfActivityLog" class="wf-activity-log">
|
||||||
|
<div class="wf-empty">Ready</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Capture</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Gain <span class="wf-inline-value">(dB or AUTO)</span></label>
|
||||||
|
<input type="text" id="wfGain" value="AUTO" placeholder="AUTO or numeric">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>FFT Size</label>
|
||||||
|
<select id="wfFftSize">
|
||||||
|
<option value="256">256</option>
|
||||||
|
<option value="512">512</option>
|
||||||
|
<option value="1024" selected>1024</option>
|
||||||
|
<option value="2048">2048</option>
|
||||||
|
<option value="4096">4096</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Frame Rate</label>
|
||||||
|
<select id="wfFps">
|
||||||
|
<option value="10">10 fps</option>
|
||||||
|
<option value="20" selected>20 fps</option>
|
||||||
|
<option value="30">30 fps</option>
|
||||||
|
<option value="40">40 fps</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>FFT Averaging</label>
|
||||||
|
<select id="wfAvgCount">
|
||||||
|
<option value="1">1 (none)</option>
|
||||||
|
<option value="2">2</option>
|
||||||
|
<option value="4" selected>4</option>
|
||||||
|
<option value="8">8</option>
|
||||||
|
<option value="16">16</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>PPM Correction</label>
|
||||||
|
<input type="number" id="wfPpm" value="0" step="1" min="-200" max="200" placeholder="0">
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-group wf-scan-checkboxes">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="wfBiasT">
|
||||||
|
Bias-T (antenna power)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Display</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Color Palette</label>
|
||||||
|
<select id="wfPalette" onchange="Waterfall.setPalette(this.value)">
|
||||||
|
<option value="turbo" selected>Turbo</option>
|
||||||
|
<option value="plasma">Plasma</option>
|
||||||
|
<option value="inferno">Inferno</option>
|
||||||
|
<option value="viridis">Viridis</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Noise Floor (dB)</label>
|
||||||
|
<input type="number" id="wfDbMin" value="-100" step="5" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Ceiling (dB)</label>
|
||||||
|
<input type="number" id="wfDbMax" value="-20" step="5" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-group wf-scan-checkboxes">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="wfPeakHold" onchange="Waterfall.togglePeakHold(this.checked)">
|
||||||
|
Peak Hold
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="wfBandAnnotations" checked onchange="Waterfall.toggleAnnotations(this.checked)">
|
||||||
|
Band Labels
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="wfAutoRange" checked onchange="Waterfall.toggleAutoRange(this.checked)">
|
||||||
|
Auto Range
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -65,8 +65,8 @@
|
|||||||
{{ mode_item('pager', 'Pager', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg>') }}
|
{{ mode_item('pager', 'Pager', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg>') }}
|
||||||
{{ mode_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
{{ mode_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
||||||
{{ mode_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
{{ mode_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
||||||
{{ mode_item('listening', 'Listening Post', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
|
|
||||||
{{ mode_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
|
{{ mode_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
|
||||||
|
{{ mode_item('waterfall', 'Waterfall', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M2 18h20" opacity="0.4"/><path d="M2 21h20" opacity="0.2"/></svg>') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -133,7 +133,6 @@
|
|||||||
|
|
||||||
<div class="mode-nav-dropdown-menu">
|
<div class="mode-nav-dropdown-menu">
|
||||||
{{ mode_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
{{ mode_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
||||||
{{ mode_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
|
|
||||||
{{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
{{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||||
{{ mode_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>') }}
|
{{ mode_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -177,6 +176,15 @@
|
|||||||
<button type="button" class="nav-tool-btn" onclick="showSettings()" title="Settings" aria-label="Open settings">
|
<button type="button" class="nav-tool-btn" onclick="showSettings()" title="Settings" aria-label="Open settings">
|
||||||
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="nav-tool-btn" id="voiceMuteBtn" onclick="window.VoiceAlerts && VoiceAlerts.toggleMute()" title="Toggle voice alerts" aria-label="Toggle voice alerts">
|
||||||
|
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg></span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="nav-tool-btn" onclick="window.CheatSheets && CheatSheets.showForCurrentMode()" title="Mode cheat sheet (Alt+C)" aria-label="Mode cheat sheet">
|
||||||
|
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="nav-tool-btn" onclick="window.KeyboardShortcuts && KeyboardShortcuts.showHelp()" title="Keyboard shortcuts (Alt+K)" aria-label="Keyboard shortcuts">
|
||||||
|
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M8 12h.01M12 12h.01M16 12h.01M7 16h10"/></svg></span>
|
||||||
|
</button>
|
||||||
<button type="button" class="nav-tool-btn" onclick="showHelp()" title="Help & Documentation" aria-label="Open help">?</button>
|
<button type="button" class="nav-tool-btn" onclick="showHelp()" title="Help & Documentation" aria-label="Open help">?</button>
|
||||||
<button type="button" class="nav-tool-btn" onclick="logout(event)" title="Logout" aria-label="Logout">
|
<button type="button" class="nav-tool-btn" onclick="logout(event)" title="Logout" aria-label="Logout">
|
||||||
<span class="power-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></span>
|
<span class="power-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></span>
|
||||||
@@ -191,7 +199,6 @@
|
|||||||
{{ mobile_item('pager', 'Pager', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg>') }}
|
{{ mobile_item('pager', 'Pager', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg>') }}
|
||||||
{{ mobile_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
{{ mobile_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
||||||
{{ mobile_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
{{ mobile_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
||||||
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
|
|
||||||
{{ mobile_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
|
{{ mobile_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
|
||||||
{# Tracking #}
|
{# Tracking #}
|
||||||
{{ mobile_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }}
|
{{ mobile_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }}
|
||||||
@@ -215,13 +222,70 @@
|
|||||||
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
|
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
|
||||||
{# Intel #}
|
{# Intel #}
|
||||||
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
||||||
{{ mobile_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
|
|
||||||
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||||
{{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>') }}
|
{{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>') }}
|
||||||
|
{# New modes #}
|
||||||
|
{{ mobile_item('waterfall', 'Waterfall', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h4l3-8 3 16 3-8h4"/></svg>') }}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{# JavaScript stub for pages that don't have switchMode defined #}
|
{# JavaScript stub for pages that don't have switchMode defined #}
|
||||||
<script>
|
<script>
|
||||||
|
(function () {
|
||||||
|
const NAV_PERF_KEY = 'intercept_nav_perf_v1';
|
||||||
|
const MAX_NAV_AGE_MS = 30000;
|
||||||
|
|
||||||
|
function parseNavPerf(raw) {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.InterceptNavPerf) {
|
||||||
|
window.InterceptNavPerf = {
|
||||||
|
markStart(meta = {}) {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
startedAtEpochMs: Date.now(),
|
||||||
|
sourcePath: window.location.pathname + window.location.search,
|
||||||
|
sourceMode: document.body?.getAttribute('data-mode') || null,
|
||||||
|
...meta,
|
||||||
|
};
|
||||||
|
sessionStorage.setItem(NAV_PERF_KEY, JSON.stringify(payload));
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore storage errors in private/incognito mode.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const payload = parseNavPerf(sessionStorage.getItem(NAV_PERF_KEY));
|
||||||
|
if (!payload || !payload.targetPath) return;
|
||||||
|
|
||||||
|
const ageMs = Date.now() - (payload.startedAtEpochMs || 0);
|
||||||
|
if (ageMs < 0 || ageMs > MAX_NAV_AGE_MS) {
|
||||||
|
try { sessionStorage.removeItem(NAV_PERF_KEY); } catch (_) { }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.location.pathname !== payload.targetPath) return;
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
`[Perf] Nav ${payload.sourcePath || '(unknown)'} -> ${payload.targetPath} in ${Math.round(ageMs)}ms`,
|
||||||
|
{
|
||||||
|
trigger: payload.trigger || 'unknown',
|
||||||
|
sourceMode: payload.sourceMode || null,
|
||||||
|
activeScans: payload.activeScans || null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
try { sessionStorage.removeItem(NAV_PERF_KEY); } catch (_) { }
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
// Ensure navigation functions exist (for dashboard pages that don't have the full JS)
|
// Ensure navigation functions exist (for dashboard pages that don't have the full JS)
|
||||||
if (typeof switchMode === 'undefined') {
|
if (typeof switchMode === 'undefined') {
|
||||||
window.switchMode = function(mode) {
|
window.switchMode = function(mode) {
|
||||||
|
|||||||
@@ -284,6 +284,93 @@
|
|||||||
|
|
||||||
<!-- Alerts Section -->
|
<!-- Alerts Section -->
|
||||||
<div id="settings-alerts" class="settings-section">
|
<div id="settings-alerts" class="settings-section">
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Voice Alerts</div>
|
||||||
|
<p style="color: var(--text-dim); margin-bottom: 10px; font-size: 12px;">
|
||||||
|
Configure which events trigger spoken alerts and adjust voice settings.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Pager Messages</span>
|
||||||
|
<span class="settings-label-desc">Speak decoded pager messages</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="voiceCfgPager" checked onchange="saveVoiceAlertConfig()">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">TSCM Alerts</span>
|
||||||
|
<span class="settings-label-desc">Speak counter-surveillance detections</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="voiceCfgTscm" checked onchange="saveVoiceAlertConfig()">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Tracker Detection</span>
|
||||||
|
<span class="settings-label-desc">Speak when AirTag, Tile, or SmartTag found</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="voiceCfgTracker" checked onchange="saveVoiceAlertConfig()">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Emergency Squawks</span>
|
||||||
|
<span class="settings-label-desc">Speak aircraft emergency transponder codes</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="voiceCfgSquawk" checked onchange="saveVoiceAlertConfig()">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Voice</span>
|
||||||
|
<span class="settings-label-desc">Speech synthesis voice</span>
|
||||||
|
</div>
|
||||||
|
<select id="voiceCfgVoice" class="settings-select" style="width: 200px;" onchange="saveVoiceAlertConfig()">
|
||||||
|
<option value="">Default</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Rate</span>
|
||||||
|
<span class="settings-label-desc">Speech speed (0.5 – 2.0)</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; width:200px;">
|
||||||
|
<input type="range" id="voiceCfgRate" min="0.5" max="2.0" step="0.1" value="1.1" style="flex:1;" oninput="document.getElementById('voiceCfgRateVal').textContent=this.value; saveVoiceAlertConfig();">
|
||||||
|
<span id="voiceCfgRateVal" style="font-family:var(--font-mono); font-size:11px; color:var(--text-dim); min-width:28px; text-align:right;">1.1</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Pitch</span>
|
||||||
|
<span class="settings-label-desc">Voice pitch (0.5 – 2.0)</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; width:200px;">
|
||||||
|
<input type="range" id="voiceCfgPitch" min="0.5" max="2.0" step="0.1" value="0.9" style="flex:1;" oninput="document.getElementById('voiceCfgPitchVal').textContent=this.value; saveVoiceAlertConfig();">
|
||||||
|
<span id="voiceCfgPitchVal" style="font-family:var(--font-mono); font-size:11px; color:var(--text-dim); min-width:28px; text-align:right;">0.9</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 8px;">
|
||||||
|
<button class="check-assets-btn" onclick="testVoiceAlert()">Test Voice</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<div class="settings-group-title">Alert Feed <span id="alertsFeedCount" style="color: var(--text-dim); font-weight: 500;"></span></div>
|
<div class="settings-group-title">Alert Feed <span id="alertsFeedCount" style="color: var(--text-dim); font-weight: 500;"></span></div>
|
||||||
<div id="alertsFeedList" class="settings-feed">
|
<div id="alertsFeedList" class="settings-feed">
|
||||||
@@ -316,7 +403,6 @@
|
|||||||
<option value="acars">ACARS</option>
|
<option value="acars">ACARS</option>
|
||||||
<option value="vdl2">VDL2</option>
|
<option value="vdl2">VDL2</option>
|
||||||
<option value="aprs">APRS</option>
|
<option value="aprs">APRS</option>
|
||||||
<option value="dsc">DSC</option>
|
|
||||||
<option value="meshtastic">Meshtastic</option>
|
<option value="meshtastic">Meshtastic</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -392,14 +478,12 @@
|
|||||||
<option value="bluetooth">Bluetooth</option>
|
<option value="bluetooth">Bluetooth</option>
|
||||||
<option value="adsb">ADS-B</option>
|
<option value="adsb">ADS-B</option>
|
||||||
<option value="ais">AIS</option>
|
<option value="ais">AIS</option>
|
||||||
<option value="dsc">DSC</option>
|
|
||||||
<option value="acars">ACARS</option>
|
<option value="acars">ACARS</option>
|
||||||
<option value="aprs">APRS</option>
|
<option value="aprs">APRS</option>
|
||||||
<option value="rtlamr">RTLAMR</option>
|
<option value="rtlamr">RTLAMR</option>
|
||||||
<option value="tscm">TSCM</option>
|
<option value="tscm">TSCM</option>
|
||||||
<option value="sstv">SSTV</option>
|
<option value="sstv">SSTV</option>
|
||||||
<option value="sstv_general">SSTV General</option>
|
<option value="sstv_general">SSTV General</option>
|
||||||
<option value="listening_scanner">Listening Post</option>
|
|
||||||
<option value="waterfall">Waterfall</option>
|
<option value="waterfall">Waterfall</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
"""Tests for analytics endpoints, export, and squawk detection."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='session')
|
|
||||||
def app():
|
|
||||||
"""Create application for testing."""
|
|
||||||
import app as app_module
|
|
||||||
import utils.database as db_mod
|
|
||||||
from routes import register_blueprints
|
|
||||||
|
|
||||||
app_module.app.config['TESTING'] = True
|
|
||||||
|
|
||||||
# Use temp directory for test database
|
|
||||||
tmp_dir = Path(tempfile.mkdtemp())
|
|
||||||
db_mod.DB_DIR = tmp_dir
|
|
||||||
db_mod.DB_PATH = tmp_dir / 'test_intercept.db'
|
|
||||||
# Reset thread-local connection so it picks up new path
|
|
||||||
if hasattr(db_mod._local, 'connection') and db_mod._local.connection:
|
|
||||||
db_mod._local.connection.close()
|
|
||||||
db_mod._local.connection = None
|
|
||||||
|
|
||||||
db_mod.init_db()
|
|
||||||
|
|
||||||
if 'pager' not in app_module.app.blueprints:
|
|
||||||
register_blueprints(app_module.app)
|
|
||||||
|
|
||||||
return app_module.app
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client(app):
|
|
||||||
client = app.test_client()
|
|
||||||
# Set session login to bypass require_login before_request hook
|
|
||||||
with client.session_transaction() as sess:
|
|
||||||
sess['logged_in'] = True
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
class TestAnalyticsSummary:
|
|
||||||
"""Tests for /analytics/summary endpoint."""
|
|
||||||
|
|
||||||
def test_summary_returns_json(self, client):
|
|
||||||
response = client.get('/analytics/summary')
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['status'] == 'success'
|
|
||||||
assert 'counts' in data
|
|
||||||
assert 'health' in data
|
|
||||||
assert 'squawks' in data
|
|
||||||
|
|
||||||
def test_summary_counts_structure(self, client):
|
|
||||||
response = client.get('/analytics/summary')
|
|
||||||
data = json.loads(response.data)
|
|
||||||
counts = data['counts']
|
|
||||||
assert 'adsb' in counts
|
|
||||||
assert 'ais' in counts
|
|
||||||
assert 'wifi' in counts
|
|
||||||
assert 'bluetooth' in counts
|
|
||||||
assert 'dsc' in counts
|
|
||||||
# All should be integers
|
|
||||||
for val in counts.values():
|
|
||||||
assert isinstance(val, int)
|
|
||||||
|
|
||||||
def test_summary_health_structure(self, client):
|
|
||||||
response = client.get('/analytics/summary')
|
|
||||||
data = json.loads(response.data)
|
|
||||||
health = data['health']
|
|
||||||
# Should have process statuses
|
|
||||||
assert 'pager' in health
|
|
||||||
assert 'sensor' in health
|
|
||||||
assert 'adsb' in health
|
|
||||||
# Each should have a running flag
|
|
||||||
for mode_info in health.values():
|
|
||||||
if isinstance(mode_info, dict) and 'running' in mode_info:
|
|
||||||
assert isinstance(mode_info['running'], bool)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAnalyticsExport:
|
|
||||||
"""Tests for /analytics/export/<mode> endpoint."""
|
|
||||||
|
|
||||||
def test_export_adsb_json(self, client):
|
|
||||||
response = client.get('/analytics/export/adsb?format=json')
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['status'] == 'success'
|
|
||||||
assert data['mode'] == 'adsb'
|
|
||||||
assert 'data' in data
|
|
||||||
assert isinstance(data['data'], list)
|
|
||||||
|
|
||||||
def test_export_adsb_csv(self, client):
|
|
||||||
response = client.get('/analytics/export/adsb?format=csv')
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.content_type.startswith('text/csv')
|
|
||||||
assert 'Content-Disposition' in response.headers
|
|
||||||
|
|
||||||
def test_export_invalid_mode(self, client):
|
|
||||||
response = client.get('/analytics/export/invalid_mode')
|
|
||||||
assert response.status_code == 400
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['status'] == 'error'
|
|
||||||
|
|
||||||
def test_export_wifi_json(self, client):
|
|
||||||
response = client.get('/analytics/export/wifi?format=json')
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['status'] == 'success'
|
|
||||||
assert data['mode'] == 'wifi'
|
|
||||||
|
|
||||||
|
|
||||||
class TestAnalyticsSquawks:
|
|
||||||
"""Tests for squawk detection."""
|
|
||||||
|
|
||||||
def test_squawks_endpoint(self, client):
|
|
||||||
response = client.get('/analytics/squawks')
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['status'] == 'success'
|
|
||||||
assert isinstance(data['squawks'], list)
|
|
||||||
|
|
||||||
def test_get_emergency_squawks_detects_7700(self):
|
|
||||||
from utils.analytics import get_emergency_squawks
|
|
||||||
|
|
||||||
# Mock the adsb_aircraft DataStore
|
|
||||||
mock_store = MagicMock()
|
|
||||||
mock_store.items.return_value = [
|
|
||||||
('ABC123', {'squawk': '7700', 'callsign': 'TEST01', 'altitude': 35000}),
|
|
||||||
('DEF456', {'squawk': '1200', 'callsign': 'TEST02'}),
|
|
||||||
]
|
|
||||||
|
|
||||||
with patch('utils.analytics.app_module') as mock_app:
|
|
||||||
mock_app.adsb_aircraft = mock_store
|
|
||||||
squawks = get_emergency_squawks()
|
|
||||||
|
|
||||||
assert len(squawks) == 1
|
|
||||||
assert squawks[0]['squawk'] == '7700'
|
|
||||||
assert squawks[0]['meaning'] == 'General Emergency'
|
|
||||||
assert squawks[0]['icao'] == 'ABC123'
|
|
||||||
|
|
||||||
|
|
||||||
class TestGeofenceCRUD:
|
|
||||||
"""Tests for geofence CRUD endpoints."""
|
|
||||||
|
|
||||||
def test_list_geofences(self, client):
|
|
||||||
response = client.get('/analytics/geofences')
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['status'] == 'success'
|
|
||||||
assert isinstance(data['zones'], list)
|
|
||||||
|
|
||||||
def test_create_geofence(self, client):
|
|
||||||
response = client.post('/analytics/geofences',
|
|
||||||
data=json.dumps({
|
|
||||||
'name': 'Test Zone',
|
|
||||||
'lat': 51.5074,
|
|
||||||
'lon': -0.1278,
|
|
||||||
'radius_m': 500,
|
|
||||||
}),
|
|
||||||
content_type='application/json')
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['status'] == 'success'
|
|
||||||
assert 'zone_id' in data
|
|
||||||
|
|
||||||
def test_create_geofence_missing_fields(self, client):
|
|
||||||
response = client.post('/analytics/geofences',
|
|
||||||
data=json.dumps({'name': 'No coords'}),
|
|
||||||
content_type='application/json')
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
def test_create_geofence_invalid_coords(self, client):
|
|
||||||
response = client.post('/analytics/geofences',
|
|
||||||
data=json.dumps({
|
|
||||||
'name': 'Bad',
|
|
||||||
'lat': 100,
|
|
||||||
'lon': 0,
|
|
||||||
'radius_m': 100,
|
|
||||||
}),
|
|
||||||
content_type='application/json')
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
def test_delete_geofence_not_found(self, client):
|
|
||||||
response = client.delete('/analytics/geofences/99999')
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
class TestAnalyticsActivity:
|
|
||||||
"""Tests for /analytics/activity endpoint."""
|
|
||||||
|
|
||||||
def test_activity_returns_sparklines(self, client):
|
|
||||||
response = client.get('/analytics/activity')
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['status'] == 'success'
|
|
||||||
assert 'sparklines' in data
|
|
||||||
assert isinstance(data['sparklines'], dict)
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Tests for pager scope waveform payload generation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import queue
|
||||||
|
import struct
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from routes.pager import _encode_scope_waveform, audio_relay_thread
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_scope_waveform_respects_window_and_range():
|
||||||
|
samples = (-32768, -16384, 0, 16384, 32767)
|
||||||
|
waveform = _encode_scope_waveform(samples, window_size=4)
|
||||||
|
|
||||||
|
assert len(waveform) == 4
|
||||||
|
assert waveform[0] == -64
|
||||||
|
assert waveform[1] == 0
|
||||||
|
assert waveform[2] == 64
|
||||||
|
assert waveform[3] == 127
|
||||||
|
assert max(waveform) <= 127
|
||||||
|
assert min(waveform) >= -127
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_relay_thread_emits_scope_waveform(monkeypatch):
|
||||||
|
base_samples = (0, 32767, -32768, 16384) * 512
|
||||||
|
pcm = struct.pack(f"<{len(base_samples)}h", *base_samples)
|
||||||
|
|
||||||
|
rtl_stdout = io.BytesIO(pcm)
|
||||||
|
multimon_stdin = io.BytesIO()
|
||||||
|
output_queue: queue.Queue = queue.Queue()
|
||||||
|
stop_event = threading.Event()
|
||||||
|
|
||||||
|
ticks = iter([0.0, 0.2, 0.2, 0.2])
|
||||||
|
monkeypatch.setattr("routes.pager.time.monotonic", lambda: next(ticks, 0.2))
|
||||||
|
|
||||||
|
audio_relay_thread(rtl_stdout, multimon_stdin, output_queue, stop_event)
|
||||||
|
|
||||||
|
scope_event = output_queue.get_nowait()
|
||||||
|
assert scope_event["type"] == "scope"
|
||||||
|
assert scope_event["rms"] > 0
|
||||||
|
assert scope_event["peak"] > 0
|
||||||
|
assert "waveform" in scope_event
|
||||||
|
assert len(scope_event["waveform"]) > 0
|
||||||
|
assert max(scope_event["waveform"]) <= 127
|
||||||
|
assert min(scope_event["waveform"]) >= -127
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Tests for RTL-SDR detection parsing."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from utils.sdr.base import SDRType
|
||||||
|
from utils.sdr.detection import detect_rtlsdr_devices
|
||||||
|
|
||||||
|
|
||||||
|
@patch('utils.sdr.detection._check_tool', return_value=True)
|
||||||
|
@patch('utils.sdr.detection.subprocess.run')
|
||||||
|
def test_detect_rtlsdr_devices_filters_empty_serial_entries(mock_run, _mock_check_tool):
|
||||||
|
"""Ignore malformed rtl_test rows that have an empty SN field."""
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.stdout = ""
|
||||||
|
mock_result.stderr = (
|
||||||
|
"Found 3 device(s):\n"
|
||||||
|
" 0: ??C?, , SN:\n"
|
||||||
|
" 1: ??C?, , SN:\n"
|
||||||
|
" 2: RTLSDRBlog, Blog V4, SN: 1\n"
|
||||||
|
)
|
||||||
|
mock_run.return_value = mock_result
|
||||||
|
|
||||||
|
devices = detect_rtlsdr_devices()
|
||||||
|
|
||||||
|
assert len(devices) == 1
|
||||||
|
assert devices[0].sdr_type == SDRType.RTL_SDR
|
||||||
|
assert devices[0].index == 2
|
||||||
|
assert devices[0].name == "RTLSDRBlog, Blog V4"
|
||||||
|
assert devices[0].serial == "1"
|
||||||
|
|
||||||
|
|
||||||
|
@patch('utils.sdr.detection._check_tool', return_value=True)
|
||||||
|
@patch('utils.sdr.detection.subprocess.run')
|
||||||
|
def test_detect_rtlsdr_devices_uses_replace_decode_mode(mock_run, _mock_check_tool):
|
||||||
|
"""Run rtl_test with tolerant decoding for malformed output bytes."""
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.stdout = ""
|
||||||
|
mock_result.stderr = "Found 0 device(s):"
|
||||||
|
mock_run.return_value = mock_result
|
||||||
|
|
||||||
|
detect_rtlsdr_devices()
|
||||||
|
|
||||||
|
_, kwargs = mock_run.call_args
|
||||||
|
assert kwargs["text"] is True
|
||||||
|
assert kwargs["encoding"] == "utf-8"
|
||||||
|
assert kwargs["errors"] == "replace"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Tests for synthesized 433 MHz scope waveform payload."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from routes.sensor import _build_scope_waveform
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_scope_waveform_has_expected_shape_and_bounds():
|
||||||
|
waveform = _build_scope_waveform(rssi=-8.5, snr=11.2, noise=-26.0, points=96)
|
||||||
|
|
||||||
|
assert len(waveform) == 96
|
||||||
|
assert max(waveform) <= 127
|
||||||
|
assert min(waveform) >= -127
|
||||||
|
assert any(sample != 0 for sample in waveform)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_scope_waveform_changes_with_signal_profile():
|
||||||
|
low_snr = _build_scope_waveform(rssi=-14.0, snr=2.0, noise=-12.0, points=64)
|
||||||
|
high_snr = _build_scope_waveform(rssi=-14.0, snr=20.0, noise=-12.0, points=64)
|
||||||
|
|
||||||
|
assert low_snr != high_snr
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Tests for the SigID Wiki lookup API endpoint."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import routes.signalid as signalid_module
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_client(client):
|
||||||
|
"""Client with logged-in session."""
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess['logged_in'] = True
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def test_sigidwiki_lookup_missing_frequency(auth_client):
|
||||||
|
"""frequency_mhz is required."""
|
||||||
|
resp = auth_client.post('/signalid/sigidwiki', json={})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
|
||||||
|
|
||||||
|
def test_sigidwiki_lookup_invalid_frequency(auth_client):
|
||||||
|
"""frequency_mhz must be numeric and positive."""
|
||||||
|
resp = auth_client.post('/signalid/sigidwiki', json={'frequency_mhz': 'abc'})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
resp = auth_client.post('/signalid/sigidwiki', json={'frequency_mhz': -1})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_sigidwiki_lookup_success(auth_client):
|
||||||
|
"""Endpoint returns normalized SigID lookup structure."""
|
||||||
|
signalid_module._cache.clear()
|
||||||
|
fake_lookup = {
|
||||||
|
'matches': [
|
||||||
|
{
|
||||||
|
'title': 'POCSAG',
|
||||||
|
'url': 'https://www.sigidwiki.com/wiki/POCSAG',
|
||||||
|
'frequencies_mhz': [929.6625],
|
||||||
|
'modes': ['NFM'],
|
||||||
|
'modulations': ['FSK'],
|
||||||
|
'distance_hz': 0,
|
||||||
|
'source': 'SigID Wiki',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'search_used': False,
|
||||||
|
'exact_queries': ['[[Category:Signal]][[Frequencies::929.6625 MHz]]|?Frequencies|?Mode|?Modulation|limit=10'],
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch('routes.signalid._lookup_sigidwiki_matches', return_value=fake_lookup) as lookup_mock:
|
||||||
|
resp = auth_client.post('/signalid/sigidwiki', json={
|
||||||
|
'frequency_mhz': 929.6625,
|
||||||
|
'modulation': 'fm',
|
||||||
|
'limit': 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert lookup_mock.call_count == 1
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data['status'] == 'ok'
|
||||||
|
assert data['source'] == 'sigidwiki'
|
||||||
|
assert data['cached'] is False
|
||||||
|
assert data['match_count'] == 1
|
||||||
|
assert data['matches'][0]['title'] == 'POCSAG'
|
||||||
|
|
||||||
|
|
||||||
|
def test_sigidwiki_lookup_cached_response(auth_client):
|
||||||
|
"""Second identical lookup should be served from cache."""
|
||||||
|
signalid_module._cache.clear()
|
||||||
|
fake_lookup = {
|
||||||
|
'matches': [{'title': 'Test Signal', 'url': 'https://www.sigidwiki.com/wiki/Test_Signal'}],
|
||||||
|
'search_used': True,
|
||||||
|
'exact_queries': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {'frequency_mhz': 433.92, 'modulation': 'nfm', 'limit': 5}
|
||||||
|
with patch('routes.signalid._lookup_sigidwiki_matches', return_value=fake_lookup) as lookup_mock:
|
||||||
|
first = auth_client.post('/signalid/sigidwiki', json=payload)
|
||||||
|
second = auth_client.post('/signalid/sigidwiki', json=payload)
|
||||||
|
|
||||||
|
assert lookup_mock.call_count == 1
|
||||||
|
assert first.status_code == 200
|
||||||
|
assert second.status_code == 200
|
||||||
|
assert first.get_json()['cached'] is False
|
||||||
|
assert second.get_json()['cached'] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_sigidwiki_lookup_backend_failure(auth_client):
|
||||||
|
"""Unexpected lookup failures should return 502."""
|
||||||
|
signalid_module._cache.clear()
|
||||||
|
with patch('routes.signalid._lookup_sigidwiki_matches', side_effect=RuntimeError('boom')):
|
||||||
|
resp = auth_client.post('/signalid/sigidwiki', json={'frequency_mhz': 433.92})
|
||||||
|
assert resp.status_code == 502
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Tests for SSTV scope waveform encoding."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from utils.sstv.sstv_decoder import _encode_scope_waveform
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_scope_waveform_respects_window_and_bounds():
|
||||||
|
samples = np.array([-32768, -16384, 0, 16384, 32767], dtype=np.int16)
|
||||||
|
waveform = _encode_scope_waveform(samples, window_size=4)
|
||||||
|
|
||||||
|
assert len(waveform) == 4
|
||||||
|
assert waveform[0] == -64
|
||||||
|
assert waveform[1] == 0
|
||||||
|
assert waveform[2] == 64
|
||||||
|
assert waveform[3] == 127
|
||||||
|
assert max(waveform) <= 127
|
||||||
|
assert min(waveform) >= -127
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_scope_waveform_empty_input():
|
||||||
|
waveform = _encode_scope_waveform(np.array([], dtype=np.int16))
|
||||||
|
assert waveform == []
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Tests for waterfall WebSocket configuration helpers."""
|
||||||
|
|
||||||
|
from routes.waterfall_websocket import (
|
||||||
|
_parse_center_freq_mhz,
|
||||||
|
_parse_span_mhz,
|
||||||
|
_pick_sample_rate,
|
||||||
|
)
|
||||||
|
from utils.sdr import SDRType
|
||||||
|
from utils.sdr.base import SDRCapabilities
|
||||||
|
|
||||||
|
|
||||||
|
def _caps(sample_rates):
|
||||||
|
return SDRCapabilities(
|
||||||
|
sdr_type=SDRType.RTL_SDR,
|
||||||
|
freq_min_mhz=24.0,
|
||||||
|
freq_max_mhz=1766.0,
|
||||||
|
gain_min=0.0,
|
||||||
|
gain_max=49.6,
|
||||||
|
sample_rates=sample_rates,
|
||||||
|
supports_bias_t=True,
|
||||||
|
supports_ppm=True,
|
||||||
|
tx_capable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_center_prefers_center_freq_mhz():
|
||||||
|
assert _parse_center_freq_mhz({'center_freq_mhz': 162.55, 'center_freq': 144000000}) == 162.55
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_center_supports_center_freq_hz():
|
||||||
|
assert _parse_center_freq_mhz({'center_freq_hz': 915000000}) == 915.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_center_supports_legacy_hz_payload():
|
||||||
|
assert _parse_center_freq_mhz({'center_freq': 109000000}) == 109.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_center_supports_legacy_mhz_payload():
|
||||||
|
assert _parse_center_freq_mhz({'center_freq': 433.92}) == 433.92
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_span_from_hz_and_mhz():
|
||||||
|
assert _parse_span_mhz({'span_hz': 2400000}) == 2.4
|
||||||
|
assert _parse_span_mhz({'span_mhz': 10.0}) == 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_sample_rate_chooses_nearest_declared_rate():
|
||||||
|
caps = _caps([250000, 1024000, 1800000, 2048000, 2400000])
|
||||||
|
assert _pick_sample_rate(700000, caps, SDRType.RTL_SDR) == 1024000
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_sample_rate_falls_back_to_max_bandwidth():
|
||||||
|
caps = _caps([])
|
||||||
|
assert _pick_sample_rate(10_000_000, caps, SDRType.RTL_SDR) == 2_400_000
|
||||||
@@ -97,7 +97,7 @@ class AgentClient:
|
|||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
raise AgentHTTPError(f"Request failed: {e}")
|
raise AgentHTTPError(f"Request failed: {e}")
|
||||||
|
|
||||||
def _post(self, path: str, data: dict | None = None) -> dict:
|
def _post(self, path: str, data: dict | None = None, timeout: float | None = None) -> dict:
|
||||||
"""
|
"""
|
||||||
Perform POST request to agent.
|
Perform POST request to agent.
|
||||||
|
|
||||||
@@ -113,19 +113,20 @@ class AgentClient:
|
|||||||
AgentConnectionError: If agent is unreachable
|
AgentConnectionError: If agent is unreachable
|
||||||
"""
|
"""
|
||||||
url = f"{self.base_url}{path}"
|
url = f"{self.base_url}{path}"
|
||||||
|
request_timeout = self.timeout if timeout is None else timeout
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
url,
|
url,
|
||||||
json=data or {},
|
json=data or {},
|
||||||
headers=self._headers(),
|
headers=self._headers(),
|
||||||
timeout=self.timeout
|
timeout=request_timeout
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json() if response.content else {}
|
return response.json() if response.content else {}
|
||||||
except requests.ConnectionError as e:
|
except requests.ConnectionError as e:
|
||||||
raise AgentConnectionError(f"Cannot connect to agent at {self.base_url}: {e}")
|
raise AgentConnectionError(f"Cannot connect to agent at {self.base_url}: {e}")
|
||||||
except requests.Timeout:
|
except requests.Timeout:
|
||||||
raise AgentConnectionError(f"Request to agent timed out after {self.timeout}s")
|
raise AgentConnectionError(f"Request to agent timed out after {request_timeout}s")
|
||||||
except requests.HTTPError as e:
|
except requests.HTTPError as e:
|
||||||
# Try to extract error message from response body
|
# Try to extract error message from response body
|
||||||
error_msg = f"Agent returned error: {e.response.status_code}"
|
error_msg = f"Agent returned error: {e.response.status_code}"
|
||||||
@@ -141,9 +142,9 @@ class AgentClient:
|
|||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
raise AgentHTTPError(f"Request failed: {e}")
|
raise AgentHTTPError(f"Request failed: {e}")
|
||||||
|
|
||||||
def post(self, path: str, data: dict | None = None) -> dict:
|
def post(self, path: str, data: dict | None = None, timeout: float | None = None) -> dict:
|
||||||
"""Public POST method for arbitrary endpoints."""
|
"""Public POST method for arbitrary endpoints."""
|
||||||
return self._post(path, data)
|
return self._post(path, data, timeout=timeout)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Capability & Status
|
# Capability & Status
|
||||||
@@ -214,7 +215,7 @@ class AgentClient:
|
|||||||
"""
|
"""
|
||||||
return self._post(f'/{mode}/start', params or {})
|
return self._post(f'/{mode}/start', params or {})
|
||||||
|
|
||||||
def stop_mode(self, mode: str) -> dict:
|
def stop_mode(self, mode: str, timeout: float = 8.0) -> dict:
|
||||||
"""
|
"""
|
||||||
Stop a running mode on the agent.
|
Stop a running mode on the agent.
|
||||||
|
|
||||||
@@ -224,7 +225,7 @@ class AgentClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Stop result with 'status' field
|
Stop result with 'status' field
|
||||||
"""
|
"""
|
||||||
return self._post(f'/{mode}/stop')
|
return self._post(f'/{mode}/stop', timeout=timeout)
|
||||||
|
|
||||||
def get_mode_status(self, mode: str) -> dict:
|
def get_mode_status(self, mode: str) -> dict:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ def _load_meta() -> dict[str, Any] | None:
|
|||||||
if os.path.exists(DB_META_FILE):
|
if os.path.exists(DB_META_FILE):
|
||||||
with open(DB_META_FILE, 'r') as f:
|
with open(DB_META_FILE, 'r') as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(f"Corrupt aircraft db meta file, removing: {e}")
|
||||||
|
try:
|
||||||
|
os.remove(DB_META_FILE)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error loading aircraft db meta: {e}")
|
logger.warning(f"Error loading aircraft db meta: {e}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,230 +0,0 @@
|
|||||||
"""Cross-mode analytics: activity tracking, summaries, and emergency squawk detection."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import time
|
|
||||||
from collections import deque
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import app as app_module
|
|
||||||
|
|
||||||
|
|
||||||
class ModeActivityTracker:
|
|
||||||
"""Track device counts per mode in time-bucketed ring buffer for sparklines."""
|
|
||||||
|
|
||||||
def __init__(self, max_buckets: int = 60, bucket_interval: float = 5.0):
|
|
||||||
self._max_buckets = max_buckets
|
|
||||||
self._bucket_interval = bucket_interval
|
|
||||||
self._history: dict[str, deque] = {}
|
|
||||||
self._last_record_time = 0.0
|
|
||||||
|
|
||||||
def record(self) -> None:
|
|
||||||
"""Snapshot current counts for all modes."""
|
|
||||||
now = time.time()
|
|
||||||
if now - self._last_record_time < self._bucket_interval:
|
|
||||||
return
|
|
||||||
self._last_record_time = now
|
|
||||||
|
|
||||||
counts = _get_mode_counts()
|
|
||||||
for mode, count in counts.items():
|
|
||||||
if mode not in self._history:
|
|
||||||
self._history[mode] = deque(maxlen=self._max_buckets)
|
|
||||||
self._history[mode].append(count)
|
|
||||||
|
|
||||||
def get_sparkline(self, mode: str) -> list[int]:
|
|
||||||
"""Return sparkline array for a mode."""
|
|
||||||
self.record()
|
|
||||||
return list(self._history.get(mode, []))
|
|
||||||
|
|
||||||
def get_all_sparklines(self) -> dict[str, list[int]]:
|
|
||||||
"""Return sparkline arrays for all tracked modes."""
|
|
||||||
self.record()
|
|
||||||
return {mode: list(values) for mode, values in self._history.items()}
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton
|
|
||||||
_tracker: ModeActivityTracker | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_activity_tracker() -> ModeActivityTracker:
|
|
||||||
global _tracker
|
|
||||||
if _tracker is None:
|
|
||||||
_tracker = ModeActivityTracker()
|
|
||||||
return _tracker
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_len(attr_name: str) -> int:
|
|
||||||
"""Safely get len() of an app_module attribute."""
|
|
||||||
try:
|
|
||||||
return len(getattr(app_module, attr_name))
|
|
||||||
except Exception:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_route_attr(module_path: str, attr_name: str, default: int = 0) -> int:
|
|
||||||
"""Safely read a module-level counter from a route file."""
|
|
||||||
try:
|
|
||||||
import importlib
|
|
||||||
mod = importlib.import_module(module_path)
|
|
||||||
return int(getattr(mod, attr_name, default))
|
|
||||||
except Exception:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def _get_mode_counts() -> dict[str, int]:
|
|
||||||
"""Read current entity counts from all available data sources."""
|
|
||||||
counts: dict[str, int] = {}
|
|
||||||
|
|
||||||
# ADS-B aircraft (DataStore)
|
|
||||||
counts['adsb'] = _safe_len('adsb_aircraft')
|
|
||||||
|
|
||||||
# AIS vessels (DataStore)
|
|
||||||
counts['ais'] = _safe_len('ais_vessels')
|
|
||||||
|
|
||||||
# WiFi: prefer v2 scanner, fall back to legacy DataStore
|
|
||||||
wifi_count = 0
|
|
||||||
try:
|
|
||||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
|
||||||
if wifi_scanner is not None:
|
|
||||||
wifi_count = len(wifi_scanner.access_points)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if wifi_count == 0:
|
|
||||||
wifi_count = _safe_len('wifi_networks')
|
|
||||||
counts['wifi'] = wifi_count
|
|
||||||
|
|
||||||
# Bluetooth: prefer v2 scanner, fall back to legacy DataStore
|
|
||||||
bt_count = 0
|
|
||||||
try:
|
|
||||||
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
|
||||||
if bt_scanner is not None:
|
|
||||||
bt_count = len(bt_scanner.get_devices())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if bt_count == 0:
|
|
||||||
bt_count = _safe_len('bt_devices')
|
|
||||||
counts['bluetooth'] = bt_count
|
|
||||||
|
|
||||||
# DSC messages (DataStore)
|
|
||||||
counts['dsc'] = _safe_len('dsc_messages')
|
|
||||||
|
|
||||||
# ACARS message count (route-level counter)
|
|
||||||
counts['acars'] = _safe_route_attr('routes.acars', 'acars_message_count')
|
|
||||||
|
|
||||||
# VDL2 message count (route-level counter)
|
|
||||||
counts['vdl2'] = _safe_route_attr('routes.vdl2', 'vdl2_message_count')
|
|
||||||
|
|
||||||
# APRS stations (route-level dict)
|
|
||||||
try:
|
|
||||||
import routes.aprs as aprs_mod
|
|
||||||
counts['aprs'] = len(getattr(aprs_mod, 'aprs_stations', {}))
|
|
||||||
except Exception:
|
|
||||||
counts['aprs'] = 0
|
|
||||||
|
|
||||||
# Meshtastic recent messages (route-level list)
|
|
||||||
try:
|
|
||||||
import routes.meshtastic as mesh_route
|
|
||||||
counts['meshtastic'] = len(getattr(mesh_route, '_recent_messages', []))
|
|
||||||
except Exception:
|
|
||||||
counts['meshtastic'] = 0
|
|
||||||
|
|
||||||
return counts
|
|
||||||
|
|
||||||
|
|
||||||
def get_cross_mode_summary() -> dict[str, Any]:
|
|
||||||
"""Return counts dict for all available data sources."""
|
|
||||||
counts = _get_mode_counts()
|
|
||||||
wifi_clients_count = 0
|
|
||||||
try:
|
|
||||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
|
||||||
if wifi_scanner is not None:
|
|
||||||
wifi_clients_count = len(wifi_scanner.clients)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if wifi_clients_count == 0:
|
|
||||||
wifi_clients_count = _safe_len('wifi_clients')
|
|
||||||
counts['wifi_clients'] = wifi_clients_count
|
|
||||||
return counts
|
|
||||||
|
|
||||||
|
|
||||||
def get_mode_health() -> dict[str, dict]:
|
|
||||||
"""Check process refs and SDR status for each mode."""
|
|
||||||
health: dict[str, dict] = {}
|
|
||||||
|
|
||||||
process_map = {
|
|
||||||
'pager': 'current_process',
|
|
||||||
'sensor': 'sensor_process',
|
|
||||||
'adsb': 'adsb_process',
|
|
||||||
'ais': 'ais_process',
|
|
||||||
'acars': 'acars_process',
|
|
||||||
'vdl2': 'vdl2_process',
|
|
||||||
'aprs': 'aprs_process',
|
|
||||||
'wifi': 'wifi_process',
|
|
||||||
'bluetooth': 'bt_process',
|
|
||||||
'dsc': 'dsc_process',
|
|
||||||
'rtlamr': 'rtlamr_process',
|
|
||||||
}
|
|
||||||
|
|
||||||
for mode, attr in process_map.items():
|
|
||||||
proc = getattr(app_module, attr, None)
|
|
||||||
running = proc is not None and (proc.poll() is None if proc else False)
|
|
||||||
health[mode] = {'running': running}
|
|
||||||
|
|
||||||
# Override WiFi/BT health with v2 scanner status if available
|
|
||||||
try:
|
|
||||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
|
||||||
if wifi_scanner is not None and wifi_scanner.is_scanning:
|
|
||||||
health['wifi'] = {'running': True}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
|
||||||
if bt_scanner is not None and bt_scanner.is_scanning:
|
|
||||||
health['bluetooth'] = {'running': True}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Meshtastic: check client connection status
|
|
||||||
try:
|
|
||||||
from utils.meshtastic import get_meshtastic_client
|
|
||||||
client = get_meshtastic_client()
|
|
||||||
health['meshtastic'] = {'running': client._interface is not None}
|
|
||||||
except Exception:
|
|
||||||
health['meshtastic'] = {'running': False}
|
|
||||||
|
|
||||||
try:
|
|
||||||
sdr_status = app_module.get_sdr_device_status()
|
|
||||||
health['sdr_devices'] = {str(k): v for k, v in sdr_status.items()}
|
|
||||||
except Exception:
|
|
||||||
health['sdr_devices'] = {}
|
|
||||||
|
|
||||||
return health
|
|
||||||
|
|
||||||
|
|
||||||
EMERGENCY_SQUAWKS = {
|
|
||||||
'7700': 'General Emergency',
|
|
||||||
'7600': 'Comms Failure',
|
|
||||||
'7500': 'Hijack',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_emergency_squawks() -> list[dict]:
|
|
||||||
"""Iterate adsb_aircraft DataStore for emergency squawk codes."""
|
|
||||||
emergencies: list[dict] = []
|
|
||||||
try:
|
|
||||||
for icao, aircraft in app_module.adsb_aircraft.items():
|
|
||||||
sq = str(aircraft.get('squawk', '')).strip()
|
|
||||||
if sq in EMERGENCY_SQUAWKS:
|
|
||||||
emergencies.append({
|
|
||||||
'icao': icao,
|
|
||||||
'callsign': aircraft.get('callsign', ''),
|
|
||||||
'squawk': sq,
|
|
||||||
'meaning': EMERGENCY_SQUAWKS[sq],
|
|
||||||
'altitude': aircraft.get('altitude'),
|
|
||||||
'lat': aircraft.get('lat'),
|
|
||||||
'lon': aircraft.get('lon'),
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return emergencies
|
|
||||||
+29
-11
@@ -637,27 +637,45 @@ def start_locate_session(
|
|||||||
"""Start a new locate session, stopping any existing one."""
|
"""Start a new locate session, stopping any existing one."""
|
||||||
global _session
|
global _session
|
||||||
|
|
||||||
|
# Grab and evict any existing session without holding the lock during stop()
|
||||||
|
# (stop() joins a thread which can block for up to 3 s).
|
||||||
|
old_session = None
|
||||||
with _session_lock:
|
with _session_lock:
|
||||||
if _session and _session.active:
|
if _session and _session.active:
|
||||||
_session.stop()
|
old_session = _session
|
||||||
|
|
||||||
_session = LocateSession(
|
|
||||||
target, environment, custom_exponent, fallback_lat, fallback_lon
|
|
||||||
)
|
|
||||||
if not _session.start():
|
|
||||||
_session = None
|
_session = None
|
||||||
raise RuntimeError("Bluetooth scanner failed to start")
|
|
||||||
return _session
|
if old_session:
|
||||||
|
old_session.stop()
|
||||||
|
|
||||||
|
new_session = LocateSession(
|
||||||
|
target, environment, custom_exponent, fallback_lat, fallback_lon
|
||||||
|
)
|
||||||
|
with _session_lock:
|
||||||
|
_session = new_session
|
||||||
|
|
||||||
|
if not new_session.start():
|
||||||
|
with _session_lock:
|
||||||
|
if _session is new_session:
|
||||||
|
_session = None
|
||||||
|
raise RuntimeError("Bluetooth scanner failed to start")
|
||||||
|
|
||||||
|
return new_session
|
||||||
|
|
||||||
|
|
||||||
def stop_locate_session() -> None:
|
def stop_locate_session() -> None:
|
||||||
"""Stop the active locate session."""
|
"""Stop the active locate session."""
|
||||||
global _session
|
global _session
|
||||||
|
|
||||||
|
# Release the lock before stop() so concurrent status/SSE requests
|
||||||
|
# aren't blocked for up to 3 s while the poll thread is joined.
|
||||||
|
session_to_stop = None
|
||||||
with _session_lock:
|
with _session_lock:
|
||||||
if _session:
|
session_to_stop = _session
|
||||||
_session.stop()
|
_session = None
|
||||||
_session = None
|
|
||||||
|
if session_to_stop:
|
||||||
|
session_to_stop.stop()
|
||||||
|
|
||||||
|
|
||||||
def get_locate_session() -> LocateSession | None:
|
def get_locate_session() -> LocateSession | None:
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ def safe_terminate(process: subprocess.Popen | None, timeout: float = 2.0) -> bo
|
|||||||
return True
|
return True
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
process.kill()
|
process.kill()
|
||||||
|
try:
|
||||||
|
process.wait(timeout=3)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
pass
|
||||||
unregister_process(process)
|
unregister_process(process)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
|||||||
['rtl_test', '-t'],
|
['rtl_test', '-t'],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
encoding='utf-8',
|
||||||
|
errors='replace',
|
||||||
timeout=5,
|
timeout=5,
|
||||||
env=env
|
env=env
|
||||||
)
|
)
|
||||||
@@ -123,7 +125,8 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
|||||||
|
|
||||||
# Parse device info from rtl_test output
|
# Parse device info from rtl_test output
|
||||||
# Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001"
|
# Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001"
|
||||||
device_pattern = r'(\d+):\s+(.+?)(?:,\s*SN:\s*(\S+))?$'
|
# Require a non-empty serial to avoid matching malformed lines like "SN:".
|
||||||
|
device_pattern = r'(\d+):\s+(.+?),\s*SN:\s*(\S+)\s*$'
|
||||||
|
|
||||||
from .rtlsdr import RTLSDRCommandBuilder
|
from .rtlsdr import RTLSDRCommandBuilder
|
||||||
|
|
||||||
@@ -135,7 +138,7 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
|||||||
sdr_type=SDRType.RTL_SDR,
|
sdr_type=SDRType.RTL_SDR,
|
||||||
index=int(match.group(1)),
|
index=int(match.group(1)),
|
||||||
name=match.group(2).strip().rstrip(','),
|
name=match.group(2).strip().rstrip(','),
|
||||||
serial=match.group(3) or 'N/A',
|
serial=match.group(3),
|
||||||
driver='rtlsdr',
|
driver='rtlsdr',
|
||||||
capabilities=RTLSDRCommandBuilder.CAPABILITIES
|
capabilities=RTLSDRCommandBuilder.CAPABILITIES
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -122,6 +122,17 @@ class DecodeProgress:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_scope_waveform(raw_samples: np.ndarray, window_size: int = 256) -> list[int]:
|
||||||
|
"""Compress recent int16 PCM samples to signed 8-bit values for SSE."""
|
||||||
|
if raw_samples.size == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
window = raw_samples[-window_size:] if raw_samples.size > window_size else raw_samples
|
||||||
|
packed = np.rint(window.astype(np.float64) / 256.0).astype(np.int16)
|
||||||
|
packed = np.clip(packed, -127, 127)
|
||||||
|
return packed.tolist()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# DopplerTracker
|
# DopplerTracker
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -423,6 +434,7 @@ class SSTVDecoder:
|
|||||||
# Scope: compute RMS/peak from raw int16 samples every chunk
|
# Scope: compute RMS/peak from raw int16 samples every chunk
|
||||||
rms_val = int(np.sqrt(np.mean(raw_samples.astype(np.float64) ** 2)))
|
rms_val = int(np.sqrt(np.mean(raw_samples.astype(np.float64) ** 2)))
|
||||||
peak_val = int(np.max(np.abs(raw_samples)))
|
peak_val = int(np.max(np.abs(raw_samples)))
|
||||||
|
waveform = _encode_scope_waveform(raw_samples)
|
||||||
|
|
||||||
if image_decoder is not None:
|
if image_decoder is not None:
|
||||||
# Currently decoding an image
|
# Currently decoding an image
|
||||||
@@ -451,7 +463,7 @@ class SSTVDecoder:
|
|||||||
message=f'Decoding {current_mode_name}: {pct}%',
|
message=f'Decoding {current_mode_name}: {pct}%',
|
||||||
partial_image=partial_url,
|
partial_image=partial_url,
|
||||||
))
|
))
|
||||||
self._emit_scope(rms_val, peak_val, 'decoding')
|
self._emit_scope(rms_val, peak_val, 'decoding', waveform)
|
||||||
|
|
||||||
if complete:
|
if complete:
|
||||||
# Save image
|
# Save image
|
||||||
@@ -529,7 +541,7 @@ class SSTVDecoder:
|
|||||||
vis_state=vis_detector.state.value,
|
vis_state=vis_detector.state.value,
|
||||||
))
|
))
|
||||||
|
|
||||||
self._emit_scope(rms_val, peak_val, scope_tone)
|
self._emit_scope(rms_val, peak_val, scope_tone, waveform)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in decode thread: {e}")
|
logger.error(f"Error in decode thread: {e}")
|
||||||
@@ -762,11 +774,20 @@ class SSTVDecoder:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in progress callback: {e}")
|
logger.error(f"Error in progress callback: {e}")
|
||||||
|
|
||||||
def _emit_scope(self, rms: int, peak: int, tone: str | None = None) -> None:
|
def _emit_scope(
|
||||||
|
self,
|
||||||
|
rms: int,
|
||||||
|
peak: int,
|
||||||
|
tone: str | None = None,
|
||||||
|
waveform: list[int] | None = None,
|
||||||
|
) -> None:
|
||||||
"""Emit scope signal levels to callback."""
|
"""Emit scope signal levels to callback."""
|
||||||
if self._callback:
|
if self._callback:
|
||||||
try:
|
try:
|
||||||
self._callback({'type': 'sstv_scope', 'rms': rms, 'peak': peak, 'tone': tone})
|
payload = {'type': 'sstv_scope', 'rms': rms, 'peak': peak, 'tone': tone}
|
||||||
|
if waveform:
|
||||||
|
payload['waveform'] = waveform
|
||||||
|
self._callback(payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
+72
-22
@@ -733,39 +733,69 @@ class UnifiedWiFiScanner:
|
|||||||
Returns:
|
Returns:
|
||||||
True if scan was stopped.
|
True if scan was stopped.
|
||||||
"""
|
"""
|
||||||
|
cleanup_process: Optional[subprocess.Popen] = None
|
||||||
|
cleanup_thread: Optional[threading.Thread] = None
|
||||||
|
cleanup_detector = None
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if not self._status.is_scanning:
|
if not self._status.is_scanning:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Stop deauth detector first
|
|
||||||
self._stop_deauth_detector()
|
|
||||||
|
|
||||||
self._deep_scan_stop_event.set()
|
self._deep_scan_stop_event.set()
|
||||||
|
cleanup_process = self._deep_scan_process
|
||||||
if self._deep_scan_process:
|
cleanup_thread = self._deep_scan_thread
|
||||||
try:
|
cleanup_detector = self._deauth_detector
|
||||||
self._deep_scan_process.terminate()
|
self._deauth_detector = None
|
||||||
self._deep_scan_process.wait(timeout=5)
|
self._deep_scan_process = None
|
||||||
except Exception as e:
|
self._deep_scan_thread = None
|
||||||
logger.warning(f"Error terminating airodump-ng: {e}")
|
|
||||||
try:
|
|
||||||
self._deep_scan_process.kill()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._deep_scan_process = None
|
|
||||||
|
|
||||||
if self._deep_scan_thread:
|
|
||||||
self._deep_scan_thread.join(timeout=5)
|
|
||||||
self._deep_scan_thread = None
|
|
||||||
|
|
||||||
self._status.is_scanning = False
|
self._status.is_scanning = False
|
||||||
|
self._status.error = None
|
||||||
|
|
||||||
self._queue_event({
|
self._queue_event({
|
||||||
'type': 'scan_stopped',
|
'type': 'scan_stopped',
|
||||||
'mode': SCAN_MODE_DEEP,
|
'mode': SCAN_MODE_DEEP,
|
||||||
})
|
})
|
||||||
|
|
||||||
return True
|
cleanup_start = time.perf_counter()
|
||||||
|
|
||||||
|
def _finalize_stop(
|
||||||
|
process: Optional[subprocess.Popen],
|
||||||
|
scan_thread: Optional[threading.Thread],
|
||||||
|
detector,
|
||||||
|
) -> None:
|
||||||
|
if detector:
|
||||||
|
try:
|
||||||
|
detector.stop()
|
||||||
|
logger.info("Deauth detector stopped")
|
||||||
|
self._queue_event({'type': 'deauth_detector_stopped'})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Error stopping deauth detector: {exc}")
|
||||||
|
|
||||||
|
if process and process.poll() is None:
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
process.wait(timeout=1.5)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if scan_thread and scan_thread.is_alive():
|
||||||
|
scan_thread.join(timeout=1.5)
|
||||||
|
|
||||||
|
elapsed_ms = (time.perf_counter() - cleanup_start) * 1000.0
|
||||||
|
logger.info(f"Deep scan stop finalized in {elapsed_ms:.1f}ms")
|
||||||
|
|
||||||
|
threading.Thread(
|
||||||
|
target=_finalize_stop,
|
||||||
|
args=(cleanup_process, cleanup_thread, cleanup_detector),
|
||||||
|
daemon=True,
|
||||||
|
name='wifi-deep-stop',
|
||||||
|
).start()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
def _run_deep_scan(
|
def _run_deep_scan(
|
||||||
self,
|
self,
|
||||||
@@ -799,12 +829,30 @@ class UnifiedWiFiScanner:
|
|||||||
|
|
||||||
logger.info(f"Starting airodump-ng: {' '.join(cmd)}")
|
logger.info(f"Starting airodump-ng: {' '.join(cmd)}")
|
||||||
|
|
||||||
|
process: Optional[subprocess.Popen] = None
|
||||||
try:
|
try:
|
||||||
self._deep_scan_process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
|
should_track_process = False
|
||||||
|
with self._lock:
|
||||||
|
# Only expose the process handle if this run has not been
|
||||||
|
# replaced by a newer deep scan session.
|
||||||
|
if self._status.is_scanning and not self._deep_scan_stop_event.is_set():
|
||||||
|
should_track_process = True
|
||||||
|
self._deep_scan_process = process
|
||||||
|
if not should_track_process:
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
process.wait(timeout=1.0)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
csv_file = f"{output_prefix}-01.csv"
|
csv_file = f"{output_prefix}-01.csv"
|
||||||
|
|
||||||
@@ -837,7 +885,9 @@ class UnifiedWiFiScanner:
|
|||||||
'error': str(e),
|
'error': str(e),
|
||||||
})
|
})
|
||||||
finally:
|
finally:
|
||||||
self._deep_scan_process = None
|
with self._lock:
|
||||||
|
if process is not None and self._deep_scan_process is process:
|
||||||
|
self._deep_scan_process = None
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Observation Processing
|
# Observation Processing
|
||||||
|
|||||||
Reference in New Issue
Block a user