mirror of
https://github.com/smittix/intercept.git
synced 2026-05-30 05:49:28 -07:00
Merge upstream/main and resolve adsb_dashboard.html conflict
Take upstream's crosshair animation system and updated selectAircraft(icao, source) signature. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
122
CHANGELOG.md
122
CHANGELOG.md
@@ -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
|
||||
|
||||
13
Dockerfile
13
Dockerfile
@@ -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 \
|
||||
|
||||
12
README.md
12
README.md
@@ -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
332
app.py
@@ -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,
|
||||
)
|
||||
|
||||
96
config.py
96
config.py
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)**
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
120
routes/adsb.py
120
routes/adsb.py
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
352
routes/signalid.py
Normal 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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
1182
static/css/modes/waterfall.css
Normal file
1182
static/css/modes/waterfall.css
Normal file
File diff suppressed because it is too large
Load Diff
BIN
static/icons/apple-touch-icon.png
Normal file
BIN
static/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
static/icons/favicon-32.png
Normal file
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
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
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
20
static/icons/icon.svg
Normal 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 |
BIN
static/images/globe/earth-dark.jpg
Normal file
BIN
static/images/globe/earth-dark.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
74
static/js/core/cheat-sheets.js
Normal file
74
static/js/core/cheat-sheets.js
Normal 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 38–45 dB works well for most dongles', 'POCSAG 512/1200/2400 baud are common'] },
|
||||
sensor: { title: '433MHz Sensors', icon: '🌡️', hardware: 'RTL-SDR dongle', description: 'Decodes 433MHz IoT sensors via rtl_433.', whatToExpect: 'JSON events from weather stations, door sensors, car key fobs.', tips: ['Leave gain on AUTO', 'Walk around to discover hidden sensors', 'Protocol filter narrows false positives'] },
|
||||
wifi: { title: 'WiFi Scanner', icon: '📡', hardware: 'WiFi adapter (monitor mode)', description: 'Scans WiFi networks and clients via airodump-ng or nmcli.', whatToExpect: 'SSIDs, BSSIDs, channel, signal strength, encryption type.', tips: ['Run airmon-ng check kill before monitoring', 'Proximity radar shows signal strength', 'TSCM baseline detects rogue APs'] },
|
||||
bluetooth: { title: 'Bluetooth Scanner', icon: '🔵', hardware: 'Built-in or USB Bluetooth adapter', description: 'Scans BLE and classic Bluetooth devices. Identifies trackers.', whatToExpect: 'Device names, MACs, RSSI, manufacturer, tracker type.', tips: ['Proximity radar shows device distance', 'Known tracker DB has 47K+ fingerprints', 'Use BT Locate to physically find a tracker'] },
|
||||
bt_locate: { title: 'BT Locate (SAR)', icon: '🎯', hardware: 'Bluetooth adapter + optional GPS', description: 'SAR Bluetooth locator. Tracks RSSI over time to triangulate position.', whatToExpect: 'RSSI chart, proximity band (IMMEDIATE/NEAR/FAR), GPS trail.', tips: ['Handoff from Bluetooth mode to lock onto a device', 'Indoor n=3.0 gives better distance estimates', 'Follow the heat trail toward stronger signal'] },
|
||||
meshtastic: { title: 'Meshtastic', icon: '🕸️', hardware: 'Meshtastic LoRa node (USB)', description: 'Monitors Meshtastic LoRa mesh network messages and positions.', whatToExpect: 'Text messages, node map, telemetry.', tips: ['Default channel must match your mesh', 'Long-Fast has best range', 'GPS nodes appear on map automatically'] },
|
||||
adsb: { title: 'ADS-B Aircraft', icon: '✈️', hardware: 'RTL-SDR + 1090MHz antenna', description: 'Tracks aircraft via ADS-B Mode S transponders using dump1090.', whatToExpect: 'Flight numbers, positions, altitude, speed, squawk codes.', tips: ['1090MHz — use a dedicated antenna', 'Emergency squawks: 7500 hijack, 7600 radio fail, 7700 emergency', 'Full Dashboard shows map view'] },
|
||||
ais: { title: 'AIS Vessels', icon: '🚢', hardware: 'RTL-SDR + VHF antenna (162 MHz)', description: 'Tracks marine vessels via AIS using AIS-catcher.', whatToExpect: 'MMSI, vessel names, positions, speed, heading, cargo type.', tips: ['VHF antenna centered at 162MHz works best', 'DSC distress alerts appear in red', 'Coastline range ~40 nautical miles'] },
|
||||
aprs: { title: 'APRS', icon: '📻', hardware: 'RTL-SDR + VHF + direwolf', description: 'Decodes APRS amateur packet radio via direwolf TNC modem.', whatToExpect: 'Station positions, weather reports, messages, telemetry.', tips: ['Primary APRS frequency: 144.390 MHz (North America)', 'direwolf must be running', 'Positions appear on the map'] },
|
||||
satellite: { title: 'Satellite Tracker', icon: '🛰️', hardware: 'None (pass prediction only)', description: 'Predicts satellite pass times using TLE data from CelesTrak.', whatToExpect: 'Pass windows with AOS/LOS times, max elevation, bearing.', tips: ['Set observer location in Settings', 'Plan ISS SSTV using pass times', 'TLEs auto-update every 24 hours'] },
|
||||
sstv: { title: 'ISS SSTV', icon: '🖼️', hardware: 'RTL-SDR + 145MHz antenna', description: 'Receives ISS SSTV images via slowrx.', whatToExpect: 'Color images during ISS SSTV events (PD180 mode).', tips: ['ISS SSTV: 145.800 MHz', 'Check ARISS for active event dates', 'ISS must be overhead — check pass times'] },
|
||||
weathersat: { title: 'Weather Satellites', icon: '🌤️', hardware: 'RTL-SDR + 137MHz turnstile/QFH antenna', description: 'Decodes NOAA APT and Meteor LRPT weather imagery via SatDump.', whatToExpect: 'Infrared/visible cloud imagery.', tips: ['NOAA 15/18/19: 137.1–137.9 MHz APT', 'Meteor M2-3: 137.9 MHz LRPT', 'Use circular polarized antenna (QFH or turnstile)'] },
|
||||
sstv_general:{ title: 'HF SSTV', icon: '📷', hardware: 'RTL-SDR + HF upconverter', description: 'Receives HF SSTV transmissions.', whatToExpect: 'Amateur radio images on 14.230 MHz (USB mode).', tips: ['14.230 MHz USB is primary HF SSTV frequency', 'Scottie 1 and Martin 1 most common', 'Best during daylight hours'] },
|
||||
gps: { title: 'GPS Receiver', icon: '🗺️', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction', 'Verify a 3D fix before relying on altitude'] },
|
||||
spaceweather:{ title: 'Space Weather', icon: '☀️', hardware: 'None (NOAA/SpaceWeatherLive data)', description: 'Monitors solar activity and geomagnetic storm indices.', whatToExpect: 'Kp index, solar flux, X-ray flare alerts, CME tracking.', tips: ['High Kp (≥5) = geomagnetic storm', 'X-class flares cause HF radio blackouts', 'Check before HF or satellite operations'] },
|
||||
tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] },
|
||||
spystations: { title: 'Spy Stations', icon: '🕵️', hardware: 'RTL-SDR + HF antenna', description: 'Database of known number stations, military, and diplomatic HF signals.', whatToExpect: 'Scheduled broadcasts, frequency database, tune-to links.', tips: ['Numbers stations often broadcast on the hour', 'Use Spectrum Waterfall to tune directly', 'STANAG and HF mil signals are common'] },
|
||||
websdr: { title: 'WebSDR', icon: '🌐', hardware: 'None (uses remote SDR servers)', description: 'Access remote WebSDR receivers worldwide for HF shortwave listening.', whatToExpect: 'Live audio from global HF receivers, waterfall display.', tips: ['websdr.org lists available servers', 'Good for HF when local antenna is lacking', 'Use in-app player for seamless experience'] },
|
||||
subghz: { title: 'SubGHz Transceiver', icon: '📡', hardware: 'HackRF One', description: 'Transmit and receive sub-GHz RF signals for IoT and industrial protocols.', whatToExpect: 'Raw signal capture, replay, and protocol analysis.', tips: ['Only use on licensed frequencies', 'Capture mode records raw IQ for replay', 'Common: garage doors, keyfobs, 315/433/868/915 MHz'] },
|
||||
rtlamr: { title: 'Utility Meter Reader', icon: '⚡', hardware: 'RTL-SDR dongle', description: 'Reads AMI/AMR smart utility meter broadcasts via rtlamr.', whatToExpect: 'Meter IDs, consumption readings, interval data.', tips: ['Most meters broadcast on 915 MHz', 'MSG types 5, 7, 13, 21 most common', 'Consumption data is read-only public broadcast'] },
|
||||
waterfall: { title: 'Spectrum Waterfall', icon: '🌊', hardware: 'RTL-SDR or HackRF (WebSocket)', description: 'Full-screen real-time FFT spectrum waterfall display.', whatToExpect: 'Color-coded signal intensity scrolling over time.', tips: ['Turbo palette has best contrast for weak signals', 'Peak hold shows max power in red', 'Hover over waterfall to see frequency'] },
|
||||
};
|
||||
|
||||
function show(mode) {
|
||||
const data = CONTENT[mode];
|
||||
const modal = document.getElementById('cheatSheetModal');
|
||||
const content = document.getElementById('cheatSheetContent');
|
||||
if (!modal || !content) return;
|
||||
|
||||
if (!data) {
|
||||
content.innerHTML = `<p style="color:var(--text-dim); font-family:var(--font-mono);">No cheat sheet for: ${mode}</p>`;
|
||||
} else {
|
||||
content.innerHTML = `
|
||||
<div style="font-family:var(--font-mono, monospace);">
|
||||
<div style="font-size:24px; margin-bottom:4px;">${data.icon}</div>
|
||||
<h2 style="margin:0 0 8px; font-size:16px; color:var(--accent-cyan, #4aa3ff);">${data.title}</h2>
|
||||
<div style="font-size:11px; color:var(--text-dim); margin-bottom:12px; border-bottom:1px solid rgba(255,255,255,0.08); padding-bottom:8px;">
|
||||
Hardware: <span style="color:var(--text-secondary);">${data.hardware}</span>
|
||||
</div>
|
||||
<p style="font-size:12px; color:var(--text-secondary); margin:0 0 12px;">${data.description}</p>
|
||||
<div style="margin-bottom:12px;">
|
||||
<div style="font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.08em; color:var(--text-dim); margin-bottom:4px;">What to expect</div>
|
||||
<p style="font-size:12px; color:var(--text-secondary); margin:0;">${data.whatToExpect}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.08em; color:var(--text-dim); margin-bottom:6px;">Tips</div>
|
||||
<ul style="margin:0; padding-left:16px; display:flex; flex-direction:column; gap:4px;">
|
||||
${data.tips.map(t => `<li style="font-size:11px; color:var(--text-secondary);">${t}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function hide() {
|
||||
const modal = document.getElementById('cheatSheetModal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
function showForCurrentMode() {
|
||||
const mode = document.body.getAttribute('data-mode');
|
||||
if (mode) show(mode);
|
||||
}
|
||||
|
||||
return { show, hide, showForCurrentMode };
|
||||
})();
|
||||
|
||||
window.CheatSheets = CheatSheets;
|
||||
@@ -12,8 +12,8 @@ const CommandPalette = (function() {
|
||||
{ mode: 'pager', label: 'Pager' },
|
||||
{ mode: '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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
72
static/js/core/keyboard-shortcuts.js
Normal file
72
static/js/core/keyboard-shortcuts.js
Normal 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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
255
static/js/core/voice-alerts.js
Normal file
255
static/js/core/voice-alerts.js
Normal 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;
|
||||
@@ -1,549 +0,0 @@
|
||||
/**
|
||||
* Analytics Dashboard Module
|
||||
* Cross-mode summary, sparklines, alerts, correlations, target view, and replay.
|
||||
*/
|
||||
const Analytics = (function () {
|
||||
'use strict';
|
||||
|
||||
let refreshTimer = null;
|
||||
let replayTimer = null;
|
||||
let replaySessions = [];
|
||||
let replayEvents = [];
|
||||
let replayIndex = 0;
|
||||
|
||||
function init() {
|
||||
refresh();
|
||||
loadReplaySessions();
|
||||
if (!refreshTimer) {
|
||||
refreshTimer = setInterval(refresh, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
pauseReplay();
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
Promise.all([
|
||||
fetch('/analytics/summary').then(r => r.json()).catch(() => null),
|
||||
fetch('/analytics/activity').then(r => r.json()).catch(() => null),
|
||||
fetch('/analytics/insights').then(r => r.json()).catch(() => null),
|
||||
fetch('/analytics/patterns').then(r => r.json()).catch(() => null),
|
||||
fetch('/alerts/events?limit=20').then(r => r.json()).catch(() => null),
|
||||
fetch('/correlation').then(r => r.json()).catch(() => null),
|
||||
fetch('/analytics/geofences').then(r => r.json()).catch(() => null),
|
||||
]).then(([summary, activity, insights, patterns, alerts, correlations, geofences]) => {
|
||||
if (summary) renderSummary(summary);
|
||||
if (activity) renderSparklines(activity.sparklines || {});
|
||||
if (insights) renderInsights(insights);
|
||||
if (patterns) renderPatterns(patterns.patterns || []);
|
||||
if (alerts) renderAlerts(alerts.events || []);
|
||||
if (correlations) renderCorrelations(correlations);
|
||||
if (geofences) renderGeofences(geofences.zones || []);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSummary(data) {
|
||||
const counts = data.counts || {};
|
||||
_setText('analyticsCountAdsb', counts.adsb || 0);
|
||||
_setText('analyticsCountAis', counts.ais || 0);
|
||||
_setText('analyticsCountWifi', counts.wifi || 0);
|
||||
_setText('analyticsCountBt', counts.bluetooth || 0);
|
||||
_setText('analyticsCountDsc', counts.dsc || 0);
|
||||
_setText('analyticsCountAcars', counts.acars || 0);
|
||||
_setText('analyticsCountVdl2', counts.vdl2 || 0);
|
||||
_setText('analyticsCountAprs', counts.aprs || 0);
|
||||
_setText('analyticsCountMesh', counts.meshtastic || 0);
|
||||
|
||||
const health = data.health || {};
|
||||
const container = document.getElementById('analyticsHealth');
|
||||
if (container) {
|
||||
let html = '';
|
||||
const modeLabels = {
|
||||
pager: 'Pager', sensor: '433MHz', adsb: 'ADS-B', ais: 'AIS',
|
||||
acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', wifi: 'WiFi',
|
||||
bluetooth: 'BT', dsc: 'DSC', meshtastic: 'Mesh'
|
||||
};
|
||||
for (const [mode, info] of Object.entries(health)) {
|
||||
if (mode === 'sdr_devices') continue;
|
||||
const running = info && info.running;
|
||||
const label = modeLabels[mode] || mode;
|
||||
html += '<div class="health-item"><span class="health-dot' + (running ? ' running' : '') + '"></span>' + _esc(label) + '</div>';
|
||||
}
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
const squawks = data.squawks || [];
|
||||
const sqSection = document.getElementById('analyticsSquawkSection');
|
||||
const sqList = document.getElementById('analyticsSquawkList');
|
||||
if (sqSection && sqList) {
|
||||
if (squawks.length > 0) {
|
||||
sqSection.style.display = '';
|
||||
sqList.innerHTML = squawks.map(s =>
|
||||
'<div class="squawk-item"><strong>' + _esc(s.squawk) + '</strong> ' +
|
||||
_esc(s.meaning) + ' - ' + _esc(s.callsign || s.icao) + '</div>'
|
||||
).join('');
|
||||
} else {
|
||||
sqSection.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderSparklines(sparklines) {
|
||||
const map = {
|
||||
adsb: 'analyticsSparkAdsb',
|
||||
ais: 'analyticsSparkAis',
|
||||
wifi: 'analyticsSparkWifi',
|
||||
bluetooth: 'analyticsSparkBt',
|
||||
dsc: 'analyticsSparkDsc',
|
||||
acars: 'analyticsSparkAcars',
|
||||
vdl2: 'analyticsSparkVdl2',
|
||||
aprs: 'analyticsSparkAprs',
|
||||
meshtastic: 'analyticsSparkMesh',
|
||||
};
|
||||
|
||||
for (const [mode, elId] of Object.entries(map)) {
|
||||
const el = document.getElementById(elId);
|
||||
if (!el) continue;
|
||||
const data = sparklines[mode] || [];
|
||||
if (data.length < 2) {
|
||||
el.innerHTML = '';
|
||||
continue;
|
||||
}
|
||||
const max = Math.max(...data, 1);
|
||||
const w = 100;
|
||||
const h = 24;
|
||||
const step = w / (data.length - 1);
|
||||
const points = data.map((v, i) =>
|
||||
(i * step).toFixed(1) + ',' + (h - (v / max) * (h - 2)).toFixed(1)
|
||||
).join(' ');
|
||||
el.innerHTML = '<svg viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none"><polyline points="' + points + '"/></svg>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderInsights(data) {
|
||||
const cards = data.cards || [];
|
||||
const topChanges = data.top_changes || [];
|
||||
const cardsEl = document.getElementById('analyticsInsights');
|
||||
const changesEl = document.getElementById('analyticsTopChanges');
|
||||
|
||||
if (cardsEl) {
|
||||
if (!cards.length) {
|
||||
cardsEl.innerHTML = '<div class="analytics-empty">No insight data available</div>';
|
||||
} else {
|
||||
cardsEl.innerHTML = cards.map(c => {
|
||||
const sev = _esc(c.severity || 'low');
|
||||
const title = _esc(c.title || 'Insight');
|
||||
const value = _esc(c.value || '--');
|
||||
const label = _esc(c.label || '');
|
||||
const detail = _esc(c.detail || '');
|
||||
return '<div class="analytics-insight-card ' + sev + '">' +
|
||||
'<div class="insight-title">' + title + '</div>' +
|
||||
'<div class="insight-value">' + value + '</div>' +
|
||||
'<div class="insight-label">' + label + '</div>' +
|
||||
'<div class="insight-detail">' + detail + '</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
if (changesEl) {
|
||||
if (!topChanges.length) {
|
||||
changesEl.innerHTML = '<div class="analytics-empty">No change signals yet</div>';
|
||||
} else {
|
||||
changesEl.innerHTML = topChanges.map(item => {
|
||||
const mode = _esc(item.mode_label || item.mode || '');
|
||||
const deltaRaw = Number(item.delta || 0);
|
||||
const trendClass = deltaRaw > 0 ? 'up' : (deltaRaw < 0 ? 'down' : 'flat');
|
||||
const delta = _esc(item.signed_delta || String(deltaRaw));
|
||||
const recentAvg = _esc(item.recent_avg);
|
||||
const prevAvg = _esc(item.previous_avg);
|
||||
return '<div class="analytics-change-row">' +
|
||||
'<span class="mode">' + mode + '</span>' +
|
||||
'<span class="delta ' + trendClass + '">' + delta + '</span>' +
|
||||
'<span class="avg">avg ' + recentAvg + ' vs ' + prevAvg + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderPatterns(patterns) {
|
||||
const container = document.getElementById('analyticsPatternList');
|
||||
if (!container) return;
|
||||
if (!patterns || patterns.length === 0) {
|
||||
container.innerHTML = '<div class="analytics-empty">No recurring patterns detected</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const modeLabels = {
|
||||
adsb: 'ADS-B', ais: 'AIS', wifi: 'WiFi', bluetooth: 'Bluetooth',
|
||||
dsc: 'DSC', acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', meshtastic: 'Meshtastic',
|
||||
};
|
||||
|
||||
const sorted = patterns
|
||||
.slice()
|
||||
.sort((a, b) => (b.confidence || 0) - (a.confidence || 0))
|
||||
.slice(0, 20);
|
||||
|
||||
container.innerHTML = sorted.map(p => {
|
||||
const confidencePct = Math.round((Number(p.confidence || 0)) * 100);
|
||||
const mode = modeLabels[p.mode] || (p.mode || '--').toUpperCase();
|
||||
const period = _humanPeriod(Number(p.period_seconds || 0));
|
||||
const occurrences = Number(p.occurrences || 0);
|
||||
const deviceId = _shortId(p.device_id || '--');
|
||||
return '<div class="analytics-pattern-item">' +
|
||||
'<div class="pattern-main">' +
|
||||
'<span class="pattern-mode">' + _esc(mode) + '</span>' +
|
||||
'<span class="pattern-device">' + _esc(deviceId) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="pattern-meta">' +
|
||||
'<span>Period: ' + _esc(period) + '</span>' +
|
||||
'<span>Hits: ' + _esc(occurrences) + '</span>' +
|
||||
'<span class="pattern-confidence">' + _esc(confidencePct) + '%</span>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderAlerts(events) {
|
||||
const container = document.getElementById('analyticsAlertFeed');
|
||||
if (!container) return;
|
||||
if (!events || events.length === 0) {
|
||||
container.innerHTML = '<div class="analytics-empty">No recent alerts</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = events.slice(0, 20).map(e => {
|
||||
const sev = e.severity || 'medium';
|
||||
const title = e.title || e.event_type || 'Alert';
|
||||
const time = e.created_at ? new Date(e.created_at).toLocaleTimeString() : '';
|
||||
return '<div class="analytics-alert-item">' +
|
||||
'<span class="alert-severity ' + _esc(sev) + '">' + _esc(sev) + '</span>' +
|
||||
'<span>' + _esc(title) + '</span>' +
|
||||
'<span style="margin-left:auto;color:var(--text-dim)">' + _esc(time) + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderCorrelations(data) {
|
||||
const container = document.getElementById('analyticsCorrelations');
|
||||
if (!container) return;
|
||||
const pairs = (data && data.correlations) || [];
|
||||
if (pairs.length === 0) {
|
||||
container.innerHTML = '<div class="analytics-empty">No correlations detected</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = pairs.slice(0, 20).map(p => {
|
||||
const conf = Math.round((p.confidence || 0) * 100);
|
||||
return '<div class="analytics-correlation-pair">' +
|
||||
'<span>' + _esc(p.wifi_mac || '') + '</span>' +
|
||||
'<span style="color:var(--text-dim)">↔</span>' +
|
||||
'<span>' + _esc(p.bt_mac || '') + '</span>' +
|
||||
'<div class="confidence-bar"><div class="confidence-fill" style="width:' + conf + '%"></div></div>' +
|
||||
'<span style="color:var(--text-dim)">' + conf + '%</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderGeofences(zones) {
|
||||
const container = document.getElementById('analyticsGeofenceList');
|
||||
if (!container) return;
|
||||
if (!zones || zones.length === 0) {
|
||||
container.innerHTML = '<div class="analytics-empty">No geofence zones defined</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = zones.map(z =>
|
||||
'<div class="geofence-zone-item">' +
|
||||
'<span class="zone-name">' + _esc(z.name) + '</span>' +
|
||||
'<span class="zone-radius">' + z.radius_m + 'm</span>' +
|
||||
'<button class="zone-delete" onclick="Analytics.deleteGeofence(' + z.id + ')">DEL</button>' +
|
||||
'</div>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
function addGeofence() {
|
||||
const name = prompt('Zone name:');
|
||||
if (!name) return;
|
||||
const lat = parseFloat(prompt('Latitude:', '0'));
|
||||
const lon = parseFloat(prompt('Longitude:', '0'));
|
||||
const radius = parseFloat(prompt('Radius (meters):', '1000'));
|
||||
if (isNaN(lat) || isNaN(lon) || isNaN(radius)) {
|
||||
alert('Invalid input');
|
||||
return;
|
||||
}
|
||||
fetch('/analytics/geofences', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, lat, lon, radius_m: radius }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(() => refresh());
|
||||
}
|
||||
|
||||
function deleteGeofence(id) {
|
||||
if (!confirm('Delete this geofence zone?')) return;
|
||||
fetch('/analytics/geofences/' + id, { method: 'DELETE' })
|
||||
.then(r => r.json())
|
||||
.then(() => refresh());
|
||||
}
|
||||
|
||||
function exportData(mode) {
|
||||
const m = mode || (document.getElementById('exportMode') || {}).value || 'adsb';
|
||||
const f = (document.getElementById('exportFormat') || {}).value || 'json';
|
||||
window.open('/analytics/export/' + encodeURIComponent(m) + '?format=' + encodeURIComponent(f), '_blank');
|
||||
}
|
||||
|
||||
function searchTarget() {
|
||||
const input = document.getElementById('analyticsTargetQuery');
|
||||
const summaryEl = document.getElementById('analyticsTargetSummary');
|
||||
const q = (input && input.value || '').trim();
|
||||
if (!q) {
|
||||
if (summaryEl) summaryEl.textContent = 'Enter a search value to correlate entities';
|
||||
renderTargetResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/analytics/target?q=' + encodeURIComponent(q) + '&limit=120')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const results = data.results || [];
|
||||
if (summaryEl) {
|
||||
const modeCounts = data.mode_counts || {};
|
||||
const bits = Object.entries(modeCounts).map(([mode, count]) => `${mode}: ${count}`).join(' | ');
|
||||
summaryEl.textContent = `${results.length} results${bits ? ' | ' + bits : ''}`;
|
||||
}
|
||||
renderTargetResults(results);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (summaryEl) summaryEl.textContent = 'Search failed';
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Target View Search', err, { onRetry: searchTarget });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderTargetResults(results) {
|
||||
const container = document.getElementById('analyticsTargetResults');
|
||||
if (!container) return;
|
||||
|
||||
if (!results || !results.length) {
|
||||
container.innerHTML = '<div class="analytics-empty">No matching entities</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = results.map((item) => {
|
||||
const title = _esc(item.title || item.id || 'Entity');
|
||||
const subtitle = _esc(item.subtitle || '');
|
||||
const mode = _esc(item.mode || 'unknown');
|
||||
const confidence = item.confidence != null ? `Confidence ${_esc(Math.round(Number(item.confidence) * 100))}%` : '';
|
||||
const lastSeen = _esc(item.last_seen || '');
|
||||
return '<div class="analytics-target-item">' +
|
||||
'<div class="title"><span class="mode">' + mode + '</span><span>' + title + '</span></div>' +
|
||||
'<div class="meta"><span>' + subtitle + '</span>' +
|
||||
(lastSeen ? '<span>Last seen ' + lastSeen + '</span>' : '') +
|
||||
(confidence ? '<span>' + confidence + '</span>' : '') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function loadReplaySessions() {
|
||||
const select = document.getElementById('analyticsReplaySelect');
|
||||
if (!select) return;
|
||||
|
||||
fetch('/recordings?limit=60')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
replaySessions = (data.recordings || []).filter((rec) => Number(rec.event_count || 0) > 0);
|
||||
|
||||
if (!replaySessions.length) {
|
||||
select.innerHTML = '<option value="">No recordings</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
select.innerHTML = replaySessions.map((rec) => {
|
||||
const label = `${rec.mode} | ${(rec.label || 'session')} | ${new Date(rec.started_at).toLocaleString()}`;
|
||||
return `<option value="${_esc(rec.id)}">${_esc(label)}</option>`;
|
||||
}).join('');
|
||||
|
||||
const pendingReplay = localStorage.getItem('analyticsReplaySession');
|
||||
if (pendingReplay && replaySessions.some((rec) => rec.id === pendingReplay)) {
|
||||
select.value = pendingReplay;
|
||||
localStorage.removeItem('analyticsReplaySession');
|
||||
loadReplay();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Load Replay Sessions', err, { onRetry: loadReplaySessions });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadReplay() {
|
||||
pauseReplay();
|
||||
replayEvents = [];
|
||||
replayIndex = 0;
|
||||
|
||||
const select = document.getElementById('analyticsReplaySelect');
|
||||
const meta = document.getElementById('analyticsReplayMeta');
|
||||
const timeline = document.getElementById('analyticsReplayTimeline');
|
||||
if (!select || !meta || !timeline) return;
|
||||
|
||||
const id = select.value;
|
||||
if (!id) {
|
||||
meta.textContent = 'Select a recording';
|
||||
timeline.innerHTML = '<div class="analytics-empty">No recording selected</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
meta.textContent = 'Loading replay events...';
|
||||
|
||||
fetch('/recordings/' + encodeURIComponent(id) + '/events?limit=600')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
replayEvents = data.events || [];
|
||||
replayIndex = 0;
|
||||
if (!replayEvents.length) {
|
||||
meta.textContent = 'No events found in selected recording';
|
||||
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const rec = replaySessions.find((s) => s.id === id);
|
||||
const mode = rec ? rec.mode : (data.recording && data.recording.mode) || 'unknown';
|
||||
meta.textContent = `${replayEvents.length} events loaded | mode ${mode}`;
|
||||
renderReplayWindow();
|
||||
})
|
||||
.catch((err) => {
|
||||
meta.textContent = 'Replay load failed';
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Load Replay', err, { onRetry: loadReplay });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function playReplay() {
|
||||
if (!replayEvents.length) {
|
||||
loadReplay();
|
||||
return;
|
||||
}
|
||||
|
||||
if (replayTimer) return;
|
||||
|
||||
replayTimer = setInterval(() => {
|
||||
if (replayIndex >= replayEvents.length - 1) {
|
||||
pauseReplay();
|
||||
return;
|
||||
}
|
||||
replayIndex += 1;
|
||||
renderReplayWindow();
|
||||
}, 260);
|
||||
}
|
||||
|
||||
function pauseReplay() {
|
||||
if (replayTimer) {
|
||||
clearInterval(replayTimer);
|
||||
replayTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function stepReplay() {
|
||||
if (!replayEvents.length) {
|
||||
loadReplay();
|
||||
return;
|
||||
}
|
||||
|
||||
pauseReplay();
|
||||
replayIndex = Math.min(replayIndex + 1, replayEvents.length - 1);
|
||||
renderReplayWindow();
|
||||
}
|
||||
|
||||
function renderReplayWindow() {
|
||||
const timeline = document.getElementById('analyticsReplayTimeline');
|
||||
const meta = document.getElementById('analyticsReplayMeta');
|
||||
if (!timeline || !meta) return;
|
||||
|
||||
const total = replayEvents.length;
|
||||
if (!total) {
|
||||
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const start = Math.max(0, replayIndex - 15);
|
||||
const end = Math.min(total, replayIndex + 20);
|
||||
const windowed = replayEvents.slice(start, end);
|
||||
|
||||
timeline.innerHTML = windowed.map((row, i) => {
|
||||
const absolute = start + i;
|
||||
const active = absolute === replayIndex;
|
||||
const eventType = _esc(row.event_type || 'event');
|
||||
const mode = _esc(row.mode || '--');
|
||||
const ts = _esc(row.timestamp ? new Date(row.timestamp).toLocaleTimeString() : '--');
|
||||
const detail = summarizeReplayEvent(row.event || {});
|
||||
return '<div class="analytics-replay-item" style="opacity:' + (active ? '1' : '0.65') + ';">' +
|
||||
'<div class="title"><span class="mode">' + mode + '</span><span>' + eventType + '</span></div>' +
|
||||
'<div class="meta"><span>' + ts + '</span><span>' + _esc(detail) + '</span></div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
meta.textContent = `Event ${replayIndex + 1}/${total}`;
|
||||
}
|
||||
|
||||
function summarizeReplayEvent(event) {
|
||||
if (!event || typeof event !== 'object') return 'No details';
|
||||
if (event.callsign) return `Callsign ${event.callsign}`;
|
||||
if (event.icao) return `ICAO ${event.icao}`;
|
||||
if (event.ssid) return `SSID ${event.ssid}`;
|
||||
if (event.bssid) return `BSSID ${event.bssid}`;
|
||||
if (event.address) return `Address ${event.address}`;
|
||||
if (event.name) return `Name ${event.name}`;
|
||||
const keys = Object.keys(event);
|
||||
if (!keys.length) return 'No fields';
|
||||
return `${keys[0]}=${String(event[keys[0]]).slice(0, 40)}`;
|
||||
}
|
||||
|
||||
function _setText(id, val) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = val;
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
if (typeof s !== 'string') s = String(s == null ? '' : s);
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _shortId(value) {
|
||||
const text = String(value || '');
|
||||
if (text.length <= 18) return text;
|
||||
return text.slice(0, 8) + '...' + text.slice(-6);
|
||||
}
|
||||
|
||||
function _humanPeriod(seconds) {
|
||||
if (!isFinite(seconds) || seconds <= 0) return '--';
|
||||
if (seconds < 60) return Math.round(seconds) + 's';
|
||||
const mins = seconds / 60;
|
||||
if (mins < 60) return mins.toFixed(mins < 10 ? 1 : 0) + 'm';
|
||||
const hours = mins / 60;
|
||||
return hours.toFixed(hours < 10 ? 1 : 0) + 'h';
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
destroy,
|
||||
refresh,
|
||||
addGeofence,
|
||||
deleteGeofence,
|
||||
exportData,
|
||||
searchTarget,
|
||||
loadReplay,
|
||||
playReplay,
|
||||
pauseReplay,
|
||||
stepReplay,
|
||||
loadReplaySessions,
|
||||
};
|
||||
})();
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
3481
static/js/modes/waterfall.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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: '© OpenStreetMap contributors © 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: '© OpenStreetMap contributors © CARTO',
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 19,
|
||||
className: 'tile-layer-cyan',
|
||||
}).addTo(websdrMap);
|
||||
}
|
||||
|
||||
mapEl.style.background = '#1a1d29';
|
||||
return true;
|
||||
}
|
||||
|
||||
function setupWebsdrResizeHandling(mapEl) {
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
if (websdrResizeObserver) {
|
||||
websdrResizeObserver.disconnect();
|
||||
}
|
||||
websdrResizeObserver = new ResizeObserver(() => invalidateWebSDRViewport());
|
||||
websdrResizeObserver.observe(mapEl);
|
||||
}
|
||||
|
||||
if (!websdrResizeHooked) {
|
||||
window.addEventListener('resize', invalidateWebSDRViewport);
|
||||
window.addEventListener('orientationchange', () => setTimeout(invalidateWebSDRViewport, 120));
|
||||
websdrResizeHooked = true;
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateWebSDRViewport() {
|
||||
if (websdrMapType === 'globe') {
|
||||
resizeWebsdrGlobe();
|
||||
return;
|
||||
}
|
||||
if (websdrMap && typeof websdrMap.invalidateSize === 'function') {
|
||||
websdrMap.invalidateSize({ pan: false, animate: false });
|
||||
}
|
||||
}
|
||||
|
||||
function resizeWebsdrGlobe() {
|
||||
if (!websdrGlobe) return;
|
||||
const mapEl = document.getElementById('websdrMap');
|
||||
if (!mapEl) return;
|
||||
|
||||
const width = mapEl.clientWidth;
|
||||
const height = mapEl.clientHeight;
|
||||
if (!width || !height) return;
|
||||
|
||||
websdrGlobe.width(width);
|
||||
websdrGlobe.height(height);
|
||||
}
|
||||
|
||||
function plotReceiversOnGlobe(receivers) {
|
||||
if (!websdrGlobe) return;
|
||||
|
||||
const points = [];
|
||||
receivers.forEach((rx, idx) => {
|
||||
const lat = Number(rx.lat);
|
||||
const lon = Number(rx.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
|
||||
|
||||
const selected = idx === websdrSelectedReceiverIndex;
|
||||
points.push({
|
||||
lat: lat,
|
||||
lng: lon,
|
||||
receiverIndex: idx,
|
||||
radius: selected ? 0.52 : 0.38,
|
||||
altitude: selected ? 0.1 : 0.04,
|
||||
color: selected ? '#00ff88' : (rx.available ? '#00d4ff' : '#5f6976'),
|
||||
label: buildWebsdrPointLabel(rx, idx),
|
||||
});
|
||||
});
|
||||
|
||||
websdrGlobe.pointsData(points);
|
||||
|
||||
if (points.length > 0) {
|
||||
if (websdrSelectedReceiverIndex != null) {
|
||||
const selectedPoint = points.find(point => point.receiverIndex === websdrSelectedReceiverIndex);
|
||||
if (selectedPoint) {
|
||||
websdrGlobe.pointOfView({ lat: selectedPoint.lat, lng: selectedPoint.lng, altitude: 1.45 }, 900);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const center = computeWebsdrGlobeCenter(points);
|
||||
websdrGlobe.pointOfView(center, 900);
|
||||
}
|
||||
}
|
||||
|
||||
function computeWebsdrGlobeCenter(points) {
|
||||
if (!points.length) return { lat: 20, lng: 0, altitude: 2.1 };
|
||||
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let z = 0;
|
||||
points.forEach(point => {
|
||||
const latRad = point.lat * Math.PI / 180;
|
||||
const lonRad = point.lng * Math.PI / 180;
|
||||
x += Math.cos(latRad) * Math.cos(lonRad);
|
||||
y += Math.cos(latRad) * Math.sin(lonRad);
|
||||
z += Math.sin(latRad);
|
||||
});
|
||||
|
||||
const count = points.length;
|
||||
x /= count;
|
||||
y /= count;
|
||||
z /= count;
|
||||
|
||||
const hyp = Math.sqrt((x * x) + (y * y));
|
||||
const centerLat = Math.atan2(z, hyp) * 180 / Math.PI;
|
||||
const centerLng = Math.atan2(y, x) * 180 / Math.PI;
|
||||
|
||||
let meanAngularDistance = 0;
|
||||
const centerLatRad = centerLat * Math.PI / 180;
|
||||
const centerLngRad = centerLng * Math.PI / 180;
|
||||
points.forEach(point => {
|
||||
const latRad = point.lat * Math.PI / 180;
|
||||
const lonRad = point.lng * Math.PI / 180;
|
||||
const cosAngle = (
|
||||
(Math.sin(centerLatRad) * Math.sin(latRad)) +
|
||||
(Math.cos(centerLatRad) * Math.cos(latRad) * Math.cos(lonRad - centerLngRad))
|
||||
);
|
||||
const safeCos = Math.max(-1, Math.min(1, cosAngle));
|
||||
meanAngularDistance += Math.acos(safeCos) * 180 / Math.PI;
|
||||
});
|
||||
meanAngularDistance /= count;
|
||||
|
||||
const altitude = Math.min(2.9, Math.max(1.35, 1.35 + (meanAngularDistance / 45)));
|
||||
return { lat: centerLat, lng: centerLng, altitude: altitude };
|
||||
}
|
||||
|
||||
function ensureWebsdrGlobePopup(mapEl) {
|
||||
if (websdrGlobePopup) {
|
||||
if (websdrGlobePopup.parentElement !== mapEl) {
|
||||
mapEl.appendChild(websdrGlobePopup);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
websdrGlobePopup = document.createElement('div');
|
||||
websdrGlobePopup.id = 'websdrGlobePopup';
|
||||
websdrGlobePopup.style.position = 'absolute';
|
||||
websdrGlobePopup.style.minWidth = '220px';
|
||||
websdrGlobePopup.style.maxWidth = '260px';
|
||||
websdrGlobePopup.style.padding = '10px';
|
||||
websdrGlobePopup.style.borderRadius = '8px';
|
||||
websdrGlobePopup.style.border = '1px solid rgba(0, 212, 255, 0.35)';
|
||||
websdrGlobePopup.style.background = 'rgba(5, 13, 20, 0.92)';
|
||||
websdrGlobePopup.style.backdropFilter = 'blur(4px)';
|
||||
websdrGlobePopup.style.boxShadow = '0 8px 24px rgba(0, 0, 0, 0.4)';
|
||||
websdrGlobePopup.style.color = 'var(--text-primary)';
|
||||
websdrGlobePopup.style.display = 'none';
|
||||
websdrGlobePopup.style.zIndex = '20';
|
||||
mapEl.appendChild(websdrGlobePopup);
|
||||
|
||||
if (!mapEl.dataset.websdrPopupHooked) {
|
||||
mapEl.addEventListener('click', (event) => {
|
||||
if (!websdrGlobePopup || websdrGlobePopup.style.display === 'none') return;
|
||||
if (event.target.closest('#websdrGlobePopup')) return;
|
||||
hideWebsdrGlobePopup();
|
||||
});
|
||||
mapEl.dataset.websdrPopupHooked = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
function showWebsdrGlobePopup(point, event) {
|
||||
if (!websdrGlobePopup || !point || point.receiverIndex == null) return;
|
||||
const rx = websdrReceivers[point.receiverIndex];
|
||||
if (!rx) return;
|
||||
|
||||
const mapEl = document.getElementById('websdrMap');
|
||||
if (!mapEl) return;
|
||||
|
||||
websdrSelectedReceiverIndex = point.receiverIndex;
|
||||
renderReceiverList(websdrReceivers);
|
||||
plotReceiversOnGlobe(websdrReceivers);
|
||||
|
||||
websdrGlobePopup.innerHTML = `
|
||||
<div style="display: flex; justify-content: space-between; align-items: start; gap: 10px; margin-bottom: 6px;">
|
||||
<strong style="font-size: 12px; color: var(--accent-cyan);">${escapeHtmlWebsdr(rx.name)}</strong>
|
||||
<button type="button" data-websdr-popup-close style="border: none; background: transparent; color: var(--text-muted); cursor: pointer; font-size: 14px; line-height: 1;">×</button>
|
||||
</div>
|
||||
${rx.location ? `<div style="font-size: 10px; color: var(--text-secondary); margin-bottom: 3px;">${escapeHtmlWebsdr(rx.location)}</div>` : ''}
|
||||
<div style="font-size: 10px; color: var(--text-muted); margin-bottom: 2px;">Antenna: ${escapeHtmlWebsdr(rx.antenna || 'Unknown')}</div>
|
||||
<div style="font-size: 10px; color: var(--text-muted); margin-bottom: 10px;">Users: ${rx.users}/${rx.users_max}</div>
|
||||
<button type="button" data-websdr-listen style="width: 100%; padding: 5px 10px; background: #00d4ff; color: #041018; border: none; border-radius: 4px; cursor: pointer; font-weight: 700;">Listen</button>
|
||||
`;
|
||||
websdrGlobePopup.style.display = 'block';
|
||||
|
||||
const rect = mapEl.getBoundingClientRect();
|
||||
const x = event && Number.isFinite(event.clientX) ? (event.clientX - rect.left) : (rect.width / 2);
|
||||
const y = event && Number.isFinite(event.clientY) ? (event.clientY - rect.top) : (rect.height / 2);
|
||||
const popupWidth = 260;
|
||||
const popupHeight = 155;
|
||||
const left = Math.max(12, Math.min(rect.width - popupWidth - 12, x + 12));
|
||||
const top = Math.max(12, Math.min(rect.height - popupHeight - 12, y + 12));
|
||||
websdrGlobePopup.style.left = `${left}px`;
|
||||
websdrGlobePopup.style.top = `${top}px`;
|
||||
|
||||
const closeBtn = websdrGlobePopup.querySelector('[data-websdr-popup-close]');
|
||||
if (closeBtn) {
|
||||
closeBtn.onclick = () => hideWebsdrGlobePopup();
|
||||
}
|
||||
const listenBtn = websdrGlobePopup.querySelector('[data-websdr-listen]');
|
||||
if (listenBtn) {
|
||||
listenBtn.onclick = () => selectReceiver(point.receiverIndex);
|
||||
}
|
||||
|
||||
if (event && typeof event.stopPropagation === 'function') {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
function hideWebsdrGlobePopup() {
|
||||
if (websdrGlobePopup) {
|
||||
websdrGlobePopup.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function buildWebsdrPointLabel(rx, idx) {
|
||||
const location = rx.location ? escapeHtmlWebsdr(rx.location) : 'Unknown location';
|
||||
const antenna = escapeHtmlWebsdr(rx.antenna || 'Unknown antenna');
|
||||
return `
|
||||
<div style="padding: 4px 6px; font-size: 11px; background: rgba(4, 12, 19, 0.9); border: 1px solid rgba(0,212,255,0.28); border-radius: 4px;">
|
||||
<div style="color: #00d4ff; font-weight: 600;">${escapeHtmlWebsdr(rx.name)}</div>
|
||||
<div style="color: #a5b1c3;">${location}</div>
|
||||
<div style="color: #8f9fb3;">${antenna} · ${rx.users}/${rx.users_max}</div>
|
||||
<div style="color: #7a899b; margin-top: 2px;">Receiver #${idx + 1}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ============== RECEIVER LIST ==============
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -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
297
static/js/vendor/leaflet-heat.js
vendored
Normal 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
27
static/manifest.json
Normal 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
122
static/sw.js
Normal 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 }))
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
1616
templates/index.html
1616
templates/index.html
File diff suppressed because it is too large
Load Diff
@@ -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">×</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">×</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 & history</span></div>
|
||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg></span><span class="desc">Vessels - AIS & VHF DSC distress</span></div>
|
||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="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 (∇) 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
<!-- ANALYTICS MODE -->
|
||||
<div id="analyticsMode" class="mode-content">
|
||||
{# Analytics Dashboard Sidebar Panel #}
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Summary</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div class="analytics-grid" id="analyticsSummaryCards">
|
||||
<div class="analytics-card" data-mode="adsb">
|
||||
<div class="card-count" id="analyticsCountAdsb">0</div>
|
||||
<div class="card-label">Aircraft</div>
|
||||
<div class="card-sparkline" id="analyticsSparkAdsb"></div>
|
||||
</div>
|
||||
<div class="analytics-card" data-mode="ais">
|
||||
<div class="card-count" id="analyticsCountAis">0</div>
|
||||
<div class="card-label">Vessels</div>
|
||||
<div class="card-sparkline" id="analyticsSparkAis"></div>
|
||||
</div>
|
||||
<div class="analytics-card" data-mode="wifi">
|
||||
<div class="card-count" id="analyticsCountWifi">0</div>
|
||||
<div class="card-label">WiFi</div>
|
||||
<div class="card-sparkline" id="analyticsSparkWifi"></div>
|
||||
</div>
|
||||
<div class="analytics-card" data-mode="bluetooth">
|
||||
<div class="card-count" id="analyticsCountBt">0</div>
|
||||
<div class="card-label">Bluetooth</div>
|
||||
<div class="card-sparkline" id="analyticsSparkBt"></div>
|
||||
</div>
|
||||
<div class="analytics-card" data-mode="dsc">
|
||||
<div class="card-count" id="analyticsCountDsc">0</div>
|
||||
<div class="card-label">DSC</div>
|
||||
<div class="card-sparkline" id="analyticsSparkDsc"></div>
|
||||
</div>
|
||||
<div class="analytics-card" data-mode="acars">
|
||||
<div class="card-count" id="analyticsCountAcars">0</div>
|
||||
<div class="card-label">ACARS</div>
|
||||
<div class="card-sparkline" id="analyticsSparkAcars"></div>
|
||||
</div>
|
||||
<div class="analytics-card" data-mode="vdl2">
|
||||
<div class="card-count" id="analyticsCountVdl2">0</div>
|
||||
<div class="card-label">VDL2</div>
|
||||
<div class="card-sparkline" id="analyticsSparkVdl2"></div>
|
||||
</div>
|
||||
<div class="analytics-card" data-mode="aprs">
|
||||
<div class="card-count" id="analyticsCountAprs">0</div>
|
||||
<div class="card-label">APRS</div>
|
||||
<div class="card-sparkline" id="analyticsSparkAprs"></div>
|
||||
</div>
|
||||
<div class="analytics-card" data-mode="meshtastic">
|
||||
<div class="card-count" id="analyticsCountMesh">0</div>
|
||||
<div class="card-label">Mesh</div>
|
||||
<div class="card-sparkline" id="analyticsSparkMesh"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Operational Insights</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div class="analytics-insight-grid" id="analyticsInsights">
|
||||
<div class="analytics-empty">Insights loading...</div>
|
||||
</div>
|
||||
<div class="analytics-top-changes">
|
||||
<div class="analytics-section-header">Top Changes</div>
|
||||
<div id="analyticsTopChanges">
|
||||
<div class="analytics-empty">No change signals yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Mode Health</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div class="analytics-health" id="analyticsHealth"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" id="analyticsSquawkSection" style="display:none;">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Emergency Squawks</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div class="squawk-emergency" id="analyticsSquawkPanel">
|
||||
<div class="squawk-title">Active Emergency Codes</div>
|
||||
<div id="analyticsSquawkList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Temporal Patterns</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div id="analyticsPatternList">
|
||||
<div class="analytics-empty">No recurring patterns detected</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Recent Alerts</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div class="analytics-alert-feed" id="analyticsAlertFeed">
|
||||
<div class="analytics-empty">No recent alerts</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Correlations</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div id="analyticsCorrelations">
|
||||
<div class="analytics-empty">No correlations detected</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Geofences</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div id="analyticsGeofenceList"></div>
|
||||
<button class="btn btn-sm" onclick="Analytics.addGeofence()" style="margin-top:8px; font-size:10px; padding:4px 10px; background:var(--accent-cyan); color:#fff; border:none; border-radius:4px; cursor:pointer;">
|
||||
+ Add Zone
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Target View</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div class="analytics-target-toolbar">
|
||||
<input id="analyticsTargetQuery" type="text" placeholder="Search callsign, ICAO, MMSI, MAC, SSID, node..." onkeydown="if(event.key==='Enter'){Analytics.searchTarget();}">
|
||||
<button onclick="Analytics.searchTarget()">Search</button>
|
||||
</div>
|
||||
<div id="analyticsTargetSummary" class="analytics-target-summary">Search to correlate entities across modes</div>
|
||||
<div id="analyticsTargetResults">
|
||||
<div class="analytics-empty">No target selected</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Session Replay</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div class="analytics-replay-toolbar">
|
||||
<select id="analyticsReplaySelect"></select>
|
||||
<button onclick="Analytics.loadReplay()">Load</button>
|
||||
<button onclick="Analytics.playReplay()">Play</button>
|
||||
<button onclick="Analytics.pauseReplay()">Pause</button>
|
||||
<button onclick="Analytics.stepReplay()">Step</button>
|
||||
</div>
|
||||
<div id="analyticsReplayMeta" class="analytics-target-summary">No replay loaded</div>
|
||||
<div id="analyticsReplayTimeline">
|
||||
<div class="analytics-empty">Select a recording to replay key events</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Export Data</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div class="export-controls">
|
||||
<select id="exportMode">
|
||||
<option value="adsb">ADS-B</option>
|
||||
<option value="ais">AIS</option>
|
||||
<option value="wifi">WiFi</option>
|
||||
<option value="bluetooth">Bluetooth</option>
|
||||
<option value="dsc">DSC</option>
|
||||
</select>
|
||||
<select id="exportFormat">
|
||||
<option value="json">JSON</option>
|
||||
<option value="csv">CSV</option>
|
||||
</select>
|
||||
<button onclick="Analytics.exportData()">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1,68 +0,0 @@
|
||||
<!-- LISTENING POST MODE -->
|
||||
<div id="listeningPostMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>Status</h3>
|
||||
|
||||
<!-- Dependency Warning -->
|
||||
<div id="scannerToolsWarning" style="display: none; background: rgba(255, 100, 100, 0.1); border: 1px solid var(--accent-red); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<p style="color: var(--accent-red); margin: 0; font-size: 0.85em;">
|
||||
<strong>Missing:</strong><br>
|
||||
<span id="scannerToolsWarningText"></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Status -->
|
||||
<div style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 12px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Status</span>
|
||||
<span id="lpQuickStatus" style="font-size: 11px; color: var(--accent-cyan);">IDLE</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Frequency</span>
|
||||
<span id="lpQuickFreq" style="font-size: 14px; font-family: var(--font-mono); color: var(--text-primary);">---.--- MHz</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Signals</span>
|
||||
<span id="lpQuickSignals" style="font-size: 14px; font-weight: bold; color: var(--accent-green);">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Bookmarks</h3>
|
||||
<div style="display: flex; gap: 4px; margin-bottom: 8px;">
|
||||
<input type="text" id="bookmarkFreqInput" placeholder="Freq (MHz)" style="flex: 1; padding: 6px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
||||
<button class="preset-btn" onclick="addFrequencyBookmark()" style="background: var(--accent-green); color: #000; padding: 6px 10px;">+</button>
|
||||
</div>
|
||||
<div id="bookmarksList" style="max-height: 150px; overflow-y: auto; font-size: 11px;">
|
||||
<div style="color: var(--text-muted); text-align: center; padding: 10px;">No bookmarks saved</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Recent Signals</h3>
|
||||
<div id="sidebarRecentSignals" style="max-height: 150px; overflow-y: auto; font-size: 11px;">
|
||||
<div style="color: var(--text-muted); text-align: center; padding: 10px;">No signals yet</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Signal Identification -->
|
||||
<div class="section">
|
||||
<h3>Signal Identification</h3>
|
||||
<div style="display: flex; gap: 4px; margin-bottom: 8px;">
|
||||
<input type="text" id="signalGuessFreqInput" placeholder="Freq (MHz)" style="flex: 1; padding: 6px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
||||
<button class="preset-btn" onclick="manualSignalGuess()" style="background: var(--accent-cyan); color: #000; padding: 6px 10px; font-weight: 600;">ID</button>
|
||||
</div>
|
||||
<div id="signalGuessPanel" style="display: none; background: rgba(0,0,0,0.3); border-radius: 6px; padding: 10px; font-size: 11px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<span id="signalGuessLabel" style="font-weight: bold; color: var(--text-primary);"></span>
|
||||
<span id="signalGuessBadge" style="padding: 2px 8px; border-radius: 3px; font-size: 9px; font-weight: bold;"></span>
|
||||
</div>
|
||||
<div id="signalGuessExplanation" style="color: var(--text-muted); font-size: 10px; margin-bottom: 6px;"></div>
|
||||
<div id="signalGuessTags" style="display: flex; flex-wrap: wrap; gap: 3px;"></div>
|
||||
<div id="signalGuessAlternatives" style="margin-top: 6px; font-size: 10px; color: var(--text-muted);"></div>
|
||||
<div id="signalGuessSendTo" style="margin-top: 8px; display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
327
templates/partials/modes/waterfall.html
Normal file
327
templates/partials/modes/waterfall.html
Normal 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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
47
tests/test_pager_scope.py
Normal 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
|
||||
46
tests/test_sdr_detection.py
Normal file
46
tests/test_sdr_detection.py
Normal 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"
|
||||
21
tests/test_sensor_scope.py
Normal file
21
tests/test_sensor_scope.py
Normal 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
|
||||
99
tests/test_signalid_sigidwiki_api.py
Normal file
99
tests/test_signalid_sigidwiki_api.py
Normal 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
25
tests/test_sstv_scope.py
Normal 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 == []
|
||||
54
tests/test_waterfall_websocket.py
Normal file
54
tests/test_waterfall_websocket.py
Normal 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
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user