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:
mitchross
2026-02-23 23:16:39 -05:00
77 changed files with 12673 additions and 9572 deletions

View File

@@ -1,36 +1,96 @@
# Changelog
All notable changes to iNTERCEPT will be documented in this file.
## [2.21.1] - 2026-02-20
### Fixed
- BT Locate map first-load rendering race that could cause blank/late map initialization
- BT Locate mode switch timing so Leaflet invalidation runs after panel visibility settles
- BT Locate trail restore startup latency by batching historical GPS point rendering
---
## [2.21.0] - 2026-02-20
### Added
- Analytics panels for operational insights and temporal pattern analysis
### Changed
- Global map theme refresh with improved contrast and cross-dashboard consistency
- Cross-app UX refinements for accessibility, mode consistency, and render performance
- BT Locate enhancements including improved continuity, smoothing, and confidence reporting
### Fixed
- Weather satellite auto-scheduler and Mercator tracking reliability issues
- Bluetooth/WiFi runtime health issues affecting scanner continuity
- ADS-B SSE multi-client fanout stability and remote VDL2 streaming reliability
---
## [2.15.0] - 2026-02-09
### Added
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
### Fixed
- BT Locate map first-load rendering race that could cause blank/late map initialization
- BT Locate mode switch timing so Leaflet invalidation runs after panel visibility settles
- BT Locate trail restore startup latency by batching historical GPS point rendering
---
## [2.21.0] - 2026-02-20
### Added
- Analytics panels for operational insights and temporal pattern analysis
### Changed
- Global map theme refresh with improved contrast and cross-dashboard consistency
- Cross-app UX refinements for accessibility, mode consistency, and render performance
- BT Locate enhancements including improved continuity, smoothing, and confidence reporting
### Fixed
- Weather satellite auto-scheduler and Mercator tracking reliability issues
- Bluetooth/WiFi runtime health issues affecting scanner continuity
- ADS-B SSE multi-client fanout stability and remote VDL2 streaming reliability
---
## [2.15.0] - 2026-02-09
### Added
- **Real-time WebSocket Waterfall** - I/Q capture with server-side FFT
- Click-to-tune, zoom controls, and auto-scaling quantization
- Shared waterfall UI across SDR modes with function bar controls

View File

@@ -57,7 +57,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
soapysdr-module-airspy \
airspy \
limesuite \
hackrf \
# Utilities
curl \
procps \
@@ -190,6 +189,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
fi \
&& cd /tmp \
&& 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)
&& cd /tmp \
&& 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 5050
EXPOSE 5443
# Environment variables with defaults
ENV INTERCEPT_HOST=0.0.0.0 \

View File

@@ -2,7 +2,7 @@
<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/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">
</p>
@@ -40,7 +40,7 @@ Support the developer of this open-source project
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF)
- **APRS** - Amateur packet radio position reports and telemetry via direwolf
- **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)
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
- **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
```
**1. Clone and run:**
```bash
git clone https://github.com/smittix/intercept.git
@@ -150,7 +148,7 @@ Set these as environment variables for either local installs or Docker:
```bash
INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
python app.py
sudo -E venv/bin/python intercept.py
```
**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>
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) |
[acarsdec](https://github.com/TLeconte/acarsdec) |
[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) |
[aircrack-ng](https://www.aircrack-ng.org/) |
[Leaflet.js](https://leafletjs.com/) |

332
app.py
View File

@@ -25,7 +25,7 @@ import subprocess
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 config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
@@ -96,32 +96,32 @@ def add_security_headers(response):
# CONTEXT PROCESSORS
# ============================================
@app.context_processor
def inject_offline_settings():
"""Inject offline settings into all templates."""
from utils.database import get_setting
# Privacy-first defaults: keep dashboard assets/fonts local to avoid
# third-party tracker/storage defenses in strict browsers.
assets_source = str(get_setting('offline.assets_source', 'local') or 'local').lower()
fonts_source = str(get_setting('offline.fonts_source', 'local') or 'local').lower()
if assets_source not in ('local', 'cdn'):
assets_source = 'local'
if fonts_source not in ('local', 'cdn'):
fonts_source = 'local'
# Force local delivery for core dashboard pages.
assets_source = 'local'
fonts_source = 'local'
return {
'offline_settings': {
'enabled': get_setting('offline.enabled', False),
'assets_source': assets_source,
'fonts_source': fonts_source,
'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'),
'tile_server_url': get_setting('offline.tile_server_url', '')
}
}
@app.context_processor
def inject_offline_settings():
"""Inject offline settings into all templates."""
from utils.database import get_setting
# Privacy-first defaults: keep dashboard assets/fonts local to avoid
# third-party tracker/storage defenses in strict browsers.
assets_source = str(get_setting('offline.assets_source', 'local') or 'local').lower()
fonts_source = str(get_setting('offline.fonts_source', 'local') or 'local').lower()
if assets_source not in ('local', 'cdn'):
assets_source = 'local'
if fonts_source not in ('local', 'cdn'):
fonts_source = 'local'
# Force local delivery for core dashboard pages.
assets_source = 'local'
fonts_source = 'local'
return {
'offline_settings': {
'enabled': get_setting('offline.enabled', False),
'assets_source': assets_source,
'fonts_source': fonts_source,
'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'),
'tile_server_url': get_setting('offline.tile_server_url', '')
}
}
# ============================================
@@ -190,9 +190,9 @@ dsc_rtl_process = None
dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
dsc_lock = threading.Lock()
# TSCM (Technical Surveillance Countermeasures)
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
tscm_lock = threading.Lock()
# TSCM (Technical Surveillance Countermeasures)
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
tscm_lock = threading.Lock()
# SubGHz Transceiver (HackRF)
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
@@ -396,6 +396,18 @@ def favicon() -> Response:
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')
def get_devices() -> Response:
"""Get all detected SDR devices with hardware type info."""
@@ -659,105 +671,105 @@ def export_bluetooth() -> Response:
})
def _get_subghz_active() -> bool:
"""Check if SubGHz manager has an active process."""
try:
from utils.subghz import get_subghz_manager
return get_subghz_manager().active_mode != 'idle'
except Exception:
return False
def _get_bluetooth_health() -> tuple[bool, int]:
"""Return Bluetooth active state and best-effort device count."""
legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False)
scanner_running = False
scanner_count = 0
try:
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
if bt_scanner is not None:
scanner_running = bool(bt_scanner.is_scanning)
scanner_count = int(bt_scanner.device_count)
except Exception:
scanner_running = False
scanner_count = 0
locate_running = False
try:
from utils.bt_locate import get_locate_session
session = get_locate_session()
if session and getattr(session, 'active', False):
scanner = getattr(session, '_scanner', None)
locate_running = bool(scanner and scanner.is_scanning)
except Exception:
locate_running = False
return (legacy_running or scanner_running or locate_running), max(len(bt_devices), scanner_count)
def _get_wifi_health() -> tuple[bool, int, int]:
"""Return WiFi active state and best-effort network/client counts."""
legacy_running = wifi_process is not None and (wifi_process.poll() is None if wifi_process else False)
scanner_running = False
scanner_networks = 0
scanner_clients = 0
try:
from utils.wifi.scanner import _scanner_instance as wifi_scanner
if wifi_scanner is not None:
status = wifi_scanner.get_status()
scanner_running = bool(status.is_scanning)
scanner_networks = int(status.networks_found or 0)
scanner_clients = int(status.clients_found or 0)
except Exception:
scanner_running = False
scanner_networks = 0
scanner_clients = 0
return (
legacy_running or scanner_running,
max(len(wifi_networks), scanner_networks),
max(len(wifi_clients), scanner_clients),
)
@app.route('/health')
def health_check() -> Response:
"""Health check endpoint for monitoring."""
import time
bt_active, bt_device_count = _get_bluetooth_health()
wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health()
return jsonify({
'status': 'healthy',
'version': VERSION,
'uptime_seconds': round(time.time() - _app_start_time, 2),
'processes': {
def _get_subghz_active() -> bool:
"""Check if SubGHz manager has an active process."""
try:
from utils.subghz import get_subghz_manager
return get_subghz_manager().active_mode != 'idle'
except Exception:
return False
def _get_bluetooth_health() -> tuple[bool, int]:
"""Return Bluetooth active state and best-effort device count."""
legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False)
scanner_running = False
scanner_count = 0
try:
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
if bt_scanner is not None:
scanner_running = bool(bt_scanner.is_scanning)
scanner_count = int(bt_scanner.device_count)
except Exception:
scanner_running = False
scanner_count = 0
locate_running = False
try:
from utils.bt_locate import get_locate_session
session = get_locate_session()
if session and getattr(session, 'active', False):
scanner = getattr(session, '_scanner', None)
locate_running = bool(scanner and scanner.is_scanning)
except Exception:
locate_running = False
return (legacy_running or scanner_running or locate_running), max(len(bt_devices), scanner_count)
def _get_wifi_health() -> tuple[bool, int, int]:
"""Return WiFi active state and best-effort network/client counts."""
legacy_running = wifi_process is not None and (wifi_process.poll() is None if wifi_process else False)
scanner_running = False
scanner_networks = 0
scanner_clients = 0
try:
from utils.wifi.scanner import _scanner_instance as wifi_scanner
if wifi_scanner is not None:
status = wifi_scanner.get_status()
scanner_running = bool(status.is_scanning)
scanner_networks = int(status.networks_found or 0)
scanner_clients = int(status.clients_found or 0)
except Exception:
scanner_running = False
scanner_networks = 0
scanner_clients = 0
return (
legacy_running or scanner_running,
max(len(wifi_networks), scanner_networks),
max(len(wifi_clients), scanner_clients),
)
@app.route('/health')
def health_check() -> Response:
"""Health check endpoint for monitoring."""
import time
bt_active, bt_device_count = _get_bluetooth_health()
wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health()
return jsonify({
'status': 'healthy',
'version': VERSION,
'uptime_seconds': round(time.time() - _app_start_time, 2),
'processes': {
'pager': current_process is not None and (current_process.poll() is None if current_process else False),
'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False),
'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False),
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
'vdl2': vdl2_process is not None and (vdl2_process.poll() is None if vdl2_process else False),
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
'wifi': wifi_active,
'bluetooth': bt_active,
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
'subghz': _get_subghz_active(),
},
'data': {
'aircraft_count': len(adsb_aircraft),
'vessel_count': len(ais_vessels),
'wifi_networks_count': wifi_network_count,
'wifi_clients_count': wifi_client_count,
'bt_devices_count': bt_device_count,
'dsc_messages_count': len(dsc_messages),
}
})
'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False),
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
'vdl2': vdl2_process is not None and (vdl2_process.poll() is None if vdl2_process else False),
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
'wifi': wifi_active,
'bluetooth': bt_active,
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
'subghz': _get_subghz_active(),
},
'data': {
'aircraft_count': len(adsb_aircraft),
'vessel_count': len(ais_vessels),
'wifi_networks_count': wifi_network_count,
'wifi_clients_count': wifi_client_count,
'bt_devices_count': bt_device_count,
'dsc_messages_count': len(dsc_messages),
}
})
@app.route('/killall', methods=['POST'])
def kill_all() -> Response:
def kill_all() -> Response:
"""Kill all decoder, WiFi, and Bluetooth processes."""
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
global vdl2_process
@@ -773,7 +785,7 @@ def kill_all() -> Response:
'rtl_fm', 'multimon-ng', 'rtl_433',
'airodump-ng', 'aireplay-ng', 'airmon-ng',
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
'hcitool', 'bluetoothctl', 'satdump',
'hcitool', 'bluetoothctl', 'satdump',
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
'hackrf_transfer', 'hackrf_sweep'
]
@@ -823,7 +835,7 @@ def kill_all() -> Response:
dsc_process = None
dsc_rtl_process = None
# Reset Bluetooth state (legacy)
# Reset Bluetooth state (legacy)
with bt_lock:
if bt_process:
try:
@@ -843,20 +855,50 @@ def kill_all() -> Response:
except Exception:
pass
# Reset SubGHz state
try:
from utils.subghz import get_subghz_manager
get_subghz_manager().stop_all()
except Exception:
pass
# Clear SDR device registry
with sdr_device_registry_lock:
sdr_device_registry.clear()
# Reset SubGHz state
try:
from utils.subghz import get_subghz_manager
get_subghz_manager().stop_all()
except Exception:
pass
# Clear SDR device registry
with sdr_device_registry_lock:
sdr_device_registry.clear()
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:
"""Main entry point."""
import argparse
@@ -883,6 +925,12 @@ def main() -> None:
default=config.DEBUG,
help='Enable debug mode'
)
parser.add_argument(
'--https',
action='store_true',
default=config.HTTPS,
help='Enable HTTPS with self-signed certificate'
)
parser.add_argument(
'--check-deps',
action='store_true',
@@ -1008,7 +1056,18 @@ def main() -> None:
except ImportError as 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("Press Ctrl+C to stop")
print()
@@ -1020,4 +1079,5 @@ def main() -> None:
debug=args.debug,
threaded=True,
load_dotenv=False,
ssl_context=ssl_context,
)

View File

@@ -6,35 +6,64 @@ import logging
import os
import sys
# Application version
VERSION = "2.21.1"
# Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [
{
"version": "2.21.1",
"date": "February 2026",
"highlights": [
"BT Locate map first-load fix with render stabilization retries during initial mode open",
"BT Locate trail restore optimization for faster startup when historical GPS points exist",
"BT Locate mode-switch map invalidation timing fix to prevent delayed/blank map render",
]
},
{
"version": "2.21.0",
"date": "February 2026",
"highlights": [
"Global map theme refresh with improved contrast and cross-dashboard consistency",
"Cross-app UX updates for accessibility, mode consistency, and render performance",
"Weather satellite reliability fixes for auto-scheduler and Mercator pass tracking",
"Bluetooth/WiFi runtime health fixes with BT Locate continuity and confidence improvements",
"ADS-B/VDL2 streaming reliability upgrades for multi-client SSE fanout and remote decoding",
"Analytics enhancements with operational insights and temporal pattern panels",
]
},
{
"version": "2.20.0",
"date": "February 2026",
# Application version
VERSION = "2.22.3"
# Changelog - latest release notes (shown on welcome screen)
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",
"date": "February 2026",
"highlights": [
"BT Locate map first-load fix with render stabilization retries during initial mode open",
"BT Locate trail restore optimization for faster startup when historical GPS points exist",
"BT Locate mode-switch map invalidation timing fix to prevent delayed/blank map render",
]
},
{
"version": "2.21.0",
"date": "February 2026",
"highlights": [
"Global map theme refresh with improved contrast and cross-dashboard consistency",
"Cross-app UX updates for accessibility, mode consistency, and render performance",
"Weather satellite reliability fixes for auto-scheduler and Mercator pass tracking",
"Bluetooth/WiFi runtime health fixes with BT Locate continuity and confidence improvements",
"ADS-B/VDL2 streaming reliability upgrades for multi-client SSE fanout and remote decoding",
"Analytics enhancements with operational insights and temporal pattern panels",
]
},
{
"version": "2.20.0",
"date": "February 2026",
"highlights": [
"Space Weather mode: real-time solar and geomagnetic monitoring from NOAA SWPC, NASA SDO, and HamQSL",
"Kp index, solar wind, X-ray flux charts with Chart.js visualization",
@@ -99,14 +128,14 @@ CHANGELOG = [
"Pure Python SSTV decoder replacing broken slowrx dependency",
"Real-time signal scope for pager, sensor, and SSTV modes",
"USB-level device probe to prevent cryptic rtl_fm crashes",
"SDR device lock-up fix from unreleased device registry on crash",
"SDR device lock-up fix from unreleased device registry on crash",
]
},
{
"version": "2.14.0",
"date": "February 2026",
"highlights": [
"HF SSTV general mode with predefined shortwave frequencies",
"HF SSTV general mode with predefined shortwave frequencies",
"WebSDR integration for remote HF/shortwave listening",
"Listening Post signal scanner and audio pipeline improvements",
"TSCM sweep resilience, WiFi detection, and correlation fixes",
@@ -251,6 +280,11 @@ PORT = _get_env_int('PORT', 5050)
DEBUG = _get_env_bool('DEBUG', False)
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_GAIN = _get_env('DEFAULT_GAIN', '40')
DEFAULT_DEVICE = _get_env('DEFAULT_DEVICE', '0')

View File

@@ -20,6 +20,8 @@ services:
- basic
ports:
- "5050:5050"
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
# - "5443:5443"
# Privileged mode required for USB SDR device access
privileged: true
# USB device mapping for all USB devices
@@ -35,6 +37,9 @@ services:
- INTERCEPT_HOST=0.0.0.0
- INTERCEPT_PORT=5050
- INTERCEPT_LOG_LEVEL=INFO
# HTTPS support (auto-generates self-signed cert)
# - INTERCEPT_HTTPS=true
# - INTERCEPT_PORT=5443
# ADS-B history is disabled by default
# To enable, use: docker compose --profile history up -d
# - INTERCEPT_ADSB_HISTORY_ENABLED=true
@@ -73,6 +78,8 @@ services:
- adsb_db
ports:
- "5050:5050"
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
# - "5443:5443"
# Privileged mode required for USB SDR device access
privileged: true
# USB device mapping for all USB devices
@@ -85,6 +92,9 @@ services:
- INTERCEPT_HOST=0.0.0.0
- INTERCEPT_PORT=5050
- INTERCEPT_LOG_LEVEL=INFO
# HTTPS support (auto-generates self-signed cert)
# - INTERCEPT_HTTPS=true
# - INTERCEPT_PORT=5443
- INTERCEPT_ADSB_HISTORY_ENABLED=true
- INTERCEPT_ADSB_DB_HOST=adsb_db
- INTERCEPT_ADSB_DB_PORT=5432

View File

@@ -24,17 +24,6 @@ Complete feature list for all modules.
- **Wideband spectrum analysis** with real-time visualization
- **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)
- **Comprehensive database** of active number stations and diplomatic networks

View File

@@ -172,7 +172,7 @@ Set the following environment variables (Docker recommended):
```bash
INTERCEPT_ADSB_AUTO_START=true \
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
python app.py
sudo -E venv/bin/python intercept.py
```
**Docker example (.env)**

View File

@@ -110,7 +110,7 @@
<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>
<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 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>
@@ -321,7 +321,7 @@ sudo -E venv/bin/python intercept.py</code></pre>
<div class="code-block">
<pre><code>git clone https://github.com/smittix/intercept.git
cd intercept
docker compose up -d</code></pre>
docker compose --profile basic up -d --build</code></pre>
</div>
<p class="install-note">Requires privileged mode for USB SDR access</p>
</div>
@@ -422,7 +422,7 @@ docker compose up -d</code></pre>
</div>
</div>
<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>
</div>
</div>

View File

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

View File

@@ -2,40 +2,40 @@
def register_blueprints(app):
"""Register all route blueprints with the Flask app."""
from .pager import pager_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 .acars import acars_bp
from .adsb import adsb_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 .aprs import aprs_bp
from .bluetooth import bluetooth_bp
from .bluetooth_v2 import bluetooth_v2_bp
from .bt_locate import bt_locate_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 .subghz import subghz_bp
from .bt_locate import bt_locate_bp
from .analytics import analytics_bp
from .space_weather import space_weather_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 .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(sensor_bp)
@@ -54,7 +54,7 @@ def register_blueprints(app):
app.register_blueprint(gps_bp)
app.register_blueprint(settings_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(tscm_bp)
app.register_blueprint(spy_stations_bp)
@@ -69,10 +69,10 @@ def register_blueprints(app):
app.register_blueprint(recordings_bp) # Session recordings
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
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
# Initialize TSCM state with queue and lock from app
app.register_blueprint(signalid_bp) # External signal ID enrichment
# Initialize TSCM state with queue and lock from app
import app as app_module
if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'):
init_tscm_state(app_module.tscm_queue, app_module.tscm_lock)

View File

@@ -379,10 +379,62 @@ def parse_sbs_stream(service_addr):
adsb_bytes_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:
try:
data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore')
if not data:
flush_pending_updates(force=True)
logger.warning("SBS connection closed (no data)")
break
adsb_bytes_received += len(data)
@@ -501,56 +553,40 @@ def parse_sbs_stream(service_addr):
'squawk': sq, 'meaning': _EMERGENCY_SQUAWKS[sq],
}, '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)
pending_updates.add(icao)
adsb_messages_received += 1
adsb_last_message_time = time.time()
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
flush_pending_updates()
except socket.timeout:
flush_pending_updates()
continue
flush_pending_updates(force=True)
sock.close()
adsb_connected = False
except OSError as e:

View File

@@ -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'})

File diff suppressed because it is too large Load Diff

View File

@@ -96,7 +96,7 @@ def parse_multimon_output(line: str) -> dict[str, str] | None:
return None
def log_message(msg: dict[str, Any]) -> None:
def log_message(msg: dict[str, Any]) -> None:
"""Log a message to file if logging is enabled."""
if not app_module.logging_enabled:
return
@@ -104,25 +104,39 @@ def log_message(msg: dict[str, Any]) -> None:
with open(app_module.log_file_path, 'a') as f:
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n")
except Exception as e:
logger.error(f"Failed to log message: {e}")
def audio_relay_thread(
rtl_stdout,
multimon_stdin,
output_queue: queue.Queue,
stop_event: threading.Event,
) -> None:
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
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
event onto *output_queue*.
"""
CHUNK = 4096 # bytes 2048 samples at 16-bit mono
INTERVAL = 0.1 # seconds between scope updates
last_scope = time.monotonic()
except Exception as 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(
rtl_stdout,
multimon_stdin,
output_queue: queue.Queue,
stop_event: threading.Event,
) -> None:
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
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
event plus a compact waveform sample onto *output_queue*.
"""
CHUNK = 4096 # bytes 2048 samples at 16-bit mono
INTERVAL = 0.1 # seconds between scope updates
last_scope = time.monotonic()
try:
while not stop_event.is_set():
@@ -146,15 +160,16 @@ def audio_relay_thread(
if n_samples == 0:
continue
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
peak = max(abs(s) for s in samples)
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
output_queue.put_nowait({
'type': 'scope',
'rms': rms,
'peak': peak,
})
except (struct.error, ValueError, queue.Full):
pass
peak = max(abs(s) for s in samples)
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
output_queue.put_nowait({
'type': 'scope',
'rms': rms,
'peak': peak,
'waveform': _encode_scope_waveform(samples),
})
except (struct.error, ValueError, queue.Full):
pass
except Exception as e:
logger.debug(f"Audio relay error: {e}")
finally:

View File

@@ -1,14 +1,15 @@
"""RTL_433 sensor monitoring routes."""
from __future__ import annotations
import json
import queue
import subprocess
import threading
import time
from datetime import datetime
from typing import Generator
from __future__ import annotations
import json
import math
import queue
import subprocess
import threading
import time
from datetime import datetime
from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response
@@ -28,12 +29,42 @@ sensor_bp = Blueprint('sensor', __name__)
# Track which device is being used
sensor_active_device: int | None = None
# RSSI history per device (model_id -> list of (timestamp, rssi))
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
_MAX_RSSI_HISTORY = 60
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
# RSSI history per device (model_id -> list of (timestamp, rssi))
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
_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:
"""Stream rtl_433 JSON output to queue."""
try:
app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
@@ -64,16 +95,24 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
rssi = data.get('rssi')
snr = data.get('snr')
noise = data.get('noise')
if rssi is not None or snr is not None:
try:
app_module.sensor_queue.put_nowait({
'type': 'scope',
'rssi': rssi if rssi is not None else 0,
'snr': snr if snr is not None else 0,
'noise': noise if noise is not None else 0,
})
except queue.Full:
pass
if rssi is not None or snr is not None:
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({
'type': 'scope',
'rssi': rssi_value,
'snr': snr_value,
'noise': noise_value,
'waveform': _build_scope_waveform(
rssi=rssi_value,
snr=snr_value,
noise=noise_value,
),
})
except (TypeError, ValueError, queue.Full):
pass
# Log if enabled
if app_module.logging_enabled:

352
routes/signalid.py Normal file
View File

@@ -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,
})

File diff suppressed because it is too large Load Diff

View File

@@ -893,6 +893,92 @@ body {
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 */
.sidebar {
display: flex;

View File

@@ -1802,6 +1802,14 @@ header h1 .tagline {
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 {
background: var(--bg-primary);
display: flex;

View File

@@ -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;
}

View File

@@ -163,29 +163,29 @@
margin-top: 2px;
}
.btl-hud-controls {
display: flex;
flex-direction: column;
gap: 6px;
flex-shrink: 0;
}
.btl-hud-export-row {
display: flex;
gap: 5px;
align-items: center;
}
.btl-hud-export-format {
min-width: 62px;
padding: 3px 6px;
font-size: 10px;
font-family: var(--font-mono);
color: var(--text-secondary);
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
}
.btl-hud-controls {
display: flex;
flex-direction: column;
gap: 6px;
flex-shrink: 0;
}
.btl-hud-export-row {
display: flex;
gap: 5px;
align-items: center;
}
.btl-hud-export-format {
min-width: 62px;
padding: 3px 6px;
font-size: 10px;
font-family: var(--font-mono);
color: var(--text-secondary);
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
}
.btl-hud-audio-toggle {
display: flex;
@@ -266,112 +266,114 @@
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
flex: 1;
min-height: 0;
overflow: hidden;
padding: 8px;
}
.btl-map-container {
flex: 1;
min-height: 250px;
position: relative;
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.btl-map-container {
flex: 1;
min-height: 250px;
position: relative;
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
#btLocateMap {
width: 100%;
height: 100%;
background: #1a1a2e;
}
.btl-map-overlay-controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 450;
display: flex;
flex-direction: column;
gap: 4px;
padding: 7px 8px;
border-radius: 7px;
background: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.15);
backdrop-filter: blur(4px);
}
.btl-map-overlay-toggle {
display: flex;
align-items: center;
gap: 5px;
font-size: 10px;
color: var(--text-secondary);
font-family: var(--font-mono);
cursor: pointer;
white-space: nowrap;
}
.btl-map-overlay-toggle input[type="checkbox"] {
margin: 0;
}
.btl-map-overlay-toggle input[type="checkbox"]:disabled + span {
opacity: 0.45;
}
.btl-map-heat-legend {
position: absolute;
left: 10px;
bottom: 10px;
z-index: 430;
min-width: 120px;
padding: 6px 8px;
border-radius: 7px;
background: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.14);
backdrop-filter: blur(4px);
}
.btl-map-heat-label {
display: block;
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.7px;
margin-bottom: 4px;
}
.btl-map-heat-bar {
height: 7px;
border-radius: 4px;
background: linear-gradient(90deg, #2563eb 0%, #16a34a 40%, #f59e0b 70%, #ef4444 100%);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.btl-map-heat-scale {
display: flex;
justify-content: space-between;
margin-top: 3px;
font-size: 8px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btl-map-track-stats {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 430;
padding: 5px 8px;
border-radius: 7px;
background: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.14);
color: var(--text-secondary);
font-size: 10px;
font-family: var(--font-mono);
backdrop-filter: blur(4px);
}
position: absolute;
inset: 0;
background: #1a1a2e;
}
.btl-map-overlay-controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 450;
display: flex;
flex-direction: column;
gap: 4px;
padding: 7px 8px;
border-radius: 7px;
background: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.15);
backdrop-filter: blur(4px);
}
.btl-map-overlay-toggle {
display: flex;
align-items: center;
gap: 5px;
font-size: 10px;
color: var(--text-secondary);
font-family: var(--font-mono);
cursor: pointer;
white-space: nowrap;
}
.btl-map-overlay-toggle input[type="checkbox"] {
margin: 0;
}
.btl-map-overlay-toggle input[type="checkbox"]:disabled + span {
opacity: 0.45;
}
.btl-map-heat-legend {
position: absolute;
left: 10px;
bottom: 10px;
z-index: 430;
min-width: 120px;
padding: 6px 8px;
border-radius: 7px;
background: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.14);
backdrop-filter: blur(4px);
}
.btl-map-heat-label {
display: block;
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.7px;
margin-bottom: 4px;
}
.btl-map-heat-bar {
height: 7px;
border-radius: 4px;
background: linear-gradient(90deg, #2563eb 0%, #16a34a 40%, #f59e0b 70%, #ef4444 100%);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.btl-map-heat-scale {
display: flex;
justify-content: space-between;
margin-top: 3px;
font-size: 8px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btl-map-track-stats {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 430;
padding: 5px 8px;
border-radius: 7px;
background: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.14);
color: var(--text-secondary);
font-size: 10px;
font-family: var(--font-mono);
backdrop-filter: blur(4px);
}
.btl-rssi-chart-container {
height: 100px;
@@ -511,7 +513,7 @@
RESPONSIVE — stack HUD vertically on narrow
============================================ */
@media (max-width: 900px) {
@media (max-width: 900px) {
.btl-hud {
flex-wrap: wrap;
gap: 10px;
@@ -528,33 +530,99 @@
justify-content: space-around;
}
.btl-hud-controls {
flex-direction: row;
width: 100%;
justify-content: center;
flex-wrap: wrap;
}
.btl-hud-export-row {
width: 100%;
justify-content: center;
}
.btl-map-overlay-controls {
top: 8px;
right: 8px;
gap: 3px;
padding: 6px 7px;
}
.btl-map-heat-legend {
left: 8px;
bottom: 8px;
}
.btl-map-track-stats {
right: 8px;
bottom: 8px;
font-size: 9px;
}
}
.btl-hud-controls {
flex-direction: row;
width: 100%;
justify-content: center;
flex-wrap: wrap;
}
.btl-hud-export-row {
width: 100%;
justify-content: center;
}
.btl-map-overlay-controls {
top: 8px;
right: 8px;
gap: 3px;
padding: 6px 7px;
}
.btl-map-heat-legend {
left: 8px;
bottom: 8px;
}
.btl-map-track-stats {
right: 8px;
bottom: 8px;
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;
}
}

View File

@@ -139,16 +139,67 @@
letter-spacing: 0.5px;
}
.gps-skyview-canvas-wrap {
display: flex;
justify-content: center;
align-items: center;
}
#gpsSkyCanvas {
max-width: 100%;
height: auto;
}
.gps-skyview-canvas-wrap {
position: relative;
display: block;
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 {
display: block;
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 */
.gps-position-panel {

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
static/icons/favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 B

BIN
static/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
static/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

20
static/icons/icon.svg Normal file
View File

@@ -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

View File

@@ -1,7 +1,7 @@
/**
* Activity Timeline Component
* 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() {
@@ -176,7 +176,7 @@ const ActivityTimeline = (function() {
*/
function categorizeById(id, mode) {
// RF frequency categorization
if (mode === 'rf' || mode === 'tscm' || mode === 'listening-post') {
if (mode === 'rf' || mode === 'tscm' || mode === 'waterfall') {
const f = parseFloat(id);
if (!isNaN(f)) {
if (f >= 2400 && f <= 2500) return '2.4 GHz wireless band';

View File

@@ -1,8 +1,8 @@
/**
* RF Signal Timeline Adapter
* Normalizes RF signal data for the Activity Timeline component
* Used by: Listening Post, TSCM
*/
/**
* RF Signal Timeline Adapter
* Normalizes RF signal data for the Activity Timeline component
* Used by: Spectrum Waterfall, TSCM
*/
const RFTimelineAdapter = (function() {
'use strict';
@@ -157,16 +157,16 @@ const RFTimelineAdapter = (function() {
return signals.map(normalizer);
}
/**
* Create timeline configuration for Listening Post mode
*/
function getListeningPostConfig() {
return {
title: 'Signal Activity',
mode: 'listening-post',
visualMode: 'enriched',
collapsed: false,
showAnnotations: true,
/**
* Create timeline configuration for spectrum waterfall mode.
*/
function getWaterfallConfig() {
return {
title: 'Spectrum Activity',
mode: 'waterfall',
visualMode: 'enriched',
collapsed: false,
showAnnotations: true,
showLegend: true,
defaultWindow: '15m',
availableWindows: ['5m', '15m', '30m', '1h'],
@@ -184,9 +184,14 @@ const RFTimelineAdapter = (function() {
}
],
maxItems: 50,
maxDisplayedLanes: 12
};
}
maxDisplayedLanes: 12
};
}
// Backward compatibility alias for legacy callers.
function getListeningPostConfig() {
return getWaterfallConfig();
}
/**
* Create timeline configuration for TSCM mode
@@ -224,8 +229,9 @@ const RFTimelineAdapter = (function() {
categorizeFrequency: categorizeFrequency,
// Configuration presets
getListeningPostConfig: getListeningPostConfig,
getTscmConfig: getTscmConfig,
getWaterfallConfig: getWaterfallConfig,
getListeningPostConfig: getListeningPostConfig,
getTscmConfig: getTscmConfig,
// Constants
RSSI_THRESHOLDS: RSSI_THRESHOLDS,

View File

@@ -98,7 +98,7 @@ function switchMode(mode) {
const modeMap = {
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
'listening': 'listening', 'meshtastic': 'meshtastic'
'meshtastic': 'meshtastic'
};
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
const label = btn.querySelector('.nav-label');
@@ -114,7 +114,6 @@ function switchMode(mode) {
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
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('tscmMode')?.classList.toggle('active', mode === 'tscm');
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
@@ -143,7 +142,6 @@ function switchMode(mode) {
'satellite': 'SATELLITE',
'wifi': 'WIFI',
'bluetooth': 'BLUETOOTH',
'listening': 'LISTENING POST',
'tscm': 'TSCM',
'aprs': 'APRS',
'meshtastic': 'MESHTASTIC'
@@ -166,7 +164,6 @@ function switchMode(mode) {
const showRadar = document.getElementById('adsbEnableMap')?.checked;
document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : '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
const titles = {
@@ -176,7 +173,6 @@ function switchMode(mode) {
'satellite': 'Satellite Monitor',
'wifi': 'WiFi Scanner',
'bluetooth': 'Bluetooth Scanner',
'listening': 'Listening Post',
'meshtastic': 'Meshtastic Mesh 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
const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
if (mode === 'satellite' || mode === 'aircraft' || mode === 'listening') {
if (mode === 'satellite' || mode === 'aircraft') {
document.getElementById('reconPanel').style.display = 'none';
if (reconBtn) reconBtn.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
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
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
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 =
(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';
@@ -226,11 +222,6 @@ function switchMode(mode) {
} else if (mode === 'satellite') {
if (typeof initPolarPlot === 'function') initPolarPlot();
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') {
if (typeof Meshtastic !== 'undefined' && Meshtastic.init) Meshtastic.init();
}

View File

@@ -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 3845 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.1137.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;

View File

@@ -12,8 +12,8 @@ const CommandPalette = (function() {
{ mode: 'pager', label: 'Pager' },
{ mode: 'sensor', label: '433MHz Sensors' },
{ mode: 'rtlamr', label: 'Meters' },
{ mode: 'listening', label: 'Listening Post' },
{ mode: 'subghz', label: 'SubGHz' },
{ mode: 'waterfall', label: 'Spectrum Waterfall' },
{ mode: 'aprs', label: 'APRS' },
{ mode: 'wifi', label: 'WiFi Scanner' },
{ mode: 'bluetooth', label: 'Bluetooth Scanner' },
@@ -25,7 +25,6 @@ const CommandPalette = (function() {
{ mode: 'gps', label: 'GPS' },
{ mode: 'meshtastic', label: 'Meshtastic' },
{ mode: 'websdr', label: 'WebSDR' },
{ mode: 'analytics', label: 'Analytics' },
{ mode: 'spaceweather', label: 'Space Weather' },
];
@@ -188,13 +187,39 @@ const CommandPalette = (function() {
title: 'View Aircraft Dashboard',
description: 'Open dedicated ADS-B dashboard page',
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',
description: 'Open dedicated AIS dashboard page',
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',

View File

@@ -130,7 +130,7 @@ const FirstRunSetup = (function() {
['pager', 'Pager'],
['sensor', '433MHz'],
['rtlamr', 'Meters'],
['listening', 'Listening Post'],
['waterfall', 'Waterfall'],
['wifi', 'WiFi'],
['bluetooth', 'Bluetooth'],
['bt_locate', 'BT Locate'],
@@ -139,7 +139,6 @@ const FirstRunSetup = (function() {
['sstv', 'ISS SSTV'],
['weathersat', 'Weather Sat'],
['sstv_general', 'HF SSTV'],
['analytics', 'Analytics'],
];
for (const [value, label] of modes) {
const opt = document.createElement('option');
@@ -150,7 +149,11 @@ const FirstRunSetup = (function() {
const savedDefaultMode = localStorage.getItem(DEFAULT_MODE_KEY);
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);

View File

@@ -18,6 +18,18 @@
if (menuLink) {
event.preventDefault();
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;
return;
}

View File

@@ -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;

View File

@@ -114,13 +114,7 @@ const RecordingUI = (function() {
function openReplay(sessionId) {
if (!sessionId) return;
localStorage.setItem('analyticsReplaySession', sessionId);
if (typeof hideSettings === 'function') hideSettings();
if (typeof switchMode === 'function') {
switchMode('analytics', { updateUrl: true });
return;
}
window.location.href = '/?mode=analytics';
window.open(`/recordings/${sessionId}/download`, '_blank');
}
function escapeHtml(str) {

View File

@@ -98,24 +98,15 @@ const Settings = {
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.
* @param {Object} [config]
*/
_syncRootMapThemeClass(config) {
if (typeof document === 'undefined' || !document.documentElement) return;
const enabled = this._isCyberThemeEnabled(config);
document.documentElement.classList.toggle('map-cyber-enabled', enabled);
const resolvedConfig = config || this.getTileConfig();
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);
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)';
tilePane.style.opacity = '1';
tilePane.style.willChange = 'filter';
if (themeClass === 'map-theme-cyber') {
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)';
tilePane.style.opacity = '1';
tilePane.style.willChange = 'filter';
}
}
// Grid/glow overlays are rendered via CSS pseudo elements on
// `html.map-cyber-enabled .leaflet-container` for consistent stacking.
// Map overlays are rendered via CSS pseudo elements on
// `html.map-*-enabled .leaflet-container` for consistent stacking.
},
/**
@@ -1265,6 +1258,7 @@ function switchSettingsTab(tabName) {
} else if (tabName === 'location') {
loadObserverLocation();
} else if (tabName === 'alerts') {
loadVoiceAlertConfig();
if (typeof AlertCenter !== 'undefined') {
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
*/

View File

@@ -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;

View File

@@ -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)">&#8596;</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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,
};
})();

View File

@@ -944,21 +944,36 @@ const BluetoothMode = (function() {
}
}
async function stopScan() {
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
try {
if (isAgentMode) {
await fetch(`/controller/agents/${currentAgent}/bluetooth/stop`, { method: 'POST' });
} else {
await fetch('/api/bluetooth/scan/stop', { method: 'POST' });
}
setScanning(false);
stopEventStream();
} catch (err) {
console.error('Failed to stop scan:', err);
}
}
async function stopScan() {
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 {
if (isAgentMode) {
await fetch(`/controller/agents/${currentAgent}/bluetooth/stop`, {
method: 'POST',
...(controller ? { signal: controller.signal } : {}),
});
} else {
await fetch('/api/bluetooth/scan/stop', {
method: 'POST',
...(controller ? { signal: controller.signal } : {}),
});
}
} catch (err) {
console.error('Failed to stop scan:', err);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
function setScanning(scanning) {
isScanning = scanning;

File diff suppressed because it is too large Load Diff

View File

@@ -4,11 +4,14 @@
* position/velocity/DOP readout. Connects to gpsd via backend SSE stream.
*/
const GPS = (function() {
let connected = false;
let lastPosition = null;
let lastSky = null;
let skyPollTimer = null;
const GPS = (function() {
let connected = false;
let lastPosition = null;
let lastSky = null;
let skyPollTimer = null;
let themeObserver = null;
let skyRenderer = null;
let skyRendererInitAttempted = false;
// Constellation color map
const CONST_COLORS = {
@@ -20,20 +23,45 @@ const GPS = (function() {
'QZSS': '#cc66ff',
};
function init() {
drawEmptySkyView();
connect();
// Redraw sky view when theme changes
const observer = new MutationObserver(() => {
if (lastSky) {
drawSkyView(lastSky.satellites || []);
} else {
drawEmptySkyView();
}
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
}
function init() {
initSkyRenderer();
drawEmptySkyView();
if (!connected) connect();
// Redraw sky view when theme changes
if (!themeObserver) {
themeObserver = new MutationObserver(() => {
if (skyRenderer && typeof skyRenderer.requestRender === 'function') {
skyRenderer.requestRender();
}
if (lastSky) {
drawSkyView(lastSky.satellites || []);
} 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() {
updateConnectionUI(false, false, 'connecting');
@@ -252,139 +280,745 @@ const GPS = (function() {
if (el) el.textContent = val;
}
// ========================
// Sky View Polar Plot
// ========================
function drawEmptySkyView() {
const canvas = document.getElementById('gpsSkyCanvas');
if (!canvas) return;
drawSkyViewBase(canvas);
}
function drawSkyView(satellites) {
const canvas = document.getElementById('gpsSkyCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
const cx = w / 2;
const cy = h / 2;
const r = Math.min(cx, cy) - 24;
drawSkyViewBase(canvas);
// Plot satellites
satellites.forEach(sat => {
if (sat.elevation == null || sat.azimuth == null) return;
const elRad = (90 - sat.elevation) / 90;
const azRad = (sat.azimuth - 90) * Math.PI / 180; // N = up
const px = cx + r * elRad * Math.cos(azRad);
const py = cy + r * elRad * Math.sin(azRad);
const color = CONST_COLORS[sat.constellation] || CONST_COLORS['GPS'];
const dotSize = sat.used ? 6 : 4;
// Draw dot
ctx.beginPath();
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
if (sat.used) {
ctx.fillStyle = color;
ctx.fill();
} else {
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.stroke();
}
// PRN label
ctx.fillStyle = color;
ctx.font = '8px Roboto Condensed, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(sat.prn, px, py - dotSize - 2);
// SNR value
if (sat.snr != null) {
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = '7px Roboto Condensed, monospace';
ctx.textBaseline = 'top';
ctx.fillText(Math.round(sat.snr), px, py + dotSize + 1);
}
});
}
function drawSkyViewBase(canvas) {
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
const cx = w / 2;
const cy = h / 2;
const r = Math.min(cx, cy) - 24;
ctx.clearRect(0, 0, w, h);
const cs = getComputedStyle(document.documentElement);
const bgColor = cs.getPropertyValue('--bg-card').trim() || '#0d1117';
const gridColor = cs.getPropertyValue('--border-color').trim() || '#2a3040';
const dimColor = cs.getPropertyValue('--text-dim').trim() || '#555';
const secondaryColor = cs.getPropertyValue('--text-secondary').trim() || '#888';
// Background
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, w, h);
// Elevation rings (0, 30, 60, 90)
ctx.strokeStyle = gridColor;
ctx.lineWidth = 0.5;
[90, 60, 30].forEach(el => {
const gr = r * (1 - el / 90);
ctx.beginPath();
ctx.arc(cx, cy, gr, 0, Math.PI * 2);
ctx.stroke();
// Label
ctx.fillStyle = dimColor;
ctx.font = '9px Roboto Condensed, monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
});
// Horizon circle
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
// Cardinal directions
ctx.fillStyle = secondaryColor;
ctx.font = 'bold 11px Roboto Condensed, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('N', cx, cy - r - 12);
ctx.fillText('S', cx, cy + r + 12);
ctx.fillText('E', cx + r + 12, cy);
ctx.fillText('W', cx - r - 12, cy);
// Crosshairs
ctx.strokeStyle = gridColor;
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx, cy + r);
ctx.moveTo(cx - r, cy);
ctx.lineTo(cx + r, cy);
ctx.stroke();
// Zenith dot
ctx.fillStyle = dimColor;
ctx.beginPath();
ctx.arc(cx, cy, 2, 0, Math.PI * 2);
ctx.fill();
}
// ========================
// Sky View Globe (WebGL with 2D fallback)
// ========================
function drawEmptySkyView() {
if (!skyRendererInitAttempted) {
initSkyRenderer();
}
if (skyRenderer) {
skyRenderer.setSatellites([]);
return;
}
const canvas = document.getElementById('gpsSkyCanvas');
if (!canvas) return;
drawSkyViewBase2D(canvas);
}
function drawSkyView(satellites) {
if (!skyRendererInitAttempted) {
initSkyRenderer();
}
const sats = Array.isArray(satellites) ? satellites : [];
if (skyRenderer) {
skyRenderer.setSatellites(sats);
return;
}
const canvas = document.getElementById('gpsSkyCanvas');
if (!canvas) return;
drawSkyViewBase2D(canvas);
const ctx = canvas.getContext('2d');
if (!ctx) return;
const w = canvas.width;
const h = canvas.height;
const cx = w / 2;
const cy = h / 2;
const r = Math.min(cx, cy) - 24;
sats.forEach(sat => {
if (sat.elevation == null || sat.azimuth == null) return;
const elRad = (90 - sat.elevation) / 90;
const azRad = (sat.azimuth - 90) * Math.PI / 180;
const px = cx + r * elRad * Math.cos(azRad);
const py = cy + r * elRad * Math.sin(azRad);
const color = CONST_COLORS[sat.constellation] || CONST_COLORS.GPS;
const dotSize = sat.used ? 6 : 4;
ctx.beginPath();
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
if (sat.used) {
ctx.fillStyle = color;
ctx.fill();
} else {
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.stroke();
}
ctx.fillStyle = color;
ctx.font = '8px Roboto Condensed, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(sat.prn, px, py - dotSize - 2);
if (sat.snr != null) {
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = '7px Roboto Condensed, monospace';
ctx.textBaseline = 'top';
ctx.fillText(Math.round(sat.snr), px, py + dotSize + 1);
}
});
}
function drawSkyViewBase2D(canvas) {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const w = canvas.width;
const h = canvas.height;
const cx = w / 2;
const cy = h / 2;
const r = Math.min(cx, cy) - 24;
ctx.clearRect(0, 0, w, h);
const cs = getComputedStyle(document.documentElement);
const bgColor = cs.getPropertyValue('--bg-card').trim() || '#0d1117';
const gridColor = cs.getPropertyValue('--border-color').trim() || '#2a3040';
const dimColor = cs.getPropertyValue('--text-dim').trim() || '#555';
const secondaryColor = cs.getPropertyValue('--text-secondary').trim() || '#888';
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = gridColor;
ctx.lineWidth = 0.5;
[90, 60, 30].forEach(el => {
const gr = r * (1 - el / 90);
ctx.beginPath();
ctx.arc(cx, cy, gr, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = dimColor;
ctx.font = '9px Roboto Condensed, monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
});
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = secondaryColor;
ctx.font = 'bold 11px Roboto Condensed, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('N', cx, cy - r - 12);
ctx.fillText('S', cx, cy + r + 12);
ctx.fillText('E', cx + r + 12, cy);
ctx.fillText('W', cx - r - 12, cy);
ctx.strokeStyle = gridColor;
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx, cy + r);
ctx.moveTo(cx - r, cy);
ctx.lineTo(cx + r, cy);
ctx.stroke();
ctx.fillStyle = dimColor;
ctx.beginPath();
ctx.arc(cx, cy, 2, 0, Math.PI * 2);
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
@@ -439,10 +1073,19 @@ const GPS = (function() {
// Cleanup
// ========================
function destroy() {
unsubscribeFromStream();
stopSkyPolling();
}
function destroy() {
unsubscribeFromStream();
stopSkyPolling();
if (themeObserver) {
themeObserver.disconnect();
themeObserver = null;
}
if (skyRenderer) {
skyRenderer.destroy();
skyRenderer = null;
}
skyRendererInitAttempted = false;
}
return {
init: init,

File diff suppressed because it is too large Load Diff

View File

@@ -269,12 +269,10 @@ const SpyStations = (function() {
*/
function tuneToStation(stationId, freqKhz) {
const freqMhz = freqKhz / 1000;
sessionStorage.setItem('tuneFrequency', freqMhz.toString());
// Find the station and determine mode
const station = stations.find(s => s.id === stationId);
const tuneMode = station ? getModeFromStation(station.mode) : 'usb';
sessionStorage.setItem('tuneMode', tuneMode);
const stationName = station ? station.name : 'Station';
@@ -282,12 +280,18 @@ const SpyStations = (function() {
showNotification('Tuning to ' + stationName, formatFrequency(freqKhz) + ' (' + tuneMode.toUpperCase() + ')');
}
// Switch to listening post mode
if (typeof selectMode === 'function') {
selectMode('listening');
} else if (typeof switchMode === 'function') {
switchMode('listening');
// Switch to spectrum waterfall mode and tune after mode init.
if (typeof switchMode === 'function') {
switchMode('waterfall');
} else if (typeof selectMode === 'function') {
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
*/
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-title">How to Listen</div>
<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
HF frequencies (typically 3-30 MHz) and an appropriate antenna.
</p>

View File

@@ -15,13 +15,21 @@ const SSTVGeneral = (function() {
let sstvGeneralScopeCtx = null;
let sstvGeneralScopeAnim = null;
let sstvGeneralScopeHistory = [];
let sstvGeneralScopeWaveBuffer = [];
let sstvGeneralScopeDisplayWave = [];
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 sstvGeneralScopePeak = 0;
let sstvGeneralScopeTargetRms = 0;
let sstvGeneralScopeTargetPeak = 0;
let sstvGeneralScopeMsgBurst = 0;
let sstvGeneralScopeTone = null;
let sstvGeneralScopeLastWaveAt = 0;
let sstvGeneralScopeLastInputSample = 0;
/**
* Initialize the SSTV General mode
@@ -205,20 +213,64 @@ const SSTVGeneral = (function() {
/**
* 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() {
const canvas = document.getElementById('sstvGeneralScopeCanvas');
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * (window.devicePixelRatio || 1);
canvas.height = rect.height * (window.devicePixelRatio || 1);
if (sstvGeneralScopeAnim) {
cancelAnimationFrame(sstvGeneralScopeAnim);
sstvGeneralScopeAnim = null;
}
resizeSstvGeneralScopeCanvas(canvas);
sstvGeneralScopeCtx = canvas.getContext('2d');
sstvGeneralScopeHistory = new Array(SSTV_GENERAL_SCOPE_LEN).fill(0);
sstvGeneralScopeWaveBuffer = [];
sstvGeneralScopeDisplayWave = [];
sstvGeneralScopeRms = 0;
sstvGeneralScopePeak = 0;
sstvGeneralScopeTargetRms = 0;
sstvGeneralScopeTargetPeak = 0;
sstvGeneralScopeMsgBurst = 0;
sstvGeneralScopeTone = null;
sstvGeneralScopeLastWaveAt = 0;
sstvGeneralScopeLastInputSample = 0;
drawSstvGeneralScope();
}
@@ -228,12 +280,14 @@ const SSTVGeneral = (function() {
function drawSstvGeneralScope() {
const ctx = sstvGeneralScopeCtx;
if (!ctx) return;
resizeSstvGeneralScopeCanvas(ctx.canvas);
const W = ctx.canvas.width;
const H = ctx.canvas.height;
const midY = H / 2;
// Phosphor persistence
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
ctx.fillStyle = 'rgba(5, 5, 16, 0.26)';
ctx.fillRect(0, 0, W, H);
// Smooth towards target
@@ -256,32 +310,84 @@ const SSTVGeneral = (function() {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
}
// Waveform
const stepX = W / (SSTV_GENERAL_SCOPE_LEN - 1);
ctx.strokeStyle = '#c080ff';
ctx.lineWidth = 1.5;
ctx.shadowColor = '#c080ff';
ctx.shadowBlur = 4;
// Upper half
// Envelope
const envStepX = W / (SSTV_GENERAL_SCOPE_LEN - 1);
ctx.strokeStyle = 'rgba(168, 110, 255, 0.45)';
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
const x = i * stepX;
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
const x = i * envStepX;
const amp = sstvGeneralScopeHistory[i] * midY * 0.85;
const y = midY - amp;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// Lower half (mirror)
ctx.beginPath();
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
const x = i * stepX;
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
const x = i * envStepX;
const amp = sstvGeneralScopeHistory[i] * midY * 0.85;
const y = midY + amp;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
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;
// Peak indicator
@@ -317,8 +423,17 @@ const SSTVGeneral = (function() {
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
}
if (statusLabel) {
if (sstvGeneralScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
const waveIsFresh = (performance.now() - sstvGeneralScopeLastWaveAt) < 1000;
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);
@@ -330,6 +445,11 @@ const SSTVGeneral = (function() {
function stopSstvGeneralScope() {
if (sstvGeneralScopeAnim) { cancelAnimationFrame(sstvGeneralScopeAnim); sstvGeneralScopeAnim = null; }
sstvGeneralScopeCtx = null;
sstvGeneralScopeWaveBuffer = [];
sstvGeneralScopeDisplayWave = [];
sstvGeneralScopeHistory = [];
sstvGeneralScopeLastWaveAt = 0;
sstvGeneralScopeLastInputSample = 0;
}
/**
@@ -353,9 +473,7 @@ const SSTVGeneral = (function() {
if (data.type === 'sstv_progress') {
handleProgress(data);
} else if (data.type === 'sstv_scope') {
sstvGeneralScopeTargetRms = data.rms;
sstvGeneralScopeTargetPeak = data.peak;
if (data.tone !== undefined) sstvGeneralScopeTone = data.tone;
applySstvGeneralScopeData(data);
}
} catch (err) {
console.error('Failed to parse SSE message:', err);

3481
static/js/modes/waterfall.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,20 @@ let websdrMarkers = [];
let websdrReceivers = [];
let websdrInitialized = 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
let kiwiWebSocket = null;
@@ -29,54 +43,50 @@ const KIWI_SAMPLE_RATE = 12000;
async function initWebSDR() {
if (websdrInitialized) {
if (websdrMap) {
setTimeout(() => websdrMap.invalidateSize(), 100);
}
setTimeout(invalidateWebSDRViewport, 100);
return;
}
const mapEl = document.getElementById('websdrMap');
if (!mapEl || typeof L === 'undefined') return;
if (!mapEl) return;
// Calculate minimum zoom so tiles fill the container vertically
const mapHeight = mapEl.clientHeight || 500;
const minZoom = Math.ceil(Math.log2(mapHeight / 256));
const globeReady = await ensureWebsdrGlobeLibrary();
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,
});
// Wait for a paint frame so the browser computes layout after the
// display:flex change in switchMode. Without this, Globe()(mapEl) can
// run before clientWidth/clientHeight are non-zero (especially when
// scripts are served from cache and resolve before the first layout pass).
await new Promise(resolve => requestAnimationFrame(resolve));
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
await Settings.init();
Settings.createTileLayer().addTo(websdrMap);
Settings.registerMap(websdrMap);
// If the mode was switched away while scripts were loading, abort so
// websdrInitialized stays false and we retry cleanly next time.
if (!mapEl.clientWidth || !mapEl.clientHeight) return;
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 {
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; OpenStreetMap contributors &copy; CARTO',
subdomains: 'abcd',
maxZoom: 19,
className: 'tile-layer-cyan',
}).addTo(websdrMap);
console.error('[WEBSDR] Unable to initialize globe or map renderer');
return;
}
// Match background to tile ocean color so any remaining edge is seamless
mapEl.style.background = '#1a1d29';
websdrInitialized = true;
if (!websdrSpyStationsLoaded) {
loadSpyStationPresets();
}
setupWebsdrResizeHandling(mapEl);
if (websdrReceivers.length > 0) {
plotReceiversOnMap(websdrReceivers);
}
[100, 300, 600, 1000].forEach(delay => {
setTimeout(() => {
if (websdrMap) websdrMap.invalidateSize();
}, delay);
setTimeout(invalidateWebSDRViewport, delay);
});
}
@@ -94,6 +104,8 @@ function searchReceivers(refresh) {
.then(data => {
if (data.status === 'success') {
websdrReceivers = data.receivers || [];
websdrSelectedReceiverIndex = null;
hideWebsdrGlobePopup();
renderReceiverList(websdrReceivers);
plotReceiversOnMap(websdrReceivers);
@@ -107,6 +119,11 @@ function searchReceivers(refresh) {
// ============== MAP ==============
function plotReceiversOnMap(receivers) {
if (websdrMapType === 'globe' && websdrGlobe) {
plotReceiversOnGlobe(receivers);
return;
}
if (!websdrMap) return;
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: '&copy; OpenStreetMap contributors &copy; 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;">&times;</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 ==============
function renderReceiverList(receivers) {
@@ -155,12 +535,16 @@ function renderReceiverList(receivers) {
return;
}
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;"
onmouseover="this.style.background='rgba(0,212,255,0.05)'" onmouseout="this.style.background='transparent'"
container.innerHTML = receivers.slice(0, 50).map((rx, idx) => {
const selected = idx === websdrSelectedReceiverIndex;
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})">
<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>
</div>
<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` : ''}
</div>
</div>
`).join('');
`;
}).join('');
}
// ============== SELECT RECEIVER ==============
@@ -180,14 +565,30 @@ function selectReceiver(index) {
const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 7000);
const mode = document.getElementById('websdrMode_select')?.value || 'am';
websdrSelectedReceiverIndex = index;
renderReceiverList(websdrReceivers);
focusReceiverOnMap(rx);
hideWebsdrGlobePopup();
kiwiReceiverName = rx.name;
// Connect via backend proxy
connectToReceiver(rx.url, freqKhz, mode);
}
// Highlight on map
if (websdrMap && rx.lat != null && rx.lon != null) {
websdrMap.setView([rx.lat, rx.lon], 6);
function focusReceiverOnMap(rx) {
const lat = Number(rx.lat);
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 => {
if (data.status === 'success') {
websdrReceivers = data.receivers || [];
websdrSelectedReceiverIndex = null;
hideWebsdrGlobePopup();
renderReceiverList(websdrReceivers);
plotReceiversOnMap(websdrReceivers);

View File

@@ -572,8 +572,8 @@ const WiFiMode = (function() {
}
}
async function stopScan() {
console.log('[WiFiMode] Stopping scan...');
async function stopScan() {
console.log('[WiFiMode] Stopping scan...');
// Stop polling
if (pollTimer) {
@@ -585,26 +585,41 @@ const WiFiMode = (function() {
stopAgentDeepScanPolling();
// Close event stream
if (eventSource) {
eventSource.close();
eventSource = null;
}
// Stop scan on server (local or agent)
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
try {
if (isAgentMode) {
await fetch(`/controller/agents/${currentAgent}/wifi/stop`, { method: 'POST' });
} else if (scanMode === 'deep') {
await fetch(`${CONFIG.apiBase}/scan/stop`, { method: 'POST' });
}
} catch (error) {
console.warn('[WiFiMode] Error stopping scan:', error);
}
setScanning(false);
}
if (eventSource) {
eventSource.close();
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)
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 {
if (isAgentMode) {
await fetch(`/controller/agents/${currentAgent}/wifi/stop`, {
method: 'POST',
...(controller ? { signal: controller.signal } : {}),
});
} else if (scanMode === 'deep') {
await fetch(`${CONFIG.apiBase}/scan/stop`, {
method: 'POST',
...(controller ? { signal: controller.signal } : {}),
});
}
} catch (error) {
console.warn('[WiFiMode] Error stopping scan:', error);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
function setScanning(scanning, mode = null) {
isScanning = scanning;

297
static/js/vendor/leaflet-heat.js vendored Normal file
View File

@@ -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);
};
}());

27
static/manifest.json Normal file
View File

@@ -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
static/sw.js Normal file
View File

@@ -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 }))
)
);
});

View File

@@ -258,6 +258,10 @@
<div class="display-container">
<div id="radarMap">
</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>
@@ -429,6 +433,17 @@
let alertsEnabled = true;
let detectionSoundEnabled = localStorage.getItem('adsb_detectionSound') !== 'false'; // Default on
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
let watchlist = JSON.parse(localStorage.getItem('adsb_watchlist') || '[]');
@@ -2620,7 +2635,7 @@ sudo make install</code>
} else {
markers[icao] = L.marker([ac.lat, ac.lon], { icon: createMarkerIcon(rotation, color, iconType, isSelected) })
.addTo(radarMap)
.on('click', () => selectAircraft(icao));
.on('click', () => selectAircraft(icao, 'map'));
markers[icao].bindTooltip(`${callsign}<br>${alt}`, {
permanent: false, direction: 'top', className: 'aircraft-tooltip'
});
@@ -2724,7 +2739,7 @@ sudo make install</code>
const div = document.createElement('div');
div.className = `aircraft-item ${selectedIcao === ac.icao ? 'selected' : ''} ${isOnWatchlist(ac) ? 'watched' : ''}`;
div.setAttribute('data-icao', ac.icao);
div.onclick = () => selectAircraft(ac.icao);
div.onclick = () => selectAircraft(ac.icao, 'panel');
div.innerHTML = buildAircraftItemHTML(ac);
fragment.appendChild(div);
});
@@ -2797,34 +2812,139 @@ sudo make install</code>
`;
}
function selectAircraft(icao) {
// Toggle: clicking the same aircraft deselects it
if (selectedIcao === icao) {
const prev = selectedIcao;
selectedIcao = null;
// Reset previous marker icon
if (prev && markers[prev] && aircraft[prev]) {
const ac = aircraft[prev];
const militaryInfo = isMilitaryAircraft(prev, ac.callsign);
const rotation = Math.round((ac.heading || 0) / 5) * 5;
const color = militaryInfo.military ? '#556b2f' : getAltitudeColor(ac.altitude);
const iconType = getAircraftIconType(ac.type_code, militaryInfo.military);
markers[prev].setIcon(createMarkerIcon(rotation, color, iconType, false));
if (markerState[prev]) markerState[prev].isSelected = false;
}
renderAircraftList();
showAircraftDetails(null);
updateFlightLookupBtn();
highlightSidebarMessages(null);
if (acarsMessageTimer) {
clearInterval(acarsMessageTimer);
acarsMessageTimer = null;
}
function triggerMapCrosshairAnimation(lat, lon, durationMs = MAP_CROSSHAIR_DURATION_MS, lockToMapCenter = false) {
if (!radarMap) return;
const overlay = document.getElementById('mapCrosshairOverlay');
if (!overlay) return;
const size = radarMap.getSize();
let targetX;
let targetY;
if (lockToMapCenter) {
targetX = size.x / 2;
targetY = size.y / 2;
} else {
const point = radarMap.latLngToContainerPoint([lat, lon]);
targetX = Math.max(0, Math.min(size.x, point.x));
targetY = Math.max(0, Math.min(size.y, point.y));
}
const startX = size.x + 8;
const startY = size.y + 8;
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;
}
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;
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
[prevSelected, icao].forEach(targetIcao => {
@@ -2850,7 +2970,15 @@ sudo make install</code>
const ac = aircraft[icao];
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() {
// Check if audio tools are available
fetch('/listening/tools')
fetch('/receiver/tools')
.then(r => r.json())
.then(data => {
const missingTools = [];
@@ -3414,7 +3542,7 @@ sudo make install</code>
try {
// Start audio on backend
const response = await fetch('/listening/audio/start', {
const response = await fetch('/receiver/audio/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -3451,7 +3579,7 @@ sudo make install</code>
audioPlayer.load();
// 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);
audioPlayer.src = streamUrl;
@@ -3495,7 +3623,7 @@ sudo make install</code>
audioPlayer.pause();
audioPlayer.src = '';
fetch('/listening/audio/stop', { method: 'POST' })
fetch('/receiver/audio/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
isAirbandPlaying = false;

File diff suppressed because it is too large Load Diff

View File

@@ -4,20 +4,20 @@
#}
<!-- Help Modal -->
<div id="helpModal" class="help-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="helpModalTitle" onclick="if(event.target === this) hideHelp()">
<div class="help-content" tabindex="-1">
<button type="button" class="help-close" onclick="hideHelp()" aria-label="Close help">&times;</button>
<h2 id="helpModalTitle">iNTERCEPT Help</h2>
<div class="help-tabs" role="tablist" aria-label="Help sections">
<button type="button" class="help-tab active" data-tab="icons" onclick="switchHelpTab('icons')" role="tab" aria-controls="help-icons" aria-selected="true">Icons</button>
<button type="button" class="help-tab" data-tab="modes" onclick="switchHelpTab('modes')" role="tab" aria-controls="help-modes" aria-selected="false">Modes</button>
<button type="button" class="help-tab" data-tab="wifi" onclick="switchHelpTab('wifi')" role="tab" aria-controls="help-wifi" aria-selected="false">WiFi</button>
<button type="button" class="help-tab" data-tab="tips" onclick="switchHelpTab('tips')" role="tab" aria-controls="help-tips" aria-selected="false">Tips</button>
</div>
<!-- Icons Section -->
<div id="help-icons" class="help-section active" role="tabpanel">
<div id="helpModal" class="help-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="helpModalTitle" onclick="if(event.target === this) hideHelp()">
<div class="help-content" tabindex="-1">
<button type="button" class="help-close" onclick="hideHelp()" aria-label="Close help">&times;</button>
<h2 id="helpModalTitle">iNTERCEPT Help</h2>
<div class="help-tabs" role="tablist" aria-label="Help sections">
<button type="button" class="help-tab active" data-tab="icons" onclick="switchHelpTab('icons')" role="tab" aria-controls="help-icons" aria-selected="true">Icons</button>
<button type="button" class="help-tab" data-tab="modes" onclick="switchHelpTab('modes')" role="tab" aria-controls="help-modes" aria-selected="false">Modes</button>
<button type="button" class="help-tab" data-tab="wifi" onclick="switchHelpTab('wifi')" role="tab" aria-controls="help-wifi" aria-selected="false">WiFi</button>
<button type="button" class="help-tab" data-tab="tips" onclick="switchHelpTab('tips')" role="tab" aria-controls="help-tips" aria-selected="false">Tips</button>
</div>
<!-- Icons Section -->
<div id="help-icons" class="help-section active" role="tabpanel">
<h3>Stats Bar Icons</h3>
<div class="icon-grid">
<div class="icon-item"><span class="icon icon--sm"><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></span><span class="desc">POCSAG messages decoded</span></div>
@@ -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 &amp; 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 &amp; 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"><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"><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>
@@ -62,7 +62,7 @@
</div>
<!-- Modes Section -->
<div id="help-modes" class="help-section" role="tabpanel" hidden>
<div id="help-modes" class="help-section" role="tabpanel" hidden>
<h3>Pager Mode</h3>
<ul class="tip-list">
<li>Decodes POCSAG and FLEX pager signals using RTL-SDR</li>
@@ -114,7 +114,7 @@
<li>Interactive map shows station positions in real-time</li>
</ul>
<h3>Listening Post Mode</h3>
<h3>Spectrum Waterfall Mode</h3>
<ul class="tip-list">
<li>Wideband SDR scanner with spectrum visualization</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>Filter by type (number/diplomatic), country, and mode</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>
<h3>Meshtastic Mode</h3>
@@ -166,11 +166,27 @@
<li>View next pass predictions with elevation and duration</li>
</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>
<ul class="tip-list">
<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>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>
</ul>
@@ -254,7 +270,7 @@
</div>
<!-- WiFi Section -->
<div id="help-wifi" class="help-section" role="tabpanel" hidden>
<div id="help-wifi" class="help-section" role="tabpanel" hidden>
<h3>Monitor Mode</h3>
<ul class="tip-list">
<li><strong>Enable Monitor:</strong> Puts WiFi adapter in monitor mode for passive scanning</li>
@@ -302,7 +318,7 @@
</div>
<!-- Tips Section -->
<div id="help-tips" class="help-section" role="tabpanel" hidden>
<div id="help-tips" class="help-section" role="tabpanel" hidden>
<h3>General Tips</h3>
<ul class="tip-list">
<li><strong>Collapsible sections:</strong> Click any section header (&nabla;) to collapse/expand</li>
@@ -330,15 +346,17 @@
<li><strong>Aircraft (ACARS):</strong> Second RTL-SDR, acarsdec</li>
<li><strong>Vessels (AIS):</strong> RTL-SDR, AIS-catcher</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>Meshtastic:</strong> Meshtastic LoRa device, <code>pip install meshtastic</code></li>
<li><strong>WebSDR:</strong> Internet connection (remote receivers)</li>
<li><strong>SubGHz:</strong> RTL-SDR or compatible SDR hardware</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>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>Space Weather:</strong> Internet connection (public APIs)</li>
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>
@@ -360,62 +378,62 @@
<script>
// Help modal functions - defined here so all pages have them
(function() {
let lastHelpFocusEl = null;
// Only define if not already defined (index.html defines its own)
if (typeof window.showHelp === 'undefined') {
window.showHelp = function() {
const modal = document.getElementById('helpModal');
lastHelpFocusEl = document.activeElement;
modal.classList.add('active');
modal.setAttribute('aria-hidden', 'false');
const content = modal.querySelector('.help-content');
if (content) content.focus();
document.body.style.overflow = 'hidden';
};
}
if (typeof window.hideHelp === 'undefined') {
window.hideHelp = function() {
const modal = document.getElementById('helpModal');
modal.classList.remove('active');
modal.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
if (lastHelpFocusEl && typeof lastHelpFocusEl.focus === 'function') {
lastHelpFocusEl.focus();
}
};
}
if (typeof window.switchHelpTab === 'undefined') {
window.switchHelpTab = function(tab) {
document.querySelectorAll('.help-tab').forEach(t => {
const isActive = t.dataset.tab === tab;
t.classList.toggle('active', isActive);
t.setAttribute('aria-selected', isActive ? 'true' : 'false');
});
document.querySelectorAll('.help-section').forEach(s => {
const isActive = s.id === ('help-' + tab);
s.classList.toggle('active', isActive);
s.hidden = !isActive;
});
};
}
(function() {
let lastHelpFocusEl = null;
// Only define if not already defined (index.html defines its own)
if (typeof window.showHelp === 'undefined') {
window.showHelp = function() {
const modal = document.getElementById('helpModal');
lastHelpFocusEl = document.activeElement;
modal.classList.add('active');
modal.setAttribute('aria-hidden', 'false');
const content = modal.querySelector('.help-content');
if (content) content.focus();
document.body.style.overflow = 'hidden';
};
}
if (typeof window.hideHelp === 'undefined') {
window.hideHelp = function() {
const modal = document.getElementById('helpModal');
modal.classList.remove('active');
modal.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
if (lastHelpFocusEl && typeof lastHelpFocusEl.focus === 'function') {
lastHelpFocusEl.focus();
}
};
}
if (typeof window.switchHelpTab === 'undefined') {
window.switchHelpTab = function(tab) {
document.querySelectorAll('.help-tab').forEach(t => {
const isActive = t.dataset.tab === tab;
t.classList.toggle('active', isActive);
t.setAttribute('aria-selected', isActive ? 'true' : 'false');
});
document.querySelectorAll('.help-section').forEach(s => {
const isActive = s.id === ('help-' + tab);
s.classList.toggle('active', isActive);
s.hidden = !isActive;
});
};
}
// Keyboard shortcuts for help (only add once)
if (!window._helpKeyboardSetup) {
window._helpKeyboardSetup = true;
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const modal = document.getElementById('helpModal');
if (modal && modal.classList.contains('active')) hideHelp();
}
// Open help with F1 or ? key (when not typing in an input)
var helpModal = document.getElementById('helpModal');
if (helpModal && (e.key === 'F1' || (e.key === '?' && !e.target.matches('input, textarea, select'))) && !helpModal.classList.contains('active')) {
e.preventDefault();
showHelp();
if (!window._helpKeyboardSetup) {
window._helpKeyboardSetup = true;
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const modal = document.getElementById('helpModal');
if (modal && modal.classList.contains('active')) hideHelp();
}
// Open help with F1 or ? key (when not typing in an input)
var helpModal = document.getElementById('helpModal');
if (helpModal && (e.key === 'F1' || (e.key === '?' && !e.target.matches('input, textarea, select'))) && !helpModal.classList.contains('active')) {
e.preventDefault();
showHelp();
}
});
}

View File

@@ -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">&#9660;</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">&#9660;</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">&#9660;</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">&#9660;</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">&#9660;</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">&#9660;</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">&#9660;</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">&#9660;</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">&#9660;</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">&#9660;</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">&#9660;</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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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('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('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('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>
@@ -133,7 +133,6 @@
<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('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('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>
@@ -177,6 +176,15 @@
<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>
</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="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>
@@ -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('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('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>') }}
{# 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') }}
@@ -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>') }}
{# 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('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('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>
{# JavaScript stub for pages that don't have switchMode defined #}
<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)
if (typeof switchMode === 'undefined') {
window.switchMode = function(mode) {

View File

@@ -284,6 +284,93 @@
<!-- Alerts 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-title">Alert Feed <span id="alertsFeedCount" style="color: var(--text-dim); font-weight: 500;"></span></div>
<div id="alertsFeedList" class="settings-feed">
@@ -316,7 +403,6 @@
<option value="acars">ACARS</option>
<option value="vdl2">VDL2</option>
<option value="aprs">APRS</option>
<option value="dsc">DSC</option>
<option value="meshtastic">Meshtastic</option>
</select>
</div>
@@ -392,14 +478,12 @@
<option value="bluetooth">Bluetooth</option>
<option value="adsb">ADS-B</option>
<option value="ais">AIS</option>
<option value="dsc">DSC</option>
<option value="acars">ACARS</option>
<option value="aprs">APRS</option>
<option value="rtlamr">RTLAMR</option>
<option value="tscm">TSCM</option>
<option value="sstv">SSTV</option>
<option value="sstv_general">SSTV General</option>
<option value="listening_scanner">Listening Post</option>
<option value="waterfall">Waterfall</option>
</select>
</div>

View File

@@ -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)

47
tests/test_pager_scope.py Normal file
View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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'

25
tests/test_sstv_scope.py Normal file
View File

@@ -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 == []

View File

@@ -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

View File

@@ -97,7 +97,7 @@ class AgentClient:
except requests.RequestException as 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.
@@ -112,20 +112,21 @@ class AgentClient:
AgentHTTPError: On HTTP errors
AgentConnectionError: If agent is unreachable
"""
url = f"{self.base_url}{path}"
try:
response = requests.post(
url,
json=data or {},
headers=self._headers(),
timeout=self.timeout
)
response.raise_for_status()
return response.json() if response.content else {}
except requests.ConnectionError as e:
raise AgentConnectionError(f"Cannot connect to agent at {self.base_url}: {e}")
except requests.Timeout:
raise AgentConnectionError(f"Request to agent timed out after {self.timeout}s")
url = f"{self.base_url}{path}"
request_timeout = self.timeout if timeout is None else timeout
try:
response = requests.post(
url,
json=data or {},
headers=self._headers(),
timeout=request_timeout
)
response.raise_for_status()
return response.json() if response.content else {}
except requests.ConnectionError as e:
raise AgentConnectionError(f"Cannot connect to agent at {self.base_url}: {e}")
except requests.Timeout:
raise AgentConnectionError(f"Request to agent timed out after {request_timeout}s")
except requests.HTTPError as e:
# Try to extract error message from response body
error_msg = f"Agent returned error: {e.response.status_code}"
@@ -141,9 +142,9 @@ class AgentClient:
except requests.RequestException as e:
raise AgentHTTPError(f"Request failed: {e}")
def post(self, path: str, data: dict | None = None) -> dict:
"""Public POST method for arbitrary endpoints."""
return self._post(path, data)
def post(self, path: str, data: dict | None = None, timeout: float | None = None) -> dict:
"""Public POST method for arbitrary endpoints."""
return self._post(path, data, timeout=timeout)
# =========================================================================
# Capability & Status
@@ -214,7 +215,7 @@ class AgentClient:
"""
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.
@@ -224,7 +225,7 @@ class AgentClient:
Returns:
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:
"""

View File

@@ -55,6 +55,12 @@ def _load_meta() -> dict[str, Any] | None:
if os.path.exists(DB_META_FILE):
with open(DB_META_FILE, 'r') as 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:
logger.warning(f"Error loading aircraft db meta: {e}")
return None

View File

@@ -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

View File

@@ -7,68 +7,68 @@ distance estimation, and proximity alerts for search and rescue operations.
from __future__ import annotations
import logging
import queue
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
import logging
import queue
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from utils.bluetooth.models import BTDeviceAggregate
from utils.bluetooth.scanner import BluetoothScanner, get_bluetooth_scanner
from utils.gps import get_current_position
logger = logging.getLogger('intercept.bt_locate')
logger = logging.getLogger('intercept.bt_locate')
# Maximum trail points to retain
MAX_TRAIL_POINTS = 500
# EMA smoothing factor for RSSI
EMA_ALPHA = 0.3
# Polling/restart tuning for scanner resilience without high CPU churn.
POLL_INTERVAL_SECONDS = 1.5
SCAN_RESTART_BACKOFF_SECONDS = 8.0
NO_MATCH_LOG_EVERY_POLLS = 10
def _normalize_mac(address: str | None) -> str | None:
"""Normalize MAC string to colon-separated uppercase form when possible."""
if not address:
return None
text = str(address).strip().upper().replace('-', ':')
if not text:
return None
# Handle raw 12-hex form: AABBCCDDEEFF
raw = ''.join(ch for ch in text if ch in '0123456789ABCDEF')
if ':' not in text and len(raw) == 12:
text = ':'.join(raw[i:i + 2] for i in range(0, 12, 2))
parts = text.split(':')
if len(parts) == 6 and all(len(p) == 2 and all(c in '0123456789ABCDEF' for c in p) for p in parts):
return ':'.join(parts)
# Return cleaned original when not a strict MAC (caller may still use exact matching)
return text
def _address_looks_like_rpa(address: str | None) -> bool:
"""
Return True when an address looks like a Resolvable Private Address.
RPA check: most-significant two bits of the first octet are `01`.
"""
normalized = _normalize_mac(address)
if not normalized:
return False
try:
first_octet = int(normalized.split(':', 1)[0], 16)
except (ValueError, TypeError):
return False
return (first_octet >> 6) == 1
# Maximum trail points to retain
MAX_TRAIL_POINTS = 500
# EMA smoothing factor for RSSI
EMA_ALPHA = 0.3
# Polling/restart tuning for scanner resilience without high CPU churn.
POLL_INTERVAL_SECONDS = 1.5
SCAN_RESTART_BACKOFF_SECONDS = 8.0
NO_MATCH_LOG_EVERY_POLLS = 10
def _normalize_mac(address: str | None) -> str | None:
"""Normalize MAC string to colon-separated uppercase form when possible."""
if not address:
return None
text = str(address).strip().upper().replace('-', ':')
if not text:
return None
# Handle raw 12-hex form: AABBCCDDEEFF
raw = ''.join(ch for ch in text if ch in '0123456789ABCDEF')
if ':' not in text and len(raw) == 12:
text = ':'.join(raw[i:i + 2] for i in range(0, 12, 2))
parts = text.split(':')
if len(parts) == 6 and all(len(p) == 2 and all(c in '0123456789ABCDEF' for c in p) for p in parts):
return ':'.join(parts)
# Return cleaned original when not a strict MAC (caller may still use exact matching)
return text
def _address_looks_like_rpa(address: str | None) -> bool:
"""
Return True when an address looks like a Resolvable Private Address.
RPA check: most-significant two bits of the first octet are `01`.
"""
normalized = _normalize_mac(address)
if not normalized:
return False
try:
first_octet = int(normalized.split(':', 1)[0], 16)
except (ValueError, TypeError):
return False
return (first_octet >> 6) == 1
class Environment(Enum):
@@ -125,110 +125,110 @@ def resolve_rpa(irk: bytes, address: str) -> bool:
return computed_hash == expected_hash
@dataclass
class LocateTarget:
"""Target device specification for locate session."""
mac_address: str | None = None
name_pattern: str | None = None
irk_hex: str | None = None
device_id: str | None = None
device_key: str | None = None
fingerprint_id: str | None = None
# Hand-off metadata from Bluetooth mode
known_name: str | None = None
known_manufacturer: str | None = None
last_known_rssi: int | None = None
_cached_irk_hex: str | None = field(default=None, init=False, repr=False)
_cached_irk_bytes: bytes | None = field(default=None, init=False, repr=False)
def _get_irk_bytes(self) -> bytes | None:
"""Parse/cache target IRK bytes once for repeated match checks."""
if not self.irk_hex:
return None
if self._cached_irk_hex == self.irk_hex:
return self._cached_irk_bytes
self._cached_irk_hex = self.irk_hex
self._cached_irk_bytes = None
try:
parsed = bytes.fromhex(self.irk_hex)
except (ValueError, TypeError):
return None
if len(parsed) != 16:
return None
self._cached_irk_bytes = parsed
return parsed
def matches(self, device: BTDeviceAggregate, irk_bytes: bytes | None = None) -> bool:
"""Check if a device matches this target."""
# Match by stable device key (survives MAC randomization for many devices)
if self.device_key and getattr(device, 'device_key', None) == self.device_key:
return True
# Match by device_id (exact)
if self.device_id and device.device_id == self.device_id:
return True
# Match by device_id address portion (without :address_type suffix)
@dataclass
class LocateTarget:
"""Target device specification for locate session."""
mac_address: str | None = None
name_pattern: str | None = None
irk_hex: str | None = None
device_id: str | None = None
device_key: str | None = None
fingerprint_id: str | None = None
# Hand-off metadata from Bluetooth mode
known_name: str | None = None
known_manufacturer: str | None = None
last_known_rssi: int | None = None
_cached_irk_hex: str | None = field(default=None, init=False, repr=False)
_cached_irk_bytes: bytes | None = field(default=None, init=False, repr=False)
def _get_irk_bytes(self) -> bytes | None:
"""Parse/cache target IRK bytes once for repeated match checks."""
if not self.irk_hex:
return None
if self._cached_irk_hex == self.irk_hex:
return self._cached_irk_bytes
self._cached_irk_hex = self.irk_hex
self._cached_irk_bytes = None
try:
parsed = bytes.fromhex(self.irk_hex)
except (ValueError, TypeError):
return None
if len(parsed) != 16:
return None
self._cached_irk_bytes = parsed
return parsed
def matches(self, device: BTDeviceAggregate, irk_bytes: bytes | None = None) -> bool:
"""Check if a device matches this target."""
# Match by stable device key (survives MAC randomization for many devices)
if self.device_key and getattr(device, 'device_key', None) == self.device_key:
return True
# Match by device_id (exact)
if self.device_id and device.device_id == self.device_id:
return True
# Match by device_id address portion (without :address_type suffix)
if self.device_id and ':' in self.device_id:
target_addr_part = self.device_id.rsplit(':', 1)[0].upper()
dev_addr = (device.address or '').upper()
if target_addr_part and dev_addr == target_addr_part:
return True
# Match by MAC/address (case-insensitive, normalize separators)
if self.mac_address:
dev_addr = _normalize_mac(device.address)
target_addr = _normalize_mac(self.mac_address)
if dev_addr and target_addr and dev_addr == target_addr:
return True
# Match by payload fingerprint.
# For explicit hand-off sessions, allow exact fingerprint matches even if
# stability is still warming up.
if self.fingerprint_id:
dev_fp = getattr(device, 'payload_fingerprint_id', None)
dev_fp_stability = getattr(device, 'payload_fingerprint_stability', 0.0) or 0.0
if dev_fp and dev_fp == self.fingerprint_id:
if dev_fp_stability >= 0.35:
return True
if any([self.device_id, self.device_key, self.mac_address, self.known_name]):
return True
# Match by RPA resolution
if self.irk_hex and device.address and _address_looks_like_rpa(device.address):
irk = irk_bytes or self._get_irk_bytes()
if irk and resolve_rpa(irk, device.address):
return True
# Match by MAC/address (case-insensitive, normalize separators)
if self.mac_address:
dev_addr = _normalize_mac(device.address)
target_addr = _normalize_mac(self.mac_address)
if dev_addr and target_addr and dev_addr == target_addr:
return True
# Match by name pattern
if self.name_pattern and device.name and self.name_pattern.lower() in device.name.lower():
return True
# Match by known_name from handoff (exact or loose normalized match)
if self.known_name and device.name:
target_name = self.known_name.strip().lower()
device_name = device.name.strip().lower()
if target_name and (
target_name == device_name
or target_name in device_name
or device_name in target_name
):
return True
return False
def to_dict(self) -> dict:
return {
'mac_address': self.mac_address,
'name_pattern': self.name_pattern,
'irk_hex': self.irk_hex,
'device_id': self.device_id,
'device_key': self.device_key,
'fingerprint_id': self.fingerprint_id,
'known_name': self.known_name,
'known_manufacturer': self.known_manufacturer,
'last_known_rssi': self.last_known_rssi,
}
# Match by payload fingerprint.
# For explicit hand-off sessions, allow exact fingerprint matches even if
# stability is still warming up.
if self.fingerprint_id:
dev_fp = getattr(device, 'payload_fingerprint_id', None)
dev_fp_stability = getattr(device, 'payload_fingerprint_stability', 0.0) or 0.0
if dev_fp and dev_fp == self.fingerprint_id:
if dev_fp_stability >= 0.35:
return True
if any([self.device_id, self.device_key, self.mac_address, self.known_name]):
return True
# Match by RPA resolution
if self.irk_hex and device.address and _address_looks_like_rpa(device.address):
irk = irk_bytes or self._get_irk_bytes()
if irk and resolve_rpa(irk, device.address):
return True
# Match by name pattern
if self.name_pattern and device.name and self.name_pattern.lower() in device.name.lower():
return True
# Match by known_name from handoff (exact or loose normalized match)
if self.known_name and device.name:
target_name = self.known_name.strip().lower()
device_name = device.name.strip().lower()
if target_name and (
target_name == device_name
or target_name in device_name
or device_name in target_name
):
return True
return False
def to_dict(self) -> dict:
return {
'mac_address': self.mac_address,
'name_pattern': self.name_pattern,
'irk_hex': self.irk_hex,
'device_id': self.device_id,
'device_key': self.device_key,
'fingerprint_id': self.fingerprint_id,
'known_name': self.known_name,
'known_manufacturer': self.known_manufacturer,
'last_known_rssi': self.last_known_rssi,
}
class DistanceEstimator:
@@ -300,7 +300,7 @@ class LocateSession:
self.environment = environment
self.fallback_lat = fallback_lat
self.fallback_lon = fallback_lon
self._lock = threading.Lock()
self._lock = threading.Lock()
# Distance estimator
n = custom_exponent if environment == Environment.CUSTOM and custom_exponent else environment.value
@@ -324,9 +324,9 @@ class LocateSession:
# Debug counters
self.callback_call_count = 0
self.poll_count = 0
self._last_seen_device: str | None = None
self._last_scan_restart_attempt = 0.0
self._target_irk = target._get_irk_bytes()
self._last_seen_device: str | None = None
self._last_scan_restart_attempt = 0.0
self._target_irk = target._get_irk_bytes()
# Scanner reference
self._scanner: BluetoothScanner | None = None
@@ -335,34 +335,34 @@ class LocateSession:
# Track last RSSI per device to detect changes
self._last_cb_rssi: dict[str, int] = {} # Dedup for rapid callbacks only
def start(self) -> bool:
"""Start the locate session.
Subscribes to scanner callbacks AND runs a polling thread that
checks the aggregator directly (handles bleak scan timeout).
"""
self._scanner = get_bluetooth_scanner()
self._scanner.add_device_callback(self._on_device)
self._scanner_started_by_us = False
# Ensure BLE scanning is active
if not self._scanner.is_scanning:
logger.info("BT scanner not running, starting scan for locate session")
self._scanner_started_by_us = True
self._last_scan_restart_attempt = time.monotonic()
if not self._scanner.start_scan(mode='auto'):
# Surface startup failure to caller and avoid leaving stale callbacks.
status = self._scanner.get_status()
reason = status.error or "unknown error"
logger.warning(f"Failed to start BT scanner for locate session: {reason}")
self._scanner.remove_device_callback(self._on_device)
self._scanner = None
self._scanner_started_by_us = False
return False
self.active = True
self.started_at = datetime.now()
self._stop_event.clear()
def start(self) -> bool:
"""Start the locate session.
Subscribes to scanner callbacks AND runs a polling thread that
checks the aggregator directly (handles bleak scan timeout).
"""
self._scanner = get_bluetooth_scanner()
self._scanner.add_device_callback(self._on_device)
self._scanner_started_by_us = False
# Ensure BLE scanning is active
if not self._scanner.is_scanning:
logger.info("BT scanner not running, starting scan for locate session")
self._scanner_started_by_us = True
self._last_scan_restart_attempt = time.monotonic()
if not self._scanner.start_scan(mode='auto'):
# Surface startup failure to caller and avoid leaving stale callbacks.
status = self._scanner.get_status()
reason = status.error or "unknown error"
logger.warning(f"Failed to start BT scanner for locate session: {reason}")
self._scanner.remove_device_callback(self._on_device)
self._scanner = None
self._scanner_started_by_us = False
return False
self.active = True
self.started_at = datetime.now()
self._stop_event.clear()
# Start polling thread as reliable fallback
self._poll_thread = threading.Thread(
@@ -388,40 +388,40 @@ class LocateSession:
def _poll_loop(self) -> None:
"""Poll scanner aggregator for target device updates."""
while not self._stop_event.is_set():
self._stop_event.wait(timeout=POLL_INTERVAL_SECONDS)
if self._stop_event.is_set():
break
try:
self._check_aggregator()
except Exception as e:
logger.error(f"Locate poll error: {e}")
while not self._stop_event.is_set():
self._stop_event.wait(timeout=POLL_INTERVAL_SECONDS)
if self._stop_event.is_set():
break
try:
self._check_aggregator()
except Exception as e:
logger.error(f"Locate poll error: {e}")
def _check_aggregator(self) -> None:
"""Check the scanner's aggregator for the target device."""
if not self._scanner:
return
self.poll_count += 1
# Restart scan if it expired (bleak 10s timeout)
if not self._scanner.is_scanning:
now = time.monotonic()
if (now - self._last_scan_restart_attempt) >= SCAN_RESTART_BACKOFF_SECONDS:
self._last_scan_restart_attempt = now
logger.info("Scanner stopped, restarting for locate session")
self._scanner.start_scan(mode='auto')
# Check devices seen within a recent window. Using a short window
self.poll_count += 1
# Restart scan if it expired (bleak 10s timeout)
if not self._scanner.is_scanning:
now = time.monotonic()
if (now - self._last_scan_restart_attempt) >= SCAN_RESTART_BACKOFF_SECONDS:
self._last_scan_restart_attempt = now
logger.info("Scanner stopped, restarting for locate session")
self._scanner.start_scan(mode='auto')
# Check devices seen within a recent window. Using a short window
# (rather than the aggregator's full 120s) so that once a device
# goes silent its stale RSSI stops producing detections. The window
# must survive bleak's 10s scan cycle + restart gap (~3s).
devices = self._scanner.get_devices(max_age_seconds=15)
found_target = False
for device in devices:
if not self.target.matches(device, irk_bytes=self._target_irk):
continue
found_target = True
found_target = False
for device in devices:
if not self.target.matches(device, irk_bytes=self._target_irk):
continue
found_target = True
rssi = device.rssi_current
if rssi is None:
continue
@@ -429,14 +429,14 @@ class LocateSession:
break # One match per poll cycle is sufficient
# Log periodically for debugging
if (
self.poll_count <= 5
or self.poll_count % 20 == 0
or (not found_target and self.poll_count % NO_MATCH_LOG_EVERY_POLLS == 0)
):
logger.info(
f"Poll #{self.poll_count}: {len(devices)} devices, "
f"target_found={found_target}, "
if (
self.poll_count <= 5
or self.poll_count % 20 == 0
or (not found_target and self.poll_count % NO_MATCH_LOG_EVERY_POLLS == 0)
):
logger.info(
f"Poll #{self.poll_count}: {len(devices)} devices, "
f"target_found={found_target}, "
f"detections={self.detection_count}, "
f"scanning={self._scanner.is_scanning}"
)
@@ -449,8 +449,8 @@ class LocateSession:
self.callback_call_count += 1
self._last_seen_device = f"{device.device_id}|{device.name}"
if not self.target.matches(device, irk_bytes=self._target_irk):
return
if not self.target.matches(device, irk_bytes=self._target_irk):
return
rssi = device.rssi_current
if rssi is None:
@@ -478,9 +478,9 @@ class LocateSession:
band = DistanceEstimator.proximity_band(distance)
# Check RPA resolution
rpa_resolved = False
if self._target_irk and device.address and _address_looks_like_rpa(device.address):
rpa_resolved = resolve_rpa(self._target_irk, device.address)
rpa_resolved = False
if self._target_irk and device.address and _address_looks_like_rpa(device.address):
rpa_resolved = resolve_rpa(self._target_irk, device.address)
# GPS tag — prefer live GPS, fall back to user-set coordinates
gps_pos = get_current_position()
@@ -542,15 +542,15 @@ class LocateSession:
with self._lock:
return [p.to_dict() for p in self.trail if p.lat is not None]
def get_status(self, include_debug: bool = False) -> dict:
"""Get session status."""
gps_pos = get_current_position()
def get_status(self, include_debug: bool = False) -> dict:
"""Get session status."""
gps_pos = get_current_position()
# Collect scanner/aggregator data OUTSIDE self._lock to avoid ABBA
# deadlock: get_status would hold self._lock then wait on
# aggregator._lock, while _poll_loop holds aggregator._lock then
# waits on self._lock in _record_detection.
debug_devices = self._debug_device_sample() if include_debug else []
debug_devices = self._debug_device_sample() if include_debug else []
scanner_running = self._scanner.is_scanning if self._scanner else False
scanner_device_count = self._scanner.device_count if self._scanner else 0
callback_registered = (
@@ -586,8 +586,8 @@ class LocateSession:
'latest_rssi_ema': round(self.trail[-1].rssi_ema, 1) if self.trail else None,
'latest_distance': round(self.trail[-1].estimated_distance, 2) if self.trail else None,
'latest_band': self.trail[-1].proximity_band if self.trail else None,
'debug_devices': debug_devices,
}
'debug_devices': debug_devices,
}
def set_environment(self, environment: Environment, custom_exponent: float | None = None) -> None:
"""Update the environment and recalculate distance estimator."""
@@ -602,16 +602,16 @@ class LocateSession:
return []
try:
devices = self._scanner.get_devices(max_age_seconds=30)
return [
{
'id': d.device_id,
'addr': d.address,
'name': d.name,
'rssi': d.rssi_current,
'match': self.target.matches(d, irk_bytes=self._target_irk),
}
for d in devices[:8]
]
return [
{
'id': d.device_id,
'addr': d.address,
'name': d.name,
'rssi': d.rssi_current,
'match': self.target.matches(d, irk_bytes=self._target_irk),
}
for d in devices[:8]
]
except Exception:
return []
@@ -627,37 +627,55 @@ _session: LocateSession | None = None
_session_lock = threading.Lock()
def start_locate_session(
target: LocateTarget,
environment: Environment = Environment.OUTDOOR,
custom_exponent: float | None = None,
fallback_lat: float | None = None,
def start_locate_session(
target: LocateTarget,
environment: Environment = Environment.OUTDOOR,
custom_exponent: float | None = None,
fallback_lat: float | None = None,
fallback_lon: float | None = None,
) -> LocateSession:
"""Start a new locate session, stopping any existing one."""
global _session
with _session_lock:
if _session and _session.active:
_session.stop()
_session = LocateSession(
target, environment, custom_exponent, fallback_lat, fallback_lon
)
if not _session.start():
_session = None
raise RuntimeError("Bluetooth scanner failed to start")
return _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:
if _session and _session.active:
old_session = _session
_session = None
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:
"""Stop the active locate 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:
if _session:
_session.stop()
_session = None
session_to_stop = _session
_session = None
if session_to_stop:
session_to_stop.stop()
def get_locate_session() -> LocateSession | None:

View File

@@ -76,6 +76,10 @@ def safe_terminate(process: subprocess.Popen | None, timeout: float = 2.0) -> bo
return True
except subprocess.TimeoutExpired:
process.kill()
try:
process.wait(timeout=3)
except subprocess.TimeoutExpired:
pass
unregister_process(process)
return True
except Exception as e:

View File

@@ -112,18 +112,21 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
lib_paths = ['/usr/local/lib', '/opt/homebrew/lib']
current_ld = env.get('DYLD_LIBRARY_PATH', '')
env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + [current_ld] if current_ld else lib_paths)
result = subprocess.run(
['rtl_test', '-t'],
capture_output=True,
text=True,
timeout=5,
env=env
)
output = result.stderr + result.stdout
# Parse device info from rtl_test output
# Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001"
device_pattern = r'(\d+):\s+(.+?)(?:,\s*SN:\s*(\S+))?$'
result = subprocess.run(
['rtl_test', '-t'],
capture_output=True,
text=True,
encoding='utf-8',
errors='replace',
timeout=5,
env=env
)
output = result.stderr + result.stdout
# Parse device info from rtl_test output
# Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001"
# 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
@@ -131,14 +134,14 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
line = line.strip()
match = re.match(device_pattern, line)
if match:
devices.append(SDRDevice(
sdr_type=SDRType.RTL_SDR,
index=int(match.group(1)),
name=match.group(2).strip().rstrip(','),
serial=match.group(3) or 'N/A',
driver='rtlsdr',
capabilities=RTLSDRCommandBuilder.CAPABILITIES
))
devices.append(SDRDevice(
sdr_type=SDRType.RTL_SDR,
index=int(match.group(1)),
name=match.group(2).strip().rstrip(','),
serial=match.group(3),
driver='rtlsdr',
capabilities=RTLSDRCommandBuilder.CAPABILITIES
))
# Fallback: if we found devices but couldn't parse details
if not devices:

View File

@@ -122,6 +122,17 @@ class DecodeProgress:
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
# ---------------------------------------------------------------------------
@@ -423,6 +434,7 @@ class SSTVDecoder:
# Scope: compute RMS/peak from raw int16 samples every chunk
rms_val = int(np.sqrt(np.mean(raw_samples.astype(np.float64) ** 2)))
peak_val = int(np.max(np.abs(raw_samples)))
waveform = _encode_scope_waveform(raw_samples)
if image_decoder is not None:
# Currently decoding an image
@@ -451,7 +463,7 @@ class SSTVDecoder:
message=f'Decoding {current_mode_name}: {pct}%',
partial_image=partial_url,
))
self._emit_scope(rms_val, peak_val, 'decoding')
self._emit_scope(rms_val, peak_val, 'decoding', waveform)
if complete:
# Save image
@@ -529,7 +541,7 @@ class SSTVDecoder:
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:
logger.error(f"Error in decode thread: {e}")
@@ -762,11 +774,20 @@ class SSTVDecoder:
except Exception as 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."""
if self._callback:
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:
pass

View File

@@ -726,46 +726,76 @@ class UnifiedWiFiScanner:
return True
def stop_deep_scan(self) -> bool:
"""
Stop the deep scan.
Returns:
True if scan was stopped.
"""
with self._lock:
if not self._status.is_scanning:
return True
# Stop deauth detector first
self._stop_deauth_detector()
self._deep_scan_stop_event.set()
if self._deep_scan_process:
try:
self._deep_scan_process.terminate()
self._deep_scan_process.wait(timeout=5)
except Exception as e:
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._queue_event({
'type': 'scan_stopped',
'mode': SCAN_MODE_DEEP,
})
return True
def stop_deep_scan(self) -> bool:
"""
Stop the deep scan.
Returns:
True if scan was stopped.
"""
cleanup_process: Optional[subprocess.Popen] = None
cleanup_thread: Optional[threading.Thread] = None
cleanup_detector = None
with self._lock:
if not self._status.is_scanning:
return True
self._deep_scan_stop_event.set()
cleanup_process = self._deep_scan_process
cleanup_thread = self._deep_scan_thread
cleanup_detector = self._deauth_detector
self._deauth_detector = None
self._deep_scan_process = None
self._deep_scan_thread = None
self._status.is_scanning = False
self._status.error = None
self._queue_event({
'type': 'scan_stopped',
'mode': SCAN_MODE_DEEP,
})
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(
self,
@@ -799,14 +829,32 @@ class UnifiedWiFiScanner:
logger.info(f"Starting airodump-ng: {' '.join(cmd)}")
try:
self._deep_scan_process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
csv_file = f"{output_prefix}-01.csv"
process: Optional[subprocess.Popen] = None
try:
process = subprocess.Popen(
cmd,
stdout=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"
# Poll CSV file for updates
while not self._deep_scan_stop_event.is_set():
@@ -830,14 +878,16 @@ class UnifiedWiFiScanner:
except Exception as e:
logger.debug(f"Error parsing airodump CSV: {e}")
except Exception as e:
logger.exception(f"Deep scan error: {e}")
self._queue_event({
'type': 'scan_error',
'error': str(e),
})
finally:
self._deep_scan_process = None
except Exception as e:
logger.exception(f"Deep scan error: {e}")
self._queue_event({
'type': 'scan_error',
'error': str(e),
})
finally:
with self._lock:
if process is not None and self._deep_scan_process is process:
self._deep_scan_process = None
# =========================================================================
# Observation Processing