mirror of
https://github.com/smittix/intercept.git
synced 2026-07-12 19:48:14 -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:
+91
-31
@@ -1,36 +1,96 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to iNTERCEPT will be documented in this file.
|
All notable changes to iNTERCEPT will be documented in this file.
|
||||||
|
|
||||||
## [2.21.1] - 2026-02-20
|
## [2.22.3] - 2026-02-23
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- BT Locate map first-load rendering race that could cause blank/late map initialization
|
- 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
|
||||||
- BT Locate mode switch timing so Leaflet invalidation runs after panel visibility settles
|
- 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
|
||||||
- BT Locate trail restore startup latency by batching historical GPS point rendering
|
- 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.21.0] - 2026-02-20
|
---
|
||||||
|
|
||||||
### Added
|
## [2.22.1] - 2026-02-23
|
||||||
- Analytics panels for operational insights and temporal pattern analysis
|
|
||||||
|
### Fixed
|
||||||
### Changed
|
- PWA install prompt not appearing — manifest now includes required PNG icons (192×192, 512×512)
|
||||||
- Global map theme refresh with improved contrast and cross-dashboard consistency
|
- Apple touch icon updated to PNG for iOS Safari compatibility
|
||||||
- Cross-app UX refinements for accessibility, mode consistency, and render performance
|
- Service worker cache bumped to bust stale cached assets
|
||||||
- BT Locate enhancements including improved continuity, smoothing, and confidence reporting
|
|
||||||
|
---
|
||||||
### Fixed
|
|
||||||
- Weather satellite auto-scheduler and Mercator tracking reliability issues
|
## [2.22.0] - 2026-02-23
|
||||||
- Bluetooth/WiFi runtime health issues affecting scanner continuity
|
|
||||||
- ADS-B SSE multi-client fanout stability and remote VDL2 streaming reliability
|
### 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
|
||||||
## [2.15.0] - 2026-02-09
|
- **SignalID** - Automatic signal classification via SigIDWiki API integration
|
||||||
|
- **PWA Support** - Installable web app with service worker caching and manifest
|
||||||
### Added
|
- **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
|
- **Real-time WebSocket Waterfall** - I/Q capture with server-side FFT
|
||||||
- Click-to-tune, zoom controls, and auto-scaling quantization
|
- Click-to-tune, zoom controls, and auto-scaling quantization
|
||||||
- Shared waterfall UI across SDR modes with function bar controls
|
- Shared waterfall UI across SDR modes with function bar controls
|
||||||
|
|||||||
+12
-1
@@ -57,7 +57,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
soapysdr-module-airspy \
|
soapysdr-module-airspy \
|
||||||
airspy \
|
airspy \
|
||||||
limesuite \
|
limesuite \
|
||||||
hackrf \
|
|
||||||
# Utilities
|
# Utilities
|
||||||
curl \
|
curl \
|
||||||
procps \
|
procps \
|
||||||
@@ -190,6 +189,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
fi \
|
fi \
|
||||||
&& cd /tmp \
|
&& cd /tmp \
|
||||||
&& rm -rf /tmp/SatDump \
|
&& rm -rf /tmp/SatDump \
|
||||||
|
# Build hackrf CLI tools from source — avoids libhackrf0 version conflict
|
||||||
|
# between the 'hackrf' apt package and soapysdr-module-hackrf's newer libhackrf0
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/greatscottgadgets/hackrf.git \
|
||||||
|
&& cd hackrf/host \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make \
|
||||||
|
&& make install \
|
||||||
|
&& ldconfig \
|
||||||
|
&& rm -rf /tmp/hackrf \
|
||||||
# Build rtlamr (utility meter decoder - requires Go)
|
# Build rtlamr (utility meter decoder - requires Go)
|
||||||
&& cd /tmp \
|
&& cd /tmp \
|
||||||
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
|
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
|
||||||
@@ -240,6 +250,7 @@ RUN mkdir -p /app/data /app/data/weather_sat
|
|||||||
|
|
||||||
# Expose web interface port
|
# Expose web interface port
|
||||||
EXPOSE 5050
|
EXPOSE 5050
|
||||||
|
EXPOSE 5443
|
||||||
|
|
||||||
# Environment variables with defaults
|
# Environment variables with defaults
|
||||||
ENV INTERCEPT_HOST=0.0.0.0 \
|
ENV INTERCEPT_HOST=0.0.0.0 \
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="Python 3.9+">
|
<img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="Python 3.9+">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green.svg" alt="MIT License">
|
<img src="https://img.shields.io/badge/license-Apache--2.0-green.svg" alt="Apache 2.0 License">
|
||||||
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg" alt="Platform">
|
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg" alt="Platform">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Support the developer of this open-source project
|
|||||||
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF)
|
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF)
|
||||||
- **APRS** - Amateur packet radio position reports and telemetry via direwolf
|
- **APRS** - Amateur packet radio position reports and telemetry via direwolf
|
||||||
- **Satellite Tracking** - Pass prediction with polar plot and ground track map
|
- **Satellite Tracking** - Pass prediction with polar plot and ground track map
|
||||||
- **Utility Meters** - Electric, gas, and water meter reading via rtl_amr
|
- **Utility Meters** - Electric, gas, and water meter reading via rtlamr
|
||||||
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
||||||
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
||||||
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
||||||
@@ -57,8 +57,6 @@ Support the developer of this open-source project
|
|||||||
|
|
||||||
## Installation / Debian / Ubuntu / MacOS
|
## Installation / Debian / Ubuntu / MacOS
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
**1. Clone and run:**
|
**1. Clone and run:**
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/smittix/intercept.git
|
git clone https://github.com/smittix/intercept.git
|
||||||
@@ -150,7 +148,7 @@ Set these as environment variables for either local installs or Docker:
|
|||||||
```bash
|
```bash
|
||||||
INTERCEPT_ADSB_AUTO_START=true \
|
INTERCEPT_ADSB_AUTO_START=true \
|
||||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||||
python app.py
|
sudo -E venv/bin/python intercept.py
|
||||||
```
|
```
|
||||||
|
|
||||||
**Docker example (.env)**
|
**Docker example (.env)**
|
||||||
@@ -172,7 +170,7 @@ Then open **/adsb/history** for the reporting dashboard.
|
|||||||
|
|
||||||
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
|
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
|
||||||
|
|
||||||
The credentials can be change in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py
|
The credentials can be changed in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -245,7 +243,7 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
|||||||
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
|
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
|
||||||
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
||||||
[direwolf](https://github.com/wb2osz/direwolf) |
|
[direwolf](https://github.com/wb2osz/direwolf) |
|
||||||
[rtl_amr](https://github.com/bemasher/rtlamr) |
|
[rtlamr](https://github.com/bemasher/rtlamr) |
|
||||||
[dumpvdl2](https://github.com/szpajder/dumpvdl2) |
|
[dumpvdl2](https://github.com/szpajder/dumpvdl2) |
|
||||||
[aircrack-ng](https://www.aircrack-ng.org/) |
|
[aircrack-ng](https://www.aircrack-ng.org/) |
|
||||||
[Leaflet.js](https://leafletjs.com/) |
|
[Leaflet.js](https://leafletjs.com/) |
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import subprocess
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session
|
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session, send_from_directory
|
||||||
from werkzeug.security import check_password_hash
|
from werkzeug.security import check_password_hash
|
||||||
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
||||||
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
||||||
@@ -96,32 +96,32 @@ def add_security_headers(response):
|
|||||||
# CONTEXT PROCESSORS
|
# CONTEXT PROCESSORS
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def inject_offline_settings():
|
def inject_offline_settings():
|
||||||
"""Inject offline settings into all templates."""
|
"""Inject offline settings into all templates."""
|
||||||
from utils.database import get_setting
|
from utils.database import get_setting
|
||||||
|
|
||||||
# Privacy-first defaults: keep dashboard assets/fonts local to avoid
|
# Privacy-first defaults: keep dashboard assets/fonts local to avoid
|
||||||
# third-party tracker/storage defenses in strict browsers.
|
# third-party tracker/storage defenses in strict browsers.
|
||||||
assets_source = str(get_setting('offline.assets_source', 'local') or 'local').lower()
|
assets_source = str(get_setting('offline.assets_source', 'local') or 'local').lower()
|
||||||
fonts_source = str(get_setting('offline.fonts_source', 'local') or 'local').lower()
|
fonts_source = str(get_setting('offline.fonts_source', 'local') or 'local').lower()
|
||||||
if assets_source not in ('local', 'cdn'):
|
if assets_source not in ('local', 'cdn'):
|
||||||
assets_source = 'local'
|
assets_source = 'local'
|
||||||
if fonts_source not in ('local', 'cdn'):
|
if fonts_source not in ('local', 'cdn'):
|
||||||
fonts_source = 'local'
|
fonts_source = 'local'
|
||||||
# Force local delivery for core dashboard pages.
|
# Force local delivery for core dashboard pages.
|
||||||
assets_source = 'local'
|
assets_source = 'local'
|
||||||
fonts_source = 'local'
|
fonts_source = 'local'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'offline_settings': {
|
'offline_settings': {
|
||||||
'enabled': get_setting('offline.enabled', False),
|
'enabled': get_setting('offline.enabled', False),
|
||||||
'assets_source': assets_source,
|
'assets_source': assets_source,
|
||||||
'fonts_source': fonts_source,
|
'fonts_source': fonts_source,
|
||||||
'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'),
|
'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'),
|
||||||
'tile_server_url': get_setting('offline.tile_server_url', '')
|
'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_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
dsc_lock = threading.Lock()
|
dsc_lock = threading.Lock()
|
||||||
|
|
||||||
# TSCM (Technical Surveillance Countermeasures)
|
# TSCM (Technical Surveillance Countermeasures)
|
||||||
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
tscm_lock = threading.Lock()
|
tscm_lock = threading.Lock()
|
||||||
|
|
||||||
# SubGHz Transceiver (HackRF)
|
# SubGHz Transceiver (HackRF)
|
||||||
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
@@ -396,6 +396,18 @@ def favicon() -> Response:
|
|||||||
return send_file('favicon.svg', mimetype='image/svg+xml')
|
return send_file('favicon.svg', mimetype='image/svg+xml')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/sw.js')
|
||||||
|
def service_worker() -> Response:
|
||||||
|
resp = send_from_directory('static', 'sw.js', mimetype='application/javascript')
|
||||||
|
resp.headers['Service-Worker-Allowed'] = '/'
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/manifest.json')
|
||||||
|
def pwa_manifest() -> Response:
|
||||||
|
return send_from_directory('static', 'manifest.json', mimetype='application/manifest+json')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/devices')
|
@app.route('/devices')
|
||||||
def get_devices() -> Response:
|
def get_devices() -> Response:
|
||||||
"""Get all detected SDR devices with hardware type info."""
|
"""Get all detected SDR devices with hardware type info."""
|
||||||
@@ -659,105 +671,105 @@ def export_bluetooth() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def _get_subghz_active() -> bool:
|
def _get_subghz_active() -> bool:
|
||||||
"""Check if SubGHz manager has an active process."""
|
"""Check if SubGHz manager has an active process."""
|
||||||
try:
|
try:
|
||||||
from utils.subghz import get_subghz_manager
|
from utils.subghz import get_subghz_manager
|
||||||
return get_subghz_manager().active_mode != 'idle'
|
return get_subghz_manager().active_mode != 'idle'
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _get_bluetooth_health() -> tuple[bool, int]:
|
def _get_bluetooth_health() -> tuple[bool, int]:
|
||||||
"""Return Bluetooth active state and best-effort device count."""
|
"""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)
|
legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False)
|
||||||
scanner_running = False
|
scanner_running = False
|
||||||
scanner_count = 0
|
scanner_count = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
||||||
if bt_scanner is not None:
|
if bt_scanner is not None:
|
||||||
scanner_running = bool(bt_scanner.is_scanning)
|
scanner_running = bool(bt_scanner.is_scanning)
|
||||||
scanner_count = int(bt_scanner.device_count)
|
scanner_count = int(bt_scanner.device_count)
|
||||||
except Exception:
|
except Exception:
|
||||||
scanner_running = False
|
scanner_running = False
|
||||||
scanner_count = 0
|
scanner_count = 0
|
||||||
|
|
||||||
locate_running = False
|
locate_running = False
|
||||||
try:
|
try:
|
||||||
from utils.bt_locate import get_locate_session
|
from utils.bt_locate import get_locate_session
|
||||||
session = get_locate_session()
|
session = get_locate_session()
|
||||||
if session and getattr(session, 'active', False):
|
if session and getattr(session, 'active', False):
|
||||||
scanner = getattr(session, '_scanner', None)
|
scanner = getattr(session, '_scanner', None)
|
||||||
locate_running = bool(scanner and scanner.is_scanning)
|
locate_running = bool(scanner and scanner.is_scanning)
|
||||||
except Exception:
|
except Exception:
|
||||||
locate_running = False
|
locate_running = False
|
||||||
|
|
||||||
return (legacy_running or scanner_running or locate_running), max(len(bt_devices), scanner_count)
|
return (legacy_running or scanner_running or locate_running), max(len(bt_devices), scanner_count)
|
||||||
|
|
||||||
|
|
||||||
def _get_wifi_health() -> tuple[bool, int, int]:
|
def _get_wifi_health() -> tuple[bool, int, int]:
|
||||||
"""Return WiFi active state and best-effort network/client counts."""
|
"""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)
|
legacy_running = wifi_process is not None and (wifi_process.poll() is None if wifi_process else False)
|
||||||
scanner_running = False
|
scanner_running = False
|
||||||
scanner_networks = 0
|
scanner_networks = 0
|
||||||
scanner_clients = 0
|
scanner_clients = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
||||||
if wifi_scanner is not None:
|
if wifi_scanner is not None:
|
||||||
status = wifi_scanner.get_status()
|
status = wifi_scanner.get_status()
|
||||||
scanner_running = bool(status.is_scanning)
|
scanner_running = bool(status.is_scanning)
|
||||||
scanner_networks = int(status.networks_found or 0)
|
scanner_networks = int(status.networks_found or 0)
|
||||||
scanner_clients = int(status.clients_found or 0)
|
scanner_clients = int(status.clients_found or 0)
|
||||||
except Exception:
|
except Exception:
|
||||||
scanner_running = False
|
scanner_running = False
|
||||||
scanner_networks = 0
|
scanner_networks = 0
|
||||||
scanner_clients = 0
|
scanner_clients = 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
legacy_running or scanner_running,
|
legacy_running or scanner_running,
|
||||||
max(len(wifi_networks), scanner_networks),
|
max(len(wifi_networks), scanner_networks),
|
||||||
max(len(wifi_clients), scanner_clients),
|
max(len(wifi_clients), scanner_clients),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/health')
|
@app.route('/health')
|
||||||
def health_check() -> Response:
|
def health_check() -> Response:
|
||||||
"""Health check endpoint for monitoring."""
|
"""Health check endpoint for monitoring."""
|
||||||
import time
|
import time
|
||||||
bt_active, bt_device_count = _get_bluetooth_health()
|
bt_active, bt_device_count = _get_bluetooth_health()
|
||||||
wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health()
|
wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'healthy',
|
'status': 'healthy',
|
||||||
'version': VERSION,
|
'version': VERSION,
|
||||||
'uptime_seconds': round(time.time() - _app_start_time, 2),
|
'uptime_seconds': round(time.time() - _app_start_time, 2),
|
||||||
'processes': {
|
'processes': {
|
||||||
'pager': current_process is not None and (current_process.poll() is None if current_process else False),
|
'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),
|
'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),
|
'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),
|
'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),
|
'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),
|
'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),
|
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
|
||||||
'wifi': wifi_active,
|
'wifi': wifi_active,
|
||||||
'bluetooth': bt_active,
|
'bluetooth': bt_active,
|
||||||
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
||||||
'subghz': _get_subghz_active(),
|
'subghz': _get_subghz_active(),
|
||||||
},
|
},
|
||||||
'data': {
|
'data': {
|
||||||
'aircraft_count': len(adsb_aircraft),
|
'aircraft_count': len(adsb_aircraft),
|
||||||
'vessel_count': len(ais_vessels),
|
'vessel_count': len(ais_vessels),
|
||||||
'wifi_networks_count': wifi_network_count,
|
'wifi_networks_count': wifi_network_count,
|
||||||
'wifi_clients_count': wifi_client_count,
|
'wifi_clients_count': wifi_client_count,
|
||||||
'bt_devices_count': bt_device_count,
|
'bt_devices_count': bt_device_count,
|
||||||
'dsc_messages_count': len(dsc_messages),
|
'dsc_messages_count': len(dsc_messages),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/killall', methods=['POST'])
|
@app.route('/killall', methods=['POST'])
|
||||||
def kill_all() -> Response:
|
def kill_all() -> Response:
|
||||||
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
||||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||||
global vdl2_process
|
global vdl2_process
|
||||||
@@ -773,7 +785,7 @@ def kill_all() -> Response:
|
|||||||
'rtl_fm', 'multimon-ng', 'rtl_433',
|
'rtl_fm', 'multimon-ng', 'rtl_433',
|
||||||
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
||||||
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
|
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
|
||||||
'hcitool', 'bluetoothctl', 'satdump',
|
'hcitool', 'bluetoothctl', 'satdump',
|
||||||
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
||||||
'hackrf_transfer', 'hackrf_sweep'
|
'hackrf_transfer', 'hackrf_sweep'
|
||||||
]
|
]
|
||||||
@@ -823,7 +835,7 @@ def kill_all() -> Response:
|
|||||||
dsc_process = None
|
dsc_process = None
|
||||||
dsc_rtl_process = None
|
dsc_rtl_process = None
|
||||||
|
|
||||||
# Reset Bluetooth state (legacy)
|
# Reset Bluetooth state (legacy)
|
||||||
with bt_lock:
|
with bt_lock:
|
||||||
if bt_process:
|
if bt_process:
|
||||||
try:
|
try:
|
||||||
@@ -843,20 +855,50 @@ def kill_all() -> Response:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Reset SubGHz state
|
# Reset SubGHz state
|
||||||
try:
|
try:
|
||||||
from utils.subghz import get_subghz_manager
|
from utils.subghz import get_subghz_manager
|
||||||
get_subghz_manager().stop_all()
|
get_subghz_manager().stop_all()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Clear SDR device registry
|
# Clear SDR device registry
|
||||||
with sdr_device_registry_lock:
|
with sdr_device_registry_lock:
|
||||||
sdr_device_registry.clear()
|
sdr_device_registry.clear()
|
||||||
|
|
||||||
return jsonify({'status': 'killed', 'processes': killed})
|
return jsonify({'status': 'killed', 'processes': killed})
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_self_signed_cert(cert_dir: str) -> tuple:
|
||||||
|
"""Generate a self-signed certificate if one doesn't already exist.
|
||||||
|
|
||||||
|
Returns (cert_path, key_path) tuple.
|
||||||
|
"""
|
||||||
|
cert_path = os.path.join(cert_dir, 'intercept.crt')
|
||||||
|
key_path = os.path.join(cert_dir, 'intercept.key')
|
||||||
|
|
||||||
|
if os.path.exists(cert_path) and os.path.exists(key_path):
|
||||||
|
print(f"Using existing SSL certificate: {cert_path}")
|
||||||
|
return cert_path, key_path
|
||||||
|
|
||||||
|
os.makedirs(cert_dir, exist_ok=True)
|
||||||
|
print("Generating self-signed SSL certificate...")
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
result = subprocess.run([
|
||||||
|
'openssl', 'req', '-x509', '-newkey', 'rsa:2048',
|
||||||
|
'-keyout', key_path, '-out', cert_path,
|
||||||
|
'-days', '365', '-nodes',
|
||||||
|
'-subj', '/CN=intercept/O=INTERCEPT/C=US',
|
||||||
|
], capture_output=True, text=True)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"Failed to generate SSL certificate: {result.stderr}")
|
||||||
|
|
||||||
|
print(f"SSL certificate generated: {cert_path}")
|
||||||
|
return cert_path, key_path
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Main entry point."""
|
"""Main entry point."""
|
||||||
import argparse
|
import argparse
|
||||||
@@ -883,6 +925,12 @@ def main() -> None:
|
|||||||
default=config.DEBUG,
|
default=config.DEBUG,
|
||||||
help='Enable debug mode'
|
help='Enable debug mode'
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--https',
|
||||||
|
action='store_true',
|
||||||
|
default=config.HTTPS,
|
||||||
|
help='Enable HTTPS with self-signed certificate'
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--check-deps',
|
'--check-deps',
|
||||||
action='store_true',
|
action='store_true',
|
||||||
@@ -1008,7 +1056,18 @@ def main() -> None:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
print(f"WebSocket waterfall disabled: {e}")
|
print(f"WebSocket waterfall disabled: {e}")
|
||||||
|
|
||||||
print(f"Open http://localhost:{args.port} in your browser")
|
# Configure SSL if HTTPS is enabled
|
||||||
|
ssl_context = None
|
||||||
|
if args.https:
|
||||||
|
cert_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data', 'certs')
|
||||||
|
if config.SSL_CERT and config.SSL_KEY:
|
||||||
|
ssl_context = (config.SSL_CERT, config.SSL_KEY)
|
||||||
|
print(f"Using provided SSL certificate: {config.SSL_CERT}")
|
||||||
|
else:
|
||||||
|
ssl_context = _ensure_self_signed_cert(cert_dir)
|
||||||
|
|
||||||
|
protocol = 'https' if ssl_context else 'http'
|
||||||
|
print(f"Open {protocol}://localhost:{args.port} in your browser")
|
||||||
print()
|
print()
|
||||||
print("Press Ctrl+C to stop")
|
print("Press Ctrl+C to stop")
|
||||||
print()
|
print()
|
||||||
@@ -1020,4 +1079,5 @@ def main() -> None:
|
|||||||
debug=args.debug,
|
debug=args.debug,
|
||||||
threaded=True,
|
threaded=True,
|
||||||
load_dotenv=False,
|
load_dotenv=False,
|
||||||
|
ssl_context=ssl_context,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,35 +6,64 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "2.21.1"
|
VERSION = "2.22.3"
|
||||||
|
|
||||||
# Changelog - latest release notes (shown on welcome screen)
|
# Changelog - latest release notes (shown on welcome screen)
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
{
|
{
|
||||||
"version": "2.21.1",
|
"version": "2.22.3",
|
||||||
"date": "February 2026",
|
"date": "February 2026",
|
||||||
"highlights": [
|
"highlights": [
|
||||||
"BT Locate map first-load fix with render stabilization retries during initial mode open",
|
"Waterfall control panel no longer shows as unstyled text on first visit",
|
||||||
"BT Locate trail restore optimization for faster startup when historical GPS points exist",
|
"WebSDR globe renders correctly on first page load without requiring a refresh",
|
||||||
"BT Locate mode-switch map invalidation timing fix to prevent delayed/blank map render",
|
"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.21.0",
|
},
|
||||||
"date": "February 2026",
|
{
|
||||||
"highlights": [
|
"version": "2.22.1",
|
||||||
"Global map theme refresh with improved contrast and cross-dashboard consistency",
|
"date": "February 2026",
|
||||||
"Cross-app UX updates for accessibility, mode consistency, and render performance",
|
"highlights": [
|
||||||
"Weather satellite reliability fixes for auto-scheduler and Mercator pass tracking",
|
"Waterfall receiver overhaul: WebSocket I/Q streaming with server-side FFT, click-to-tune, and zoom controls",
|
||||||
"Bluetooth/WiFi runtime health fixes with BT Locate continuity and confidence improvements",
|
"Voice alerts for configurable event notifications across modes",
|
||||||
"ADS-B/VDL2 streaming reliability upgrades for multi-client SSE fanout and remote decoding",
|
"Signal fingerprinting mode for RF device identification and pattern analysis",
|
||||||
"Analytics enhancements with operational insights and temporal pattern panels",
|
"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",
|
||||||
"version": "2.20.0",
|
"Pager, sensor, and SSTV real-time signal scope visualization",
|
||||||
"date": "February 2026",
|
"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": [
|
"highlights": [
|
||||||
"Space Weather mode: real-time solar and geomagnetic monitoring from NOAA SWPC, NASA SDO, and HamQSL",
|
"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",
|
"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",
|
"Pure Python SSTV decoder replacing broken slowrx dependency",
|
||||||
"Real-time signal scope for pager, sensor, and SSTV modes",
|
"Real-time signal scope for pager, sensor, and SSTV modes",
|
||||||
"USB-level device probe to prevent cryptic rtl_fm crashes",
|
"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",
|
"version": "2.14.0",
|
||||||
"date": "February 2026",
|
"date": "February 2026",
|
||||||
"highlights": [
|
"highlights": [
|
||||||
"HF SSTV general mode with predefined shortwave frequencies",
|
"HF SSTV general mode with predefined shortwave frequencies",
|
||||||
"WebSDR integration for remote HF/shortwave listening",
|
"WebSDR integration for remote HF/shortwave listening",
|
||||||
"Listening Post signal scanner and audio pipeline improvements",
|
"Listening Post signal scanner and audio pipeline improvements",
|
||||||
"TSCM sweep resilience, WiFi detection, and correlation fixes",
|
"TSCM sweep resilience, WiFi detection, and correlation fixes",
|
||||||
@@ -251,6 +280,11 @@ PORT = _get_env_int('PORT', 5050)
|
|||||||
DEBUG = _get_env_bool('DEBUG', False)
|
DEBUG = _get_env_bool('DEBUG', False)
|
||||||
THREADED = _get_env_bool('THREADED', True)
|
THREADED = _get_env_bool('THREADED', True)
|
||||||
|
|
||||||
|
# HTTPS / SSL settings
|
||||||
|
HTTPS = _get_env_bool('HTTPS', False)
|
||||||
|
SSL_CERT = _get_env('SSL_CERT', '')
|
||||||
|
SSL_KEY = _get_env('SSL_KEY', '')
|
||||||
|
|
||||||
# Default RTL-SDR settings
|
# Default RTL-SDR settings
|
||||||
DEFAULT_GAIN = _get_env('DEFAULT_GAIN', '40')
|
DEFAULT_GAIN = _get_env('DEFAULT_GAIN', '40')
|
||||||
DEFAULT_DEVICE = _get_env('DEFAULT_DEVICE', '0')
|
DEFAULT_DEVICE = _get_env('DEFAULT_DEVICE', '0')
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ services:
|
|||||||
- basic
|
- basic
|
||||||
ports:
|
ports:
|
||||||
- "5050:5050"
|
- "5050:5050"
|
||||||
|
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
|
||||||
|
# - "5443:5443"
|
||||||
# Privileged mode required for USB SDR device access
|
# Privileged mode required for USB SDR device access
|
||||||
privileged: true
|
privileged: true
|
||||||
# USB device mapping for all USB devices
|
# USB device mapping for all USB devices
|
||||||
@@ -35,6 +37,9 @@ services:
|
|||||||
- INTERCEPT_HOST=0.0.0.0
|
- INTERCEPT_HOST=0.0.0.0
|
||||||
- INTERCEPT_PORT=5050
|
- INTERCEPT_PORT=5050
|
||||||
- INTERCEPT_LOG_LEVEL=INFO
|
- INTERCEPT_LOG_LEVEL=INFO
|
||||||
|
# HTTPS support (auto-generates self-signed cert)
|
||||||
|
# - INTERCEPT_HTTPS=true
|
||||||
|
# - INTERCEPT_PORT=5443
|
||||||
# ADS-B history is disabled by default
|
# ADS-B history is disabled by default
|
||||||
# To enable, use: docker compose --profile history up -d
|
# To enable, use: docker compose --profile history up -d
|
||||||
# - INTERCEPT_ADSB_HISTORY_ENABLED=true
|
# - INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||||
@@ -73,6 +78,8 @@ services:
|
|||||||
- adsb_db
|
- adsb_db
|
||||||
ports:
|
ports:
|
||||||
- "5050:5050"
|
- "5050:5050"
|
||||||
|
# Uncomment for HTTPS support (set INTERCEPT_HTTPS=true below)
|
||||||
|
# - "5443:5443"
|
||||||
# Privileged mode required for USB SDR device access
|
# Privileged mode required for USB SDR device access
|
||||||
privileged: true
|
privileged: true
|
||||||
# USB device mapping for all USB devices
|
# USB device mapping for all USB devices
|
||||||
@@ -85,6 +92,9 @@ services:
|
|||||||
- INTERCEPT_HOST=0.0.0.0
|
- INTERCEPT_HOST=0.0.0.0
|
||||||
- INTERCEPT_PORT=5050
|
- INTERCEPT_PORT=5050
|
||||||
- INTERCEPT_LOG_LEVEL=INFO
|
- INTERCEPT_LOG_LEVEL=INFO
|
||||||
|
# HTTPS support (auto-generates self-signed cert)
|
||||||
|
# - INTERCEPT_HTTPS=true
|
||||||
|
# - INTERCEPT_PORT=5443
|
||||||
- INTERCEPT_ADSB_HISTORY_ENABLED=true
|
- INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||||
- INTERCEPT_ADSB_DB_HOST=adsb_db
|
- INTERCEPT_ADSB_DB_HOST=adsb_db
|
||||||
- INTERCEPT_ADSB_DB_PORT=5432
|
- INTERCEPT_ADSB_DB_PORT=5432
|
||||||
|
|||||||
@@ -24,17 +24,6 @@ Complete feature list for all modules.
|
|||||||
- **Wideband spectrum analysis** with real-time visualization
|
- **Wideband spectrum analysis** with real-time visualization
|
||||||
- **I/Q capture** - record raw samples for offline analysis
|
- **I/Q capture** - record raw samples for offline analysis
|
||||||
|
|
||||||
## AIS Vessel Tracking
|
|
||||||
|
|
||||||
- **Real-time vessel tracking** via AIS-catcher on 161.975/162.025 MHz
|
|
||||||
- **Full-screen dashboard** - dedicated popout with interactive map
|
|
||||||
- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
|
|
||||||
- **Vessel details popup** - name, MMSI, callsign, destination, ETA
|
|
||||||
- **Navigation data** - speed, course, heading, rate of turn
|
|
||||||
- **Ship type classification** - cargo, tanker, passenger, fishing, etc.
|
|
||||||
- **Vessel dimensions** - length, width, draught
|
|
||||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
|
||||||
|
|
||||||
## Spy Stations (Number Stations)
|
## Spy Stations (Number Stations)
|
||||||
|
|
||||||
- **Comprehensive database** of active number stations and diplomatic networks
|
- **Comprehensive database** of active number stations and diplomatic networks
|
||||||
|
|||||||
+1
-1
@@ -172,7 +172,7 @@ Set the following environment variables (Docker recommended):
|
|||||||
```bash
|
```bash
|
||||||
INTERCEPT_ADSB_AUTO_START=true \
|
INTERCEPT_ADSB_AUTO_START=true \
|
||||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||||
python app.py
|
sudo -E venv/bin/python intercept.py
|
||||||
```
|
```
|
||||||
|
|
||||||
**Docker example (.env)**
|
**Docker example (.env)**
|
||||||
|
|||||||
+3
-3
@@ -110,7 +110,7 @@
|
|||||||
<div class="feature-card" data-category="signals">
|
<div class="feature-card" data-category="signals">
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></div>
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></div>
|
||||||
<h3>Utility Meters</h3>
|
<h3>Utility Meters</h3>
|
||||||
<p>Smart meter monitoring via rtl_amr. Receive electric, gas, and water meter broadcasts in real time.</p>
|
<p>Smart meter monitoring via rtlamr. Receive electric, gas, and water meter broadcasts in real time.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="tracking">
|
<div class="feature-card" data-category="tracking">
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2L16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></svg></div>
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2L16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></svg></div>
|
||||||
@@ -321,7 +321,7 @@ sudo -E venv/bin/python intercept.py</code></pre>
|
|||||||
<div class="code-block">
|
<div class="code-block">
|
||||||
<pre><code>git clone https://github.com/smittix/intercept.git
|
<pre><code>git clone https://github.com/smittix/intercept.git
|
||||||
cd intercept
|
cd intercept
|
||||||
docker compose up -d</code></pre>
|
docker compose --profile basic up -d --build</code></pre>
|
||||||
</div>
|
</div>
|
||||||
<p class="install-note">Requires privileged mode for USB SDR access</p>
|
<p class="install-note">Requires privileged mode for USB SDR access</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -422,7 +422,7 @@ docker compose up -d</code></pre>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-bottom">
|
<div class="footer-bottom">
|
||||||
<p>Created by <a href="https://github.com/smittix" target="_blank">smittix</a> · MIT License</p>
|
<p>Created by <a href="https://github.com/smittix" target="_blank">smittix</a> · Apache 2.0 License</p>
|
||||||
<p class="disclaimer">For educational and authorized testing purposes only.</p>
|
<p class="disclaimer">For educational and authorized testing purposes only.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "intercept"
|
name = "intercept"
|
||||||
version = "2.21.1"
|
version = "2.22.3"
|
||||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
+34
-34
@@ -2,40 +2,40 @@
|
|||||||
|
|
||||||
def register_blueprints(app):
|
def register_blueprints(app):
|
||||||
"""Register all route blueprints with the Flask app."""
|
"""Register all route blueprints with the Flask app."""
|
||||||
from .pager import pager_bp
|
from .acars import acars_bp
|
||||||
from .sensor import sensor_bp
|
|
||||||
from .rtlamr import rtlamr_bp
|
|
||||||
from .wifi import wifi_bp
|
|
||||||
from .wifi_v2 import wifi_v2_bp
|
|
||||||
from .bluetooth import bluetooth_bp
|
|
||||||
from .bluetooth_v2 import bluetooth_v2_bp
|
|
||||||
from .adsb import adsb_bp
|
from .adsb import adsb_bp
|
||||||
from .ais import ais_bp
|
from .ais import ais_bp
|
||||||
from .dsc import dsc_bp
|
|
||||||
from .acars import acars_bp
|
|
||||||
from .vdl2 import vdl2_bp
|
|
||||||
from .aprs import aprs_bp
|
|
||||||
from .satellite import satellite_bp
|
|
||||||
from .gps import gps_bp
|
|
||||||
from .settings import settings_bp
|
|
||||||
from .correlation import correlation_bp
|
|
||||||
from .listening_post import listening_post_bp
|
|
||||||
from .meshtastic import meshtastic_bp
|
|
||||||
from .tscm import tscm_bp, init_tscm_state
|
|
||||||
from .spy_stations import spy_stations_bp
|
|
||||||
from .controller import controller_bp
|
|
||||||
from .offline import offline_bp
|
|
||||||
from .updater import updater_bp
|
|
||||||
from .sstv import sstv_bp
|
|
||||||
from .weather_sat import weather_sat_bp
|
|
||||||
from .sstv_general import sstv_general_bp
|
|
||||||
from .websdr import websdr_bp
|
|
||||||
from .alerts import alerts_bp
|
from .alerts import alerts_bp
|
||||||
|
from .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 .recordings import recordings_bp
|
||||||
from .subghz import subghz_bp
|
from .rtlamr import rtlamr_bp
|
||||||
from .bt_locate import bt_locate_bp
|
from .satellite import satellite_bp
|
||||||
from .analytics import analytics_bp
|
from .sensor import sensor_bp
|
||||||
from .space_weather import space_weather_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(pager_bp)
|
||||||
app.register_blueprint(sensor_bp)
|
app.register_blueprint(sensor_bp)
|
||||||
@@ -54,7 +54,7 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(gps_bp)
|
app.register_blueprint(gps_bp)
|
||||||
app.register_blueprint(settings_bp)
|
app.register_blueprint(settings_bp)
|
||||||
app.register_blueprint(correlation_bp)
|
app.register_blueprint(correlation_bp)
|
||||||
app.register_blueprint(listening_post_bp)
|
app.register_blueprint(receiver_bp)
|
||||||
app.register_blueprint(meshtastic_bp)
|
app.register_blueprint(meshtastic_bp)
|
||||||
app.register_blueprint(tscm_bp)
|
app.register_blueprint(tscm_bp)
|
||||||
app.register_blueprint(spy_stations_bp)
|
app.register_blueprint(spy_stations_bp)
|
||||||
@@ -69,10 +69,10 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(recordings_bp) # Session recordings
|
app.register_blueprint(recordings_bp) # Session recordings
|
||||||
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
||||||
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
||||||
app.register_blueprint(analytics_bp) # Cross-mode analytics dashboard
|
|
||||||
app.register_blueprint(space_weather_bp) # Space weather monitoring
|
app.register_blueprint(space_weather_bp) # Space weather monitoring
|
||||||
|
app.register_blueprint(signalid_bp) # External signal ID enrichment
|
||||||
# Initialize TSCM state with queue and lock from app
|
|
||||||
|
# Initialize TSCM state with queue and lock from app
|
||||||
import app as app_module
|
import app as app_module
|
||||||
if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'):
|
if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'):
|
||||||
init_tscm_state(app_module.tscm_queue, app_module.tscm_lock)
|
init_tscm_state(app_module.tscm_queue, app_module.tscm_lock)
|
||||||
|
|||||||
+78
-42
@@ -379,10 +379,62 @@ def parse_sbs_stream(service_addr):
|
|||||||
adsb_bytes_received = 0
|
adsb_bytes_received = 0
|
||||||
adsb_lines_received = 0
|
adsb_lines_received = 0
|
||||||
|
|
||||||
|
def flush_pending_updates(force: bool = False) -> None:
|
||||||
|
nonlocal last_update
|
||||||
|
if not pending_updates:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
if not force and now - last_update < ADSB_UPDATE_INTERVAL:
|
||||||
|
return
|
||||||
|
|
||||||
|
captured_at = datetime.now(timezone.utc)
|
||||||
|
for update_icao in tuple(pending_updates):
|
||||||
|
if update_icao in app_module.adsb_aircraft:
|
||||||
|
snapshot = app_module.adsb_aircraft[update_icao]
|
||||||
|
_broadcast_adsb_update({
|
||||||
|
'type': 'aircraft',
|
||||||
|
**snapshot
|
||||||
|
})
|
||||||
|
adsb_snapshot_writer.enqueue({
|
||||||
|
'captured_at': captured_at,
|
||||||
|
'icao': update_icao,
|
||||||
|
'callsign': snapshot.get('callsign'),
|
||||||
|
'registration': snapshot.get('registration'),
|
||||||
|
'type_code': snapshot.get('type_code'),
|
||||||
|
'type_desc': snapshot.get('type_desc'),
|
||||||
|
'altitude': snapshot.get('altitude'),
|
||||||
|
'speed': snapshot.get('speed'),
|
||||||
|
'heading': snapshot.get('heading'),
|
||||||
|
'vertical_rate': snapshot.get('vertical_rate'),
|
||||||
|
'lat': snapshot.get('lat'),
|
||||||
|
'lon': snapshot.get('lon'),
|
||||||
|
'squawk': snapshot.get('squawk'),
|
||||||
|
'source_host': service_addr,
|
||||||
|
'snapshot': snapshot,
|
||||||
|
})
|
||||||
|
# Geofence check
|
||||||
|
_gf_lat = snapshot.get('lat')
|
||||||
|
_gf_lon = snapshot.get('lon')
|
||||||
|
if _gf_lat is not None and _gf_lon is not None:
|
||||||
|
try:
|
||||||
|
from utils.geofence import get_geofence_manager
|
||||||
|
for _gf_evt in get_geofence_manager().check_position(
|
||||||
|
update_icao, 'aircraft', _gf_lat, _gf_lon,
|
||||||
|
{'callsign': snapshot.get('callsign'), 'altitude': snapshot.get('altitude')}
|
||||||
|
):
|
||||||
|
process_event('adsb', _gf_evt, 'geofence')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
pending_updates.clear()
|
||||||
|
last_update = now
|
||||||
|
|
||||||
while adsb_using_service:
|
while adsb_using_service:
|
||||||
try:
|
try:
|
||||||
data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore')
|
data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore')
|
||||||
if not data:
|
if not data:
|
||||||
|
flush_pending_updates(force=True)
|
||||||
logger.warning("SBS connection closed (no data)")
|
logger.warning("SBS connection closed (no data)")
|
||||||
break
|
break
|
||||||
adsb_bytes_received += len(data)
|
adsb_bytes_received += len(data)
|
||||||
@@ -501,56 +553,40 @@ def parse_sbs_stream(service_addr):
|
|||||||
'squawk': sq, 'meaning': _EMERGENCY_SQUAWKS[sq],
|
'squawk': sq, 'meaning': _EMERGENCY_SQUAWKS[sq],
|
||||||
}, 'squawk_emergency')
|
}, 'squawk_emergency')
|
||||||
|
|
||||||
|
elif msg_type == '2' and len(parts) > 15:
|
||||||
|
if parts[11]:
|
||||||
|
try:
|
||||||
|
aircraft['altitude'] = int(float(parts[11]))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if parts[12]:
|
||||||
|
try:
|
||||||
|
aircraft['speed'] = int(float(parts[12]))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if parts[13]:
|
||||||
|
try:
|
||||||
|
aircraft['heading'] = int(float(parts[13]))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if parts[14] and parts[15]:
|
||||||
|
try:
|
||||||
|
aircraft['lat'] = float(parts[14])
|
||||||
|
aircraft['lon'] = float(parts[15])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
app_module.adsb_aircraft.set(icao, aircraft)
|
app_module.adsb_aircraft.set(icao, aircraft)
|
||||||
pending_updates.add(icao)
|
pending_updates.add(icao)
|
||||||
adsb_messages_received += 1
|
adsb_messages_received += 1
|
||||||
adsb_last_message_time = time.time()
|
adsb_last_message_time = time.time()
|
||||||
|
flush_pending_updates()
|
||||||
now = time.time()
|
|
||||||
if now - last_update >= ADSB_UPDATE_INTERVAL:
|
|
||||||
for update_icao in pending_updates:
|
|
||||||
if update_icao in app_module.adsb_aircraft:
|
|
||||||
snapshot = app_module.adsb_aircraft[update_icao]
|
|
||||||
_broadcast_adsb_update({
|
|
||||||
'type': 'aircraft',
|
|
||||||
**snapshot
|
|
||||||
})
|
|
||||||
adsb_snapshot_writer.enqueue({
|
|
||||||
'captured_at': datetime.now(timezone.utc),
|
|
||||||
'icao': update_icao,
|
|
||||||
'callsign': snapshot.get('callsign'),
|
|
||||||
'registration': snapshot.get('registration'),
|
|
||||||
'type_code': snapshot.get('type_code'),
|
|
||||||
'type_desc': snapshot.get('type_desc'),
|
|
||||||
'altitude': snapshot.get('altitude'),
|
|
||||||
'speed': snapshot.get('speed'),
|
|
||||||
'heading': snapshot.get('heading'),
|
|
||||||
'vertical_rate': snapshot.get('vertical_rate'),
|
|
||||||
'lat': snapshot.get('lat'),
|
|
||||||
'lon': snapshot.get('lon'),
|
|
||||||
'squawk': snapshot.get('squawk'),
|
|
||||||
'source_host': service_addr,
|
|
||||||
'snapshot': snapshot,
|
|
||||||
})
|
|
||||||
# Geofence check
|
|
||||||
_gf_lat = snapshot.get('lat')
|
|
||||||
_gf_lon = snapshot.get('lon')
|
|
||||||
if _gf_lat is not None and _gf_lon is not None:
|
|
||||||
try:
|
|
||||||
from utils.geofence import get_geofence_manager
|
|
||||||
for _gf_evt in get_geofence_manager().check_position(
|
|
||||||
update_icao, 'aircraft', _gf_lat, _gf_lon,
|
|
||||||
{'callsign': snapshot.get('callsign'), 'altitude': snapshot.get('altitude')}
|
|
||||||
):
|
|
||||||
process_event('adsb', _gf_evt, 'geofence')
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
pending_updates.clear()
|
|
||||||
last_update = now
|
|
||||||
|
|
||||||
except socket.timeout:
|
except socket.timeout:
|
||||||
|
flush_pending_updates()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
flush_pending_updates(force=True)
|
||||||
sock.close()
|
sock.close()
|
||||||
adsb_connected = False
|
adsb_connected = False
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
|
|||||||
@@ -1,528 +0,0 @@
|
|||||||
"""Analytics dashboard: cross-mode summary, activity sparklines, export, geofence CRUD."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
|
||||||
|
|
||||||
import app as app_module
|
|
||||||
from utils.analytics import (
|
|
||||||
get_activity_tracker,
|
|
||||||
get_cross_mode_summary,
|
|
||||||
get_emergency_squawks,
|
|
||||||
get_mode_health,
|
|
||||||
)
|
|
||||||
from utils.alerts import get_alert_manager
|
|
||||||
from utils.flight_correlator import get_flight_correlator
|
|
||||||
from utils.geofence import get_geofence_manager
|
|
||||||
from utils.temporal_patterns import get_pattern_detector
|
|
||||||
|
|
||||||
analytics_bp = Blueprint('analytics', __name__, url_prefix='/analytics')
|
|
||||||
|
|
||||||
|
|
||||||
# Map mode names to DataStore attribute(s)
|
|
||||||
MODE_STORES: dict[str, list[str]] = {
|
|
||||||
'adsb': ['adsb_aircraft'],
|
|
||||||
'ais': ['ais_vessels'],
|
|
||||||
'wifi': ['wifi_networks', 'wifi_clients'],
|
|
||||||
'bluetooth': ['bt_devices'],
|
|
||||||
'dsc': ['dsc_messages'],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/summary')
|
|
||||||
def analytics_summary():
|
|
||||||
"""Return cross-mode counts, health, and emergency squawks."""
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'counts': get_cross_mode_summary(),
|
|
||||||
'health': get_mode_health(),
|
|
||||||
'squawks': get_emergency_squawks(),
|
|
||||||
'flight_messages': {
|
|
||||||
'acars': get_flight_correlator().acars_count,
|
|
||||||
'vdl2': get_flight_correlator().vdl2_count,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/activity')
|
|
||||||
def analytics_activity():
|
|
||||||
"""Return sparkline arrays for each mode."""
|
|
||||||
tracker = get_activity_tracker()
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'sparklines': tracker.get_all_sparklines(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/squawks')
|
|
||||||
def analytics_squawks():
|
|
||||||
"""Return current emergency squawk codes from ADS-B."""
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'squawks': get_emergency_squawks(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/patterns')
|
|
||||||
def analytics_patterns():
|
|
||||||
"""Return detected temporal patterns."""
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'patterns': get_pattern_detector().get_all_patterns(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/target')
|
|
||||||
def analytics_target():
|
|
||||||
"""Search entities across multiple modes for a target-centric view."""
|
|
||||||
query = (request.args.get('q') or '').strip()
|
|
||||||
requested_limit = request.args.get('limit', default=120, type=int) or 120
|
|
||||||
limit = max(1, min(500, requested_limit))
|
|
||||||
|
|
||||||
if not query:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'query': '',
|
|
||||||
'results': [],
|
|
||||||
'mode_counts': {},
|
|
||||||
})
|
|
||||||
|
|
||||||
needle = query.lower()
|
|
||||||
results: list[dict[str, Any]] = []
|
|
||||||
mode_counts: dict[str, int] = {}
|
|
||||||
|
|
||||||
def push(mode: str, entity_id: str, title: str, subtitle: str, last_seen: str | None = None) -> None:
|
|
||||||
if len(results) >= limit:
|
|
||||||
return
|
|
||||||
results.append({
|
|
||||||
'mode': mode,
|
|
||||||
'id': entity_id,
|
|
||||||
'title': title,
|
|
||||||
'subtitle': subtitle,
|
|
||||||
'last_seen': last_seen,
|
|
||||||
})
|
|
||||||
mode_counts[mode] = mode_counts.get(mode, 0) + 1
|
|
||||||
|
|
||||||
# ADS-B
|
|
||||||
for icao, aircraft in app_module.adsb_aircraft.items():
|
|
||||||
if not isinstance(aircraft, dict):
|
|
||||||
continue
|
|
||||||
fields = [
|
|
||||||
icao,
|
|
||||||
aircraft.get('icao'),
|
|
||||||
aircraft.get('hex'),
|
|
||||||
aircraft.get('callsign'),
|
|
||||||
aircraft.get('registration'),
|
|
||||||
aircraft.get('flight'),
|
|
||||||
]
|
|
||||||
if not _matches_query(needle, fields):
|
|
||||||
continue
|
|
||||||
title = str(aircraft.get('callsign') or icao or 'Aircraft').strip()
|
|
||||||
subtitle = f"ICAO {aircraft.get('icao') or icao} | Alt {aircraft.get('altitude', '--')} | Speed {aircraft.get('speed', '--')}"
|
|
||||||
push('adsb', str(icao), title, subtitle, aircraft.get('lastSeen') or aircraft.get('last_seen'))
|
|
||||||
if len(results) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
# AIS
|
|
||||||
if len(results) < limit:
|
|
||||||
for mmsi, vessel in app_module.ais_vessels.items():
|
|
||||||
if not isinstance(vessel, dict):
|
|
||||||
continue
|
|
||||||
fields = [
|
|
||||||
mmsi,
|
|
||||||
vessel.get('mmsi'),
|
|
||||||
vessel.get('name'),
|
|
||||||
vessel.get('shipname'),
|
|
||||||
vessel.get('callsign'),
|
|
||||||
vessel.get('imo'),
|
|
||||||
]
|
|
||||||
if not _matches_query(needle, fields):
|
|
||||||
continue
|
|
||||||
vessel_name = vessel.get('name') or vessel.get('shipname') or mmsi or 'Vessel'
|
|
||||||
subtitle = f"MMSI {vessel.get('mmsi') or mmsi} | Type {vessel.get('ship_type') or vessel.get('type') or '--'}"
|
|
||||||
push('ais', str(mmsi), str(vessel_name), subtitle, vessel.get('lastSeen') or vessel.get('last_seen'))
|
|
||||||
if len(results) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
# WiFi networks and clients
|
|
||||||
if len(results) < limit:
|
|
||||||
for bssid, net in app_module.wifi_networks.items():
|
|
||||||
if not isinstance(net, dict):
|
|
||||||
continue
|
|
||||||
fields = [bssid, net.get('bssid'), net.get('ssid'), net.get('vendor')]
|
|
||||||
if not _matches_query(needle, fields):
|
|
||||||
continue
|
|
||||||
title = str(net.get('ssid') or net.get('bssid') or bssid or 'WiFi Network')
|
|
||||||
subtitle = f"BSSID {net.get('bssid') or bssid} | CH {net.get('channel', '--')} | RSSI {net.get('signal', '--')}"
|
|
||||||
push('wifi', str(bssid), title, subtitle, net.get('lastSeen') or net.get('last_seen'))
|
|
||||||
if len(results) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
if len(results) < limit:
|
|
||||||
for client_mac, client in app_module.wifi_clients.items():
|
|
||||||
if not isinstance(client, dict):
|
|
||||||
continue
|
|
||||||
fields = [client_mac, client.get('mac'), client.get('bssid'), client.get('ssid'), client.get('vendor')]
|
|
||||||
if not _matches_query(needle, fields):
|
|
||||||
continue
|
|
||||||
title = str(client.get('mac') or client_mac or 'WiFi Client')
|
|
||||||
subtitle = f"BSSID {client.get('bssid') or '--'} | Probe {client.get('ssid') or '--'}"
|
|
||||||
push('wifi', str(client_mac), title, subtitle, client.get('lastSeen') or client.get('last_seen'))
|
|
||||||
if len(results) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Bluetooth
|
|
||||||
if len(results) < limit:
|
|
||||||
for address, dev in app_module.bt_devices.items():
|
|
||||||
if not isinstance(dev, dict):
|
|
||||||
continue
|
|
||||||
fields = [
|
|
||||||
address,
|
|
||||||
dev.get('address'),
|
|
||||||
dev.get('mac'),
|
|
||||||
dev.get('name'),
|
|
||||||
dev.get('manufacturer'),
|
|
||||||
dev.get('vendor'),
|
|
||||||
]
|
|
||||||
if not _matches_query(needle, fields):
|
|
||||||
continue
|
|
||||||
title = str(dev.get('name') or dev.get('address') or address or 'Bluetooth Device')
|
|
||||||
subtitle = f"MAC {dev.get('address') or address} | RSSI {dev.get('rssi', '--')} | Vendor {dev.get('manufacturer') or dev.get('vendor') or '--'}"
|
|
||||||
push('bluetooth', str(address), title, subtitle, dev.get('lastSeen') or dev.get('last_seen'))
|
|
||||||
if len(results) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
# DSC recent messages
|
|
||||||
if len(results) < limit:
|
|
||||||
for msg_id, msg in app_module.dsc_messages.items():
|
|
||||||
if not isinstance(msg, dict):
|
|
||||||
continue
|
|
||||||
fields = [
|
|
||||||
msg_id,
|
|
||||||
msg.get('mmsi'),
|
|
||||||
msg.get('from_mmsi'),
|
|
||||||
msg.get('to_mmsi'),
|
|
||||||
msg.get('from_callsign'),
|
|
||||||
msg.get('to_callsign'),
|
|
||||||
msg.get('category'),
|
|
||||||
]
|
|
||||||
if not _matches_query(needle, fields):
|
|
||||||
continue
|
|
||||||
title = str(msg.get('from_mmsi') or msg.get('mmsi') or msg_id or 'DSC Message')
|
|
||||||
subtitle = f"To {msg.get('to_mmsi') or '--'} | Cat {msg.get('category') or '--'} | Freq {msg.get('frequency') or '--'}"
|
|
||||||
push('dsc', str(msg_id), title, subtitle, msg.get('timestamp') or msg.get('lastSeen') or msg.get('last_seen'))
|
|
||||||
if len(results) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'query': query,
|
|
||||||
'results': results,
|
|
||||||
'mode_counts': mode_counts,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/insights')
|
|
||||||
def analytics_insights():
|
|
||||||
"""Return actionable insight cards and top changes."""
|
|
||||||
counts = get_cross_mode_summary()
|
|
||||||
tracker = get_activity_tracker()
|
|
||||||
sparklines = tracker.get_all_sparklines()
|
|
||||||
squawks = get_emergency_squawks()
|
|
||||||
patterns = get_pattern_detector().get_all_patterns()
|
|
||||||
alerts = get_alert_manager().list_events(limit=120)
|
|
||||||
|
|
||||||
top_changes = _compute_mode_changes(sparklines)
|
|
||||||
busiest_mode, busiest_count = _get_busiest_mode(counts)
|
|
||||||
critical_1h = _count_recent_alerts(alerts, severities={'critical', 'high'}, max_age_seconds=3600)
|
|
||||||
recurring_emitters = sum(1 for p in patterns if float(p.get('confidence') or 0.0) >= 0.7)
|
|
||||||
|
|
||||||
cards = []
|
|
||||||
if top_changes:
|
|
||||||
lead = top_changes[0]
|
|
||||||
direction = 'up' if lead['delta'] >= 0 else 'down'
|
|
||||||
cards.append({
|
|
||||||
'id': 'fastest_change',
|
|
||||||
'title': 'Fastest Change',
|
|
||||||
'value': f"{lead['mode_label']} ({lead['signed_delta']})",
|
|
||||||
'label': 'last window vs prior',
|
|
||||||
'severity': 'high' if lead['delta'] > 0 else 'low',
|
|
||||||
'detail': f"Traffic is trending {direction} in {lead['mode_label']}.",
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
cards.append({
|
|
||||||
'id': 'fastest_change',
|
|
||||||
'title': 'Fastest Change',
|
|
||||||
'value': 'Insufficient data',
|
|
||||||
'label': 'wait for activity history',
|
|
||||||
'severity': 'low',
|
|
||||||
'detail': 'Sparklines need more samples to score momentum.',
|
|
||||||
})
|
|
||||||
|
|
||||||
cards.append({
|
|
||||||
'id': 'busiest_mode',
|
|
||||||
'title': 'Busiest Mode',
|
|
||||||
'value': f"{busiest_mode} ({busiest_count})",
|
|
||||||
'label': 'current observed entities',
|
|
||||||
'severity': 'medium' if busiest_count > 0 else 'low',
|
|
||||||
'detail': 'Highest live entity count across monitoring modes.',
|
|
||||||
})
|
|
||||||
cards.append({
|
|
||||||
'id': 'critical_alerts',
|
|
||||||
'title': 'Critical Alerts (1h)',
|
|
||||||
'value': str(critical_1h),
|
|
||||||
'label': 'critical/high severities',
|
|
||||||
'severity': 'critical' if critical_1h > 0 else 'low',
|
|
||||||
'detail': 'Prioritize triage if this count is non-zero.',
|
|
||||||
})
|
|
||||||
cards.append({
|
|
||||||
'id': 'emergency_squawks',
|
|
||||||
'title': 'Emergency Squawks',
|
|
||||||
'value': str(len(squawks)),
|
|
||||||
'label': 'active ADS-B emergency codes',
|
|
||||||
'severity': 'critical' if squawks else 'low',
|
|
||||||
'detail': 'Immediate aviation anomalies currently visible.',
|
|
||||||
})
|
|
||||||
cards.append({
|
|
||||||
'id': 'recurring_emitters',
|
|
||||||
'title': 'Recurring Emitters',
|
|
||||||
'value': str(recurring_emitters),
|
|
||||||
'label': 'pattern confidence >= 0.70',
|
|
||||||
'severity': 'medium' if recurring_emitters > 0 else 'low',
|
|
||||||
'detail': 'Potentially stationary or periodic emitters detected.',
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'generated_at': datetime.now(timezone.utc).isoformat(),
|
|
||||||
'cards': cards,
|
|
||||||
'top_changes': top_changes[:5],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _compute_mode_changes(sparklines: dict[str, list[int]]) -> list[dict]:
|
|
||||||
mode_labels = {
|
|
||||||
'adsb': 'ADS-B',
|
|
||||||
'ais': 'AIS',
|
|
||||||
'wifi': 'WiFi',
|
|
||||||
'bluetooth': 'Bluetooth',
|
|
||||||
'dsc': 'DSC',
|
|
||||||
'acars': 'ACARS',
|
|
||||||
'vdl2': 'VDL2',
|
|
||||||
'aprs': 'APRS',
|
|
||||||
'meshtastic': 'Meshtastic',
|
|
||||||
}
|
|
||||||
rows = []
|
|
||||||
for mode, samples in (sparklines or {}).items():
|
|
||||||
if not isinstance(samples, list) or len(samples) < 4:
|
|
||||||
continue
|
|
||||||
|
|
||||||
window = max(2, min(12, len(samples) // 2))
|
|
||||||
recent = samples[-window:]
|
|
||||||
previous = samples[-(window * 2):-window]
|
|
||||||
if not previous:
|
|
||||||
continue
|
|
||||||
|
|
||||||
recent_avg = sum(recent) / len(recent)
|
|
||||||
prev_avg = sum(previous) / len(previous)
|
|
||||||
delta = round(recent_avg - prev_avg, 1)
|
|
||||||
rows.append({
|
|
||||||
'mode': mode,
|
|
||||||
'mode_label': mode_labels.get(mode, mode.upper()),
|
|
||||||
'delta': delta,
|
|
||||||
'signed_delta': ('+' if delta >= 0 else '') + str(delta),
|
|
||||||
'recent_avg': round(recent_avg, 1),
|
|
||||||
'previous_avg': round(prev_avg, 1),
|
|
||||||
'direction': 'up' if delta > 0 else ('down' if delta < 0 else 'flat'),
|
|
||||||
})
|
|
||||||
|
|
||||||
rows.sort(key=lambda r: abs(r['delta']), reverse=True)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def _matches_query(needle: str, values: list[Any]) -> bool:
|
|
||||||
for value in values:
|
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
if needle in str(value).lower():
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _count_recent_alerts(alerts: list[dict], severities: set[str], max_age_seconds: int) -> int:
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
count = 0
|
|
||||||
for event in alerts:
|
|
||||||
sev = str(event.get('severity') or '').lower()
|
|
||||||
if sev not in severities:
|
|
||||||
continue
|
|
||||||
created_raw = event.get('created_at')
|
|
||||||
if not created_raw:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
created = datetime.fromisoformat(str(created_raw).replace('Z', '+00:00'))
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if created.tzinfo is None:
|
|
||||||
created = created.replace(tzinfo=timezone.utc)
|
|
||||||
age = (now - created).total_seconds()
|
|
||||||
if 0 <= age <= max_age_seconds:
|
|
||||||
count += 1
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
|
||||||
def _get_busiest_mode(counts: dict[str, int]) -> tuple[str, int]:
|
|
||||||
mode_labels = {
|
|
||||||
'adsb': 'ADS-B',
|
|
||||||
'ais': 'AIS',
|
|
||||||
'wifi': 'WiFi',
|
|
||||||
'bluetooth': 'Bluetooth',
|
|
||||||
'dsc': 'DSC',
|
|
||||||
'acars': 'ACARS',
|
|
||||||
'vdl2': 'VDL2',
|
|
||||||
'aprs': 'APRS',
|
|
||||||
'meshtastic': 'Meshtastic',
|
|
||||||
}
|
|
||||||
filtered = {k: int(v or 0) for k, v in (counts or {}).items() if k in mode_labels}
|
|
||||||
if not filtered:
|
|
||||||
return ('None', 0)
|
|
||||||
mode = max(filtered, key=filtered.get)
|
|
||||||
return (mode_labels.get(mode, mode.upper()), filtered[mode])
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/export/<mode>')
|
|
||||||
def analytics_export(mode: str):
|
|
||||||
"""Export current DataStore contents as JSON or CSV."""
|
|
||||||
fmt = request.args.get('format', 'json').lower()
|
|
||||||
|
|
||||||
if mode == 'sensor':
|
|
||||||
# Sensor doesn't use DataStore; return recent queue-based data
|
|
||||||
return jsonify({'status': 'success', 'data': [], 'message': 'Sensor data is stream-only'})
|
|
||||||
|
|
||||||
store_names = MODE_STORES.get(mode)
|
|
||||||
if not store_names:
|
|
||||||
return jsonify({'status': 'error', 'message': f'Unknown mode: {mode}'}), 400
|
|
||||||
|
|
||||||
all_items: list[dict] = []
|
|
||||||
|
|
||||||
# Try v2 scanners first for wifi/bluetooth
|
|
||||||
if mode == 'wifi':
|
|
||||||
try:
|
|
||||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
|
||||||
if wifi_scanner is not None:
|
|
||||||
for ap in wifi_scanner.access_points:
|
|
||||||
all_items.append(ap.to_dict())
|
|
||||||
for client in wifi_scanner.clients:
|
|
||||||
item = client.to_dict()
|
|
||||||
item['_store'] = 'wifi_clients'
|
|
||||||
all_items.append(item)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
elif mode == 'bluetooth':
|
|
||||||
try:
|
|
||||||
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
|
||||||
if bt_scanner is not None:
|
|
||||||
for dev in bt_scanner.get_devices():
|
|
||||||
all_items.append(dev.to_dict())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Fall back to legacy DataStores if v2 scanners yielded nothing
|
|
||||||
if not all_items:
|
|
||||||
for store_name in store_names:
|
|
||||||
store = getattr(app_module, store_name, None)
|
|
||||||
if store is None:
|
|
||||||
continue
|
|
||||||
for key, value in store.items():
|
|
||||||
item = dict(value) if isinstance(value, dict) else {'id': key, 'value': value}
|
|
||||||
item.setdefault('_store', store_name)
|
|
||||||
all_items.append(item)
|
|
||||||
|
|
||||||
if fmt == 'csv':
|
|
||||||
if not all_items:
|
|
||||||
output = ''
|
|
||||||
else:
|
|
||||||
# Collect all keys across items
|
|
||||||
fieldnames: list[str] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for item in all_items:
|
|
||||||
for k in item:
|
|
||||||
if k not in seen:
|
|
||||||
fieldnames.append(k)
|
|
||||||
seen.add(k)
|
|
||||||
|
|
||||||
buf = io.StringIO()
|
|
||||||
writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction='ignore')
|
|
||||||
writer.writeheader()
|
|
||||||
for item in all_items:
|
|
||||||
# Serialize non-scalar values
|
|
||||||
row = {}
|
|
||||||
for k in fieldnames:
|
|
||||||
v = item.get(k)
|
|
||||||
if isinstance(v, (dict, list)):
|
|
||||||
row[k] = json.dumps(v)
|
|
||||||
else:
|
|
||||||
row[k] = v
|
|
||||||
writer.writerow(row)
|
|
||||||
output = buf.getvalue()
|
|
||||||
|
|
||||||
response = Response(output, mimetype='text/csv')
|
|
||||||
response.headers['Content-Disposition'] = f'attachment; filename={mode}_export.csv'
|
|
||||||
return response
|
|
||||||
|
|
||||||
# Default: JSON
|
|
||||||
return jsonify({'status': 'success', 'mode': mode, 'count': len(all_items), 'data': all_items})
|
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# Geofence CRUD
|
|
||||||
# =========================================================================
|
|
||||||
|
|
||||||
@analytics_bp.route('/geofences')
|
|
||||||
def list_geofences():
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'zones': get_geofence_manager().list_zones(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/geofences', methods=['POST'])
|
|
||||||
def create_geofence():
|
|
||||||
data = request.get_json() or {}
|
|
||||||
name = data.get('name')
|
|
||||||
lat = data.get('lat')
|
|
||||||
lon = data.get('lon')
|
|
||||||
radius_m = data.get('radius_m')
|
|
||||||
|
|
||||||
if not all([name, lat is not None, lon is not None, radius_m is not None]):
|
|
||||||
return jsonify({'status': 'error', 'message': 'name, lat, lon, radius_m are required'}), 400
|
|
||||||
|
|
||||||
try:
|
|
||||||
lat = float(lat)
|
|
||||||
lon = float(lon)
|
|
||||||
radius_m = float(radius_m)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return jsonify({'status': 'error', 'message': 'lat, lon, radius_m must be numbers'}), 400
|
|
||||||
|
|
||||||
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
|
|
||||||
if radius_m <= 0:
|
|
||||||
return jsonify({'status': 'error', 'message': 'radius_m must be positive'}), 400
|
|
||||||
|
|
||||||
alert_on = data.get('alert_on', 'enter_exit')
|
|
||||||
zone_id = get_geofence_manager().add_zone(name, lat, lon, radius_m, alert_on)
|
|
||||||
return jsonify({'status': 'success', 'zone_id': zone_id})
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/geofences/<int:zone_id>', methods=['DELETE'])
|
|
||||||
def delete_geofence(zone_id: int):
|
|
||||||
ok = get_geofence_manager().delete_zone(zone_id)
|
|
||||||
if not ok:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Zone not found'}), 404
|
|
||||||
return jsonify({'status': 'success'})
|
|
||||||
+493
-255
File diff suppressed because it is too large
Load Diff
+44
-29
@@ -96,7 +96,7 @@ def parse_multimon_output(line: str) -> dict[str, str] | None:
|
|||||||
return 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."""
|
"""Log a message to file if logging is enabled."""
|
||||||
if not app_module.logging_enabled:
|
if not app_module.logging_enabled:
|
||||||
return
|
return
|
||||||
@@ -104,25 +104,39 @@ def log_message(msg: dict[str, Any]) -> None:
|
|||||||
with open(app_module.log_file_path, 'a') as f:
|
with open(app_module.log_file_path, 'a') as f:
|
||||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
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")
|
f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to log message: {e}")
|
logger.error(f"Failed to log message: {e}")
|
||||||
|
|
||||||
|
|
||||||
def audio_relay_thread(
|
def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]:
|
||||||
rtl_stdout,
|
"""Compress recent PCM samples into a signed 8-bit waveform for SSE."""
|
||||||
multimon_stdin,
|
if not samples:
|
||||||
output_queue: queue.Queue,
|
return []
|
||||||
stop_event: threading.Event,
|
|
||||||
) -> None:
|
window = samples[-window_size:] if len(samples) > window_size else samples
|
||||||
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
|
waveform: list[int] = []
|
||||||
|
for sample in window:
|
||||||
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
# Convert int16 PCM to int8 range for lightweight transport.
|
||||||
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
packed = int(round(sample / 256))
|
||||||
event onto *output_queue*.
|
waveform.append(max(-127, min(127, packed)))
|
||||||
"""
|
return waveform
|
||||||
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
|
||||||
INTERVAL = 0.1 # seconds between scope updates
|
|
||||||
last_scope = time.monotonic()
|
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:
|
try:
|
||||||
while not stop_event.is_set():
|
while not stop_event.is_set():
|
||||||
@@ -146,15 +160,16 @@ def audio_relay_thread(
|
|||||||
if n_samples == 0:
|
if n_samples == 0:
|
||||||
continue
|
continue
|
||||||
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
|
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
|
||||||
peak = max(abs(s) for s in samples)
|
peak = max(abs(s) for s in samples)
|
||||||
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
|
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
|
||||||
output_queue.put_nowait({
|
output_queue.put_nowait({
|
||||||
'type': 'scope',
|
'type': 'scope',
|
||||||
'rms': rms,
|
'rms': rms,
|
||||||
'peak': peak,
|
'peak': peak,
|
||||||
})
|
'waveform': _encode_scope_waveform(samples),
|
||||||
except (struct.error, ValueError, queue.Full):
|
})
|
||||||
pass
|
except (struct.error, ValueError, queue.Full):
|
||||||
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Audio relay error: {e}")
|
logger.debug(f"Audio relay error: {e}")
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
+64
-25
@@ -1,14 +1,15 @@
|
|||||||
"""RTL_433 sensor monitoring routes."""
|
"""RTL_433 sensor monitoring routes."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import queue
|
import math
|
||||||
import subprocess
|
import queue
|
||||||
import threading
|
import subprocess
|
||||||
import time
|
import threading
|
||||||
from datetime import datetime
|
import time
|
||||||
from typing import Generator
|
from datetime import datetime
|
||||||
|
from typing import Any, Generator
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
@@ -28,12 +29,42 @@ sensor_bp = Blueprint('sensor', __name__)
|
|||||||
# Track which device is being used
|
# Track which device is being used
|
||||||
sensor_active_device: int | None = None
|
sensor_active_device: int | None = None
|
||||||
|
|
||||||
# RSSI history per device (model_id -> list of (timestamp, rssi))
|
# RSSI history per device (model_id -> list of (timestamp, rssi))
|
||||||
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
|
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
|
||||||
_MAX_RSSI_HISTORY = 60
|
_MAX_RSSI_HISTORY = 60
|
||||||
|
|
||||||
|
|
||||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
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."""
|
"""Stream rtl_433 JSON output to queue."""
|
||||||
try:
|
try:
|
||||||
app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
|
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')
|
rssi = data.get('rssi')
|
||||||
snr = data.get('snr')
|
snr = data.get('snr')
|
||||||
noise = data.get('noise')
|
noise = data.get('noise')
|
||||||
if rssi is not None or snr is not None:
|
if rssi is not None or snr is not None:
|
||||||
try:
|
try:
|
||||||
app_module.sensor_queue.put_nowait({
|
rssi_value = float(rssi) if rssi is not None else 0.0
|
||||||
'type': 'scope',
|
snr_value = float(snr) if snr is not None else 0.0
|
||||||
'rssi': rssi if rssi is not None else 0,
|
noise_value = float(noise) if noise is not None else 0.0
|
||||||
'snr': snr if snr is not None else 0,
|
app_module.sensor_queue.put_nowait({
|
||||||
'noise': noise if noise is not None else 0,
|
'type': 'scope',
|
||||||
})
|
'rssi': rssi_value,
|
||||||
except queue.Full:
|
'snr': snr_value,
|
||||||
pass
|
'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
|
# Log if enabled
|
||||||
if app_module.logging_enabled:
|
if app_module.logging_enabled:
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
"""Signal identification enrichment routes (SigID Wiki proxy lookup)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('intercept.signalid')
|
||||||
|
|
||||||
|
signalid_bp = Blueprint('signalid', __name__, url_prefix='/signalid')
|
||||||
|
|
||||||
|
SIGID_API_URL = 'https://www.sigidwiki.com/api.php'
|
||||||
|
SIGID_USER_AGENT = 'INTERCEPT-SignalID/1.0'
|
||||||
|
SIGID_TIMEOUT_SECONDS = 12
|
||||||
|
SIGID_CACHE_TTL_SECONDS = 600
|
||||||
|
|
||||||
|
_cache: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_get(key: str) -> Any | None:
|
||||||
|
entry = _cache.get(key)
|
||||||
|
if not entry:
|
||||||
|
return None
|
||||||
|
if time.time() >= entry['expires']:
|
||||||
|
_cache.pop(key, None)
|
||||||
|
return None
|
||||||
|
return entry['data']
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_set(key: str, data: Any, ttl_seconds: int = SIGID_CACHE_TTL_SECONDS) -> None:
|
||||||
|
_cache[key] = {
|
||||||
|
'data': data,
|
||||||
|
'expires': time.time() + ttl_seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_api_json(params: dict[str, str]) -> dict[str, Any] | None:
|
||||||
|
query = urllib.parse.urlencode(params, doseq=True)
|
||||||
|
url = f'{SIGID_API_URL}?{query}'
|
||||||
|
req = urllib.request.Request(url, headers={'User-Agent': SIGID_USER_AGENT})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=SIGID_TIMEOUT_SECONDS) as resp:
|
||||||
|
payload = resp.read().decode('utf-8', errors='replace')
|
||||||
|
data = json.loads(payload)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('SigID API request failed: %s', exc)
|
||||||
|
return None
|
||||||
|
if isinstance(data, dict) and data.get('error'):
|
||||||
|
logger.warning('SigID API returned error: %s', data.get('error'))
|
||||||
|
return None
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _ask_query(query: str) -> dict[str, Any] | None:
|
||||||
|
return _fetch_api_json({
|
||||||
|
'action': 'ask',
|
||||||
|
'query': query,
|
||||||
|
'format': 'json',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _search_query(search_text: str, limit: int) -> dict[str, Any] | None:
|
||||||
|
return _fetch_api_json({
|
||||||
|
'action': 'query',
|
||||||
|
'list': 'search',
|
||||||
|
'srsearch': search_text,
|
||||||
|
'srlimit': str(limit),
|
||||||
|
'format': 'json',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float_list(values: Any) -> list[float]:
|
||||||
|
if not isinstance(values, list):
|
||||||
|
return []
|
||||||
|
out: list[float] = []
|
||||||
|
for value in values:
|
||||||
|
try:
|
||||||
|
out.append(float(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _to_text_list(values: Any) -> list[str]:
|
||||||
|
if not isinstance(values, list):
|
||||||
|
return []
|
||||||
|
out: list[str] = []
|
||||||
|
for value in values:
|
||||||
|
text = str(value or '').strip()
|
||||||
|
if text:
|
||||||
|
out.append(text)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_modes(values: list[str]) -> list[str]:
|
||||||
|
out: list[str] = []
|
||||||
|
for value in values:
|
||||||
|
for token in str(value).replace('/', ',').split(','):
|
||||||
|
mode = token.strip().upper()
|
||||||
|
if mode and mode not in out:
|
||||||
|
out.append(mode)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_matches_from_ask(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
results = data.get('query', {}).get('results', {})
|
||||||
|
if not isinstance(results, dict):
|
||||||
|
return []
|
||||||
|
|
||||||
|
matches: list[dict[str, Any]] = []
|
||||||
|
for title, entry in results.items():
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
printouts = entry.get('printouts', {})
|
||||||
|
if not isinstance(printouts, dict):
|
||||||
|
printouts = {}
|
||||||
|
|
||||||
|
frequencies_hz = _to_float_list(printouts.get('Frequencies'))
|
||||||
|
frequencies_mhz = [round(v / 1e6, 6) for v in frequencies_hz if v > 0]
|
||||||
|
|
||||||
|
modes = _normalize_modes(_to_text_list(printouts.get('Mode')))
|
||||||
|
modulations = _normalize_modes(_to_text_list(printouts.get('Modulation')))
|
||||||
|
|
||||||
|
match = {
|
||||||
|
'title': str(entry.get('fulltext') or title),
|
||||||
|
'url': str(entry.get('fullurl') or ''),
|
||||||
|
'frequencies_mhz': frequencies_mhz,
|
||||||
|
'modes': modes,
|
||||||
|
'modulations': modulations,
|
||||||
|
'source': 'SigID Wiki',
|
||||||
|
}
|
||||||
|
matches.append(match)
|
||||||
|
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_matches(matches: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
deduped: dict[str, dict[str, Any]] = {}
|
||||||
|
for match in matches:
|
||||||
|
key = f"{match.get('title', '')}|{match.get('url', '')}"
|
||||||
|
if key not in deduped:
|
||||||
|
deduped[key] = match
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Merge frequencies/modes/modulations from duplicates.
|
||||||
|
existing = deduped[key]
|
||||||
|
for field in ('frequencies_mhz', 'modes', 'modulations'):
|
||||||
|
base = existing.get(field, [])
|
||||||
|
extra = match.get(field, [])
|
||||||
|
if not isinstance(base, list):
|
||||||
|
base = []
|
||||||
|
if not isinstance(extra, list):
|
||||||
|
extra = []
|
||||||
|
merged = list(base)
|
||||||
|
for item in extra:
|
||||||
|
if item not in merged:
|
||||||
|
merged.append(item)
|
||||||
|
existing[field] = merged
|
||||||
|
return list(deduped.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _rank_matches(
|
||||||
|
matches: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
frequency_mhz: float,
|
||||||
|
modulation: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
target_hz = frequency_mhz * 1e6
|
||||||
|
wanted_mod = str(modulation or '').strip().upper()
|
||||||
|
|
||||||
|
def score(match: dict[str, Any]) -> tuple[int, float, str]:
|
||||||
|
score_value = 0
|
||||||
|
freqs_mhz = match.get('frequencies_mhz') or []
|
||||||
|
distances_hz: list[float] = []
|
||||||
|
for f_mhz in freqs_mhz:
|
||||||
|
try:
|
||||||
|
distances_hz.append(abs((float(f_mhz) * 1e6) - target_hz))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
min_distance_hz = min(distances_hz) if distances_hz else 1e12
|
||||||
|
|
||||||
|
if min_distance_hz <= 100:
|
||||||
|
score_value += 120
|
||||||
|
elif min_distance_hz <= 1_000:
|
||||||
|
score_value += 90
|
||||||
|
elif min_distance_hz <= 10_000:
|
||||||
|
score_value += 70
|
||||||
|
elif min_distance_hz <= 100_000:
|
||||||
|
score_value += 40
|
||||||
|
|
||||||
|
if wanted_mod:
|
||||||
|
modes = [str(v).upper() for v in (match.get('modes') or [])]
|
||||||
|
modulations = [str(v).upper() for v in (match.get('modulations') or [])]
|
||||||
|
if wanted_mod in modes:
|
||||||
|
score_value += 25
|
||||||
|
if wanted_mod in modulations:
|
||||||
|
score_value += 25
|
||||||
|
|
||||||
|
title = str(match.get('title') or '')
|
||||||
|
title_lower = title.lower()
|
||||||
|
if 'unidentified' in title_lower or 'unknown' in title_lower:
|
||||||
|
score_value -= 10
|
||||||
|
|
||||||
|
return (score_value, min_distance_hz, title.lower())
|
||||||
|
|
||||||
|
ranked = sorted(matches, key=score, reverse=True)
|
||||||
|
for match in ranked:
|
||||||
|
try:
|
||||||
|
nearest = min(abs((float(f) * 1e6) - target_hz) for f in (match.get('frequencies_mhz') or []))
|
||||||
|
match['distance_hz'] = int(round(nearest))
|
||||||
|
except Exception:
|
||||||
|
match['distance_hz'] = None
|
||||||
|
return ranked
|
||||||
|
|
||||||
|
|
||||||
|
def _format_freq_variants_mhz(freq_mhz: float) -> list[str]:
|
||||||
|
variants = [
|
||||||
|
f'{freq_mhz:.6f}'.rstrip('0').rstrip('.'),
|
||||||
|
f'{freq_mhz:.4f}'.rstrip('0').rstrip('.'),
|
||||||
|
f'{freq_mhz:.3f}'.rstrip('0').rstrip('.'),
|
||||||
|
]
|
||||||
|
out: list[str] = []
|
||||||
|
for value in variants:
|
||||||
|
if value and value not in out:
|
||||||
|
out.append(value)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_sigidwiki_matches(frequency_mhz: float, modulation: str, limit: int) -> dict[str, Any]:
|
||||||
|
all_matches: list[dict[str, Any]] = []
|
||||||
|
exact_queries: list[str] = []
|
||||||
|
|
||||||
|
for freq_token in _format_freq_variants_mhz(frequency_mhz):
|
||||||
|
query = (
|
||||||
|
f'[[Category:Signal]][[Frequencies::{freq_token} MHz]]'
|
||||||
|
f'|?Frequencies|?Mode|?Modulation|limit={max(10, limit * 2)}'
|
||||||
|
)
|
||||||
|
exact_queries.append(query)
|
||||||
|
data = _ask_query(query)
|
||||||
|
if data:
|
||||||
|
all_matches.extend(_extract_matches_from_ask(data))
|
||||||
|
if all_matches:
|
||||||
|
break
|
||||||
|
|
||||||
|
search_used = False
|
||||||
|
if not all_matches:
|
||||||
|
search_used = True
|
||||||
|
search_terms = [f'{frequency_mhz:.4f} MHz']
|
||||||
|
if modulation:
|
||||||
|
search_terms.insert(0, f'{frequency_mhz:.4f} MHz {modulation.upper()}')
|
||||||
|
|
||||||
|
seen_titles: set[str] = set()
|
||||||
|
for term in search_terms:
|
||||||
|
search_data = _search_query(term, max(5, min(limit * 2, 10)))
|
||||||
|
search_results = search_data.get('query', {}).get('search', []) if isinstance(search_data, dict) else []
|
||||||
|
if not isinstance(search_results, list) or not search_results:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for item in search_results:
|
||||||
|
title = str(item.get('title') or '').strip()
|
||||||
|
if not title or title in seen_titles:
|
||||||
|
continue
|
||||||
|
seen_titles.add(title)
|
||||||
|
page_query = f'[[{title}]]|?Frequencies|?Mode|?Modulation|limit=1'
|
||||||
|
page_data = _ask_query(page_query)
|
||||||
|
if page_data:
|
||||||
|
all_matches.extend(_extract_matches_from_ask(page_data))
|
||||||
|
if len(all_matches) >= max(limit * 3, 12):
|
||||||
|
break
|
||||||
|
if all_matches:
|
||||||
|
break
|
||||||
|
|
||||||
|
deduped = _dedupe_matches(all_matches)
|
||||||
|
ranked = _rank_matches(deduped, frequency_mhz=frequency_mhz, modulation=modulation)
|
||||||
|
return {
|
||||||
|
'matches': ranked[:limit],
|
||||||
|
'search_used': search_used,
|
||||||
|
'exact_queries': exact_queries,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@signalid_bp.route('/sigidwiki', methods=['POST'])
|
||||||
|
def sigidwiki_lookup() -> Response:
|
||||||
|
"""Lookup likely signal types from SigID Wiki by tuned frequency."""
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
freq_raw = payload.get('frequency_mhz')
|
||||||
|
if freq_raw is None:
|
||||||
|
return jsonify({'status': 'error', 'message': 'frequency_mhz is required'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
frequency_mhz = float(freq_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid frequency_mhz'}), 400
|
||||||
|
|
||||||
|
if frequency_mhz <= 0:
|
||||||
|
return jsonify({'status': 'error', 'message': 'frequency_mhz must be positive'}), 400
|
||||||
|
|
||||||
|
modulation = str(payload.get('modulation') or '').strip().upper()
|
||||||
|
if modulation and len(modulation) > 16:
|
||||||
|
modulation = modulation[:16]
|
||||||
|
|
||||||
|
limit_raw = payload.get('limit', 8)
|
||||||
|
try:
|
||||||
|
limit = int(limit_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = 8
|
||||||
|
limit = max(1, min(limit, 20))
|
||||||
|
|
||||||
|
cache_key = f'{round(frequency_mhz, 6)}|{modulation}|{limit}'
|
||||||
|
cached = _cache_get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'source': 'sigidwiki',
|
||||||
|
'frequency_mhz': round(frequency_mhz, 6),
|
||||||
|
'modulation': modulation or None,
|
||||||
|
'cached': True,
|
||||||
|
**cached,
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
lookup = _lookup_sigidwiki_matches(frequency_mhz, modulation, limit)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('SigID lookup failed: %s', exc)
|
||||||
|
return jsonify({'status': 'error', 'message': 'SigID lookup failed'}), 502
|
||||||
|
|
||||||
|
response_payload = {
|
||||||
|
'matches': lookup.get('matches', []),
|
||||||
|
'match_count': len(lookup.get('matches', [])),
|
||||||
|
'search_used': bool(lookup.get('search_used')),
|
||||||
|
'exact_queries': lookup.get('exact_queries', []),
|
||||||
|
}
|
||||||
|
_cache_set(cache_key, response_payload)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'source': 'sigidwiki',
|
||||||
|
'frequency_mhz': round(frequency_mhz, 6),
|
||||||
|
'modulation': modulation or None,
|
||||||
|
'cached': False,
|
||||||
|
**response_payload,
|
||||||
|
})
|
||||||
|
|
||||||
+821
-386
File diff suppressed because it is too large
Load Diff
@@ -893,6 +893,92 @@ body {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-crosshair-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1200;
|
||||||
|
--crosshair-x-start: 100%;
|
||||||
|
--crosshair-y-start: 100%;
|
||||||
|
--crosshair-x-end: 50%;
|
||||||
|
--crosshair-y-end: 50%;
|
||||||
|
--crosshair-duration: 1500ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-crosshair-line {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
box-shadow: none;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-crosshair-vertical {
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
left: 0;
|
||||||
|
transform: translateX(var(--crosshair-x-start));
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-crosshair-horizontal {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
top: 0;
|
||||||
|
transform: translateY(var(--crosshair-y-start));
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-crosshair-overlay.active .map-crosshair-vertical {
|
||||||
|
animation: mapCrosshairSweepX var(--crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-crosshair-overlay.active .map-crosshair-horizontal {
|
||||||
|
animation: mapCrosshairSweepY var(--crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mapCrosshairSweepX {
|
||||||
|
0% {
|
||||||
|
transform: translateX(var(--crosshair-x-start));
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
12% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
85% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(var(--crosshair-x-end));
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mapCrosshairSweepY {
|
||||||
|
0% {
|
||||||
|
transform: translateY(var(--crosshair-y-start));
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
12% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
85% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(var(--crosshair-y-end));
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.map-crosshair-overlay.active .map-crosshair-vertical,
|
||||||
|
.map-crosshair-overlay.active .map-crosshair-horizontal {
|
||||||
|
animation-duration: 220ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Right sidebar - Mobile first */
|
/* Right sidebar - Mobile first */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1802,6 +1802,14 @@ header h1 .tagline {
|
|||||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes stop-btn-pulse {
|
||||||
|
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(239,68,68,0); }
|
||||||
|
50% { opacity: 0.75; box-shadow: 0 0 8px 2px rgba(239,68,68,0.45); }
|
||||||
|
}
|
||||||
|
.stop-btn {
|
||||||
|
animation: stop-btn-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.output-panel {
|
.output-panel {
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,500 +0,0 @@
|
|||||||
/* Analytics Dashboard Styles */
|
|
||||||
|
|
||||||
/* Analytics is a sidebar-only mode — hide the output panel and expand the sidebar */
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.main-content.analytics-active {
|
|
||||||
grid-template-columns: 1fr !important;
|
|
||||||
}
|
|
||||||
.main-content.analytics-active > .output-panel {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
.main-content.analytics-active > .sidebar {
|
|
||||||
max-width: 100% !important;
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
.main-content.analytics-active .sidebar-collapse-btn {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1023px) {
|
|
||||||
.main-content.analytics-active > .output-panel {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
|
||||||
gap: var(--space-3, 12px);
|
|
||||||
margin-bottom: var(--space-4, 16px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
|
||||||
gap: var(--space-3, 12px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card {
|
|
||||||
background: var(--bg-card, #151f2b);
|
|
||||||
border: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
border-radius: var(--radius-md, 8px);
|
|
||||||
padding: 10px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card.low {
|
|
||||||
border-color: rgba(90, 106, 122, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card.medium {
|
|
||||||
border-color: rgba(74, 163, 255, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card.high {
|
|
||||||
border-color: rgba(214, 168, 94, 0.55);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card.critical {
|
|
||||||
border-color: rgba(226, 93, 93, 0.65);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card .insight-title {
|
|
||||||
font-size: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card .insight-value {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card .insight-label {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-secondary, #9aabba);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card .insight-detail {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-top-changes {
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-change-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 7px 0;
|
|
||||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-change-row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-change-row .mode {
|
|
||||||
min-width: 84px;
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-change-row .delta {
|
|
||||||
min-width: 48px;
|
|
||||||
font-family: var(--font-mono, monospace);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-change-row .delta.up {
|
|
||||||
color: var(--accent-green, #38c180);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-change-row .delta.down {
|
|
||||||
color: var(--accent-red, #e25d5d);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-change-row .delta.flat {
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-change-row .avg {
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-card {
|
|
||||||
background: var(--bg-card, #151f2b);
|
|
||||||
border: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
border-radius: var(--radius-md, 8px);
|
|
||||||
padding: var(--space-3, 12px);
|
|
||||||
text-align: center;
|
|
||||||
transition: var(--transition-fast, 150ms ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-card:hover {
|
|
||||||
border-color: var(--accent-cyan, #4aa3ff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-card .card-count {
|
|
||||||
font-size: var(--text-2xl, 24px);
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-card .card-label {
|
|
||||||
font-size: var(--text-xs, 10px);
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin-top: var(--space-1, 4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-card .card-sparkline {
|
|
||||||
height: 24px;
|
|
||||||
margin-top: var(--space-2, 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-card .card-sparkline svg {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-card .card-sparkline polyline {
|
|
||||||
fill: none;
|
|
||||||
stroke: var(--accent-cyan, #4aa3ff);
|
|
||||||
stroke-width: 1.5;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Health indicators */
|
|
||||||
.analytics-health {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-2, 8px);
|
|
||||||
margin-bottom: var(--space-4, 16px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.health-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-1, 4px);
|
|
||||||
font-size: var(--text-xs, 10px);
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.health-dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--accent-red, #e25d5d);
|
|
||||||
}
|
|
||||||
|
|
||||||
.health-dot.running {
|
|
||||||
background: var(--accent-green, #38c180);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Emergency squawk panel */
|
|
||||||
.squawk-emergency {
|
|
||||||
background: rgba(226, 93, 93, 0.1);
|
|
||||||
border: 1px solid var(--accent-red, #e25d5d);
|
|
||||||
border-radius: var(--radius-md, 8px);
|
|
||||||
padding: var(--space-3, 12px);
|
|
||||||
margin-bottom: var(--space-3, 12px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.squawk-emergency .squawk-title {
|
|
||||||
color: var(--accent-red, #e25d5d);
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: var(--text-sm, 12px);
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-bottom: var(--space-2, 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.squawk-emergency .squawk-item {
|
|
||||||
font-size: var(--text-sm, 12px);
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
padding: var(--space-1, 4px) 0;
|
|
||||||
border-bottom: 1px solid rgba(226, 93, 93, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.squawk-emergency .squawk-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Alert feed */
|
|
||||||
.analytics-alert-feed {
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
margin-bottom: var(--space-4, 16px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-alert-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--space-2, 8px);
|
|
||||||
padding: var(--space-2, 8px);
|
|
||||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
font-size: var(--text-xs, 10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-alert-item .alert-severity {
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: var(--radius-sm, 4px);
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 9px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-severity.critical { background: var(--accent-red, #e25d5d); color: #fff; }
|
|
||||||
.alert-severity.high { background: var(--accent-orange, #d6a85e); color: #000; }
|
|
||||||
.alert-severity.medium { background: var(--accent-cyan, #4aa3ff); color: #fff; }
|
|
||||||
.alert-severity.low { background: var(--border-color, #1e2d3d); color: var(--text-dim, #5a6a7a); }
|
|
||||||
|
|
||||||
/* Correlation panel */
|
|
||||||
.analytics-correlation-pair {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2, 8px);
|
|
||||||
padding: var(--space-2, 8px);
|
|
||||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
font-size: var(--text-xs, 10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-correlation-pair .confidence-bar {
|
|
||||||
height: 4px;
|
|
||||||
background: var(--bg-secondary, #101823);
|
|
||||||
border-radius: 2px;
|
|
||||||
flex: 1;
|
|
||||||
max-width: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-correlation-pair .confidence-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: var(--accent-green, #38c180);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-pattern-item {
|
|
||||||
padding: 8px;
|
|
||||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-pattern-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-pattern-item .pattern-main {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-pattern-item .pattern-mode {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-pattern-item .pattern-device {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
font-family: var(--font-mono, monospace);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-pattern-item .pattern-meta {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-pattern-item .pattern-confidence {
|
|
||||||
color: var(--accent-green, #38c180);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Geofence zone list */
|
|
||||||
.geofence-zone-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: var(--space-2, 8px);
|
|
||||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
font-size: var(--text-xs, 10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.geofence-zone-item .zone-name {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
}
|
|
||||||
|
|
||||||
.geofence-zone-item .zone-radius {
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.geofence-zone-item .zone-delete {
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--accent-red, #e25d5d);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border: 1px solid var(--accent-red, #e25d5d);
|
|
||||||
border-radius: var(--radius-sm, 4px);
|
|
||||||
background: transparent;
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Export controls */
|
|
||||||
.export-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-2, 8px);
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-controls select,
|
|
||||||
.export-controls button {
|
|
||||||
font-size: var(--text-xs, 10px);
|
|
||||||
padding: var(--space-1, 4px) var(--space-2, 8px);
|
|
||||||
background: var(--bg-card, #151f2b);
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
border: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
border-radius: var(--radius-sm, 4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-controls button {
|
|
||||||
cursor: pointer;
|
|
||||||
background: var(--accent-cyan, #4aa3ff);
|
|
||||||
color: #fff;
|
|
||||||
border-color: var(--accent-cyan, #4aa3ff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-controls button:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section headers */
|
|
||||||
.analytics-section-header {
|
|
||||||
font-size: var(--text-xs, 10px);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin-bottom: var(--space-2, 8px);
|
|
||||||
padding-bottom: var(--space-1, 4px);
|
|
||||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Empty state */
|
|
||||||
.analytics-empty {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
font-size: var(--text-xs, 10px);
|
|
||||||
padding: var(--space-4, 16px);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-toolbar,
|
|
||||||
.analytics-replay-toolbar {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-toolbar input {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 220px;
|
|
||||||
background: var(--bg-card, #151f2b);
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
border: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-toolbar button,
|
|
||||||
.analytics-replay-toolbar button,
|
|
||||||
.analytics-replay-toolbar select {
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 5px 9px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
background: var(--bg-card, #151f2b);
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-toolbar button,
|
|
||||||
.analytics-replay-toolbar button {
|
|
||||||
cursor: pointer;
|
|
||||||
background: rgba(74, 163, 255, 0.2);
|
|
||||||
border-color: rgba(74, 163, 255, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-summary {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-item,
|
|
||||||
.analytics-replay-item {
|
|
||||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
padding: 7px 0;
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-item:last-child,
|
|
||||||
.analytics-replay-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-item .title,
|
|
||||||
.analytics-replay-item .title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-item .mode,
|
|
||||||
.analytics-replay-item .mode {
|
|
||||||
font-size: 9px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
border: 1px solid rgba(74, 163, 255, 0.35);
|
|
||||||
color: var(--accent-cyan, #4aa3ff);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-item .meta,
|
|
||||||
.analytics-replay-item .meta {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
+224
-156
@@ -163,29 +163,29 @@
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btl-hud-controls {
|
.btl-hud-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btl-hud-export-row {
|
.btl-hud-export-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btl-hud-export-format {
|
.btl-hud-export-format {
|
||||||
min-width: 62px;
|
min-width: 62px;
|
||||||
padding: 3px 6px;
|
padding: 3px 6px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: rgba(0, 0, 0, 0.45);
|
background: rgba(0, 0, 0, 0.45);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btl-hud-audio-toggle {
|
.btl-hud-audio-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -266,112 +266,114 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
height: 100%;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btl-map-container {
|
.btl-map-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 250px;
|
min-height: 250px;
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#btLocateMap {
|
#btLocateMap {
|
||||||
width: 100%;
|
position: absolute;
|
||||||
height: 100%;
|
inset: 0;
|
||||||
background: #1a1a2e;
|
background: #1a1a2e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btl-map-overlay-controls {
|
.btl-map-overlay-controls {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
z-index: 450;
|
z-index: 450;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 7px 8px;
|
padding: 7px 8px;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btl-map-overlay-toggle {
|
.btl-map-overlay-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btl-map-overlay-toggle input[type="checkbox"] {
|
.btl-map-overlay-toggle input[type="checkbox"] {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btl-map-overlay-toggle input[type="checkbox"]:disabled + span {
|
.btl-map-overlay-toggle input[type="checkbox"]:disabled + span {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btl-map-heat-legend {
|
.btl-map-heat-legend {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 10px;
|
left: 10px;
|
||||||
bottom: 10px;
|
bottom: 10px;
|
||||||
z-index: 430;
|
z-index: 430;
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btl-map-heat-label {
|
.btl-map-heat-label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.7px;
|
letter-spacing: 0.7px;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btl-map-heat-bar {
|
.btl-map-heat-bar {
|
||||||
height: 7px;
|
height: 7px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: linear-gradient(90deg, #2563eb 0%, #16a34a 40%, #f59e0b 70%, #ef4444 100%);
|
background: linear-gradient(90deg, #2563eb 0%, #16a34a 40%, #f59e0b 70%, #ef4444 100%);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btl-map-heat-scale {
|
.btl-map-heat-scale {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btl-map-track-stats {
|
.btl-map-track-stats {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
bottom: 10px;
|
bottom: 10px;
|
||||||
z-index: 430;
|
z-index: 430;
|
||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btl-rssi-chart-container {
|
.btl-rssi-chart-container {
|
||||||
height: 100px;
|
height: 100px;
|
||||||
@@ -511,7 +513,7 @@
|
|||||||
RESPONSIVE — stack HUD vertically on narrow
|
RESPONSIVE — stack HUD vertically on narrow
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.btl-hud {
|
.btl-hud {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -528,33 +530,99 @@
|
|||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btl-hud-controls {
|
.btl-hud-controls {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btl-hud-export-row {
|
.btl-hud-export-row {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btl-map-overlay-controls {
|
.btl-map-overlay-controls {
|
||||||
top: 8px;
|
top: 8px;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
padding: 6px 7px;
|
padding: 6px 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btl-map-heat-legend {
|
.btl-map-heat-legend {
|
||||||
left: 8px;
|
left: 8px;
|
||||||
bottom: 8px;
|
bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btl-map-track-stats {
|
.btl-map-track-stats {
|
||||||
right: 8px;
|
right: 8px;
|
||||||
bottom: 8px;
|
bottom: 8px;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Crosshair sweep animation ───────────────────────────────────── */
|
||||||
|
.btl-crosshair-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1200;
|
||||||
|
--btl-crosshair-x-start: 100%;
|
||||||
|
--btl-crosshair-y-start: 100%;
|
||||||
|
--btl-crosshair-x-end: 50%;
|
||||||
|
--btl-crosshair-y-end: 50%;
|
||||||
|
--btl-crosshair-duration: 1500ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-crosshair-line {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-crosshair-vertical {
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
left: 0;
|
||||||
|
transform: translateX(var(--btl-crosshair-x-start));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-crosshair-horizontal {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
top: 0;
|
||||||
|
transform: translateY(var(--btl-crosshair-y-start));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-crosshair-overlay.active .btl-crosshair-vertical {
|
||||||
|
animation: btlCrosshairSweepX var(--btl-crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-crosshair-overlay.active .btl-crosshair-horizontal {
|
||||||
|
animation: btlCrosshairSweepY var(--btl-crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes btlCrosshairSweepX {
|
||||||
|
0% { transform: translateX(var(--btl-crosshair-x-start)); opacity: 0; }
|
||||||
|
12% { opacity: 1; }
|
||||||
|
85% { opacity: 1; }
|
||||||
|
100% { transform: translateX(var(--btl-crosshair-x-end)); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes btlCrosshairSweepY {
|
||||||
|
0% { transform: translateY(var(--btl-crosshair-y-start)); opacity: 0; }
|
||||||
|
12% { opacity: 1; }
|
||||||
|
85% { opacity: 1; }
|
||||||
|
100% { transform: translateY(var(--btl-crosshair-y-end)); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.btl-crosshair-overlay.active .btl-crosshair-vertical,
|
||||||
|
.btl-crosshair-overlay.active .btl-crosshair-horizontal {
|
||||||
|
animation-duration: 220ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+61
-10
@@ -139,16 +139,67 @@
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gps-skyview-canvas-wrap {
|
.gps-skyview-canvas-wrap {
|
||||||
display: flex;
|
position: relative;
|
||||||
justify-content: center;
|
display: block;
|
||||||
align-items: center;
|
width: min(100%, 430px);
|
||||||
}
|
aspect-ratio: 1 / 1;
|
||||||
|
margin: 0 auto;
|
||||||
#gpsSkyCanvas {
|
border: 1px solid var(--border-color);
|
||||||
max-width: 100%;
|
border-radius: 6px;
|
||||||
height: auto;
|
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 */
|
/* Position info panel */
|
||||||
.gps-position-panel {
|
.gps-position-panel {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 919 B |
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<!-- Background -->
|
||||||
|
<rect width="100" height="100" fill="#0a0a0f"/>
|
||||||
|
|
||||||
|
<!-- Signal brackets - left side -->
|
||||||
|
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||||
|
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||||
|
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Signal brackets - right side -->
|
||||||
|
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||||
|
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||||
|
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- The 'i' letter -->
|
||||||
|
<circle cx="50" cy="22" r="7" fill="#00ff88"/>
|
||||||
|
<rect x="43" y="35" width="14" height="45" rx="2" fill="#00d4ff"/>
|
||||||
|
<rect x="36" y="35" width="28" height="5" rx="1" fill="#00d4ff"/>
|
||||||
|
<rect x="36" y="75" width="28" height="5" rx="1" fill="#00d4ff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Activity Timeline Component
|
* Activity Timeline Component
|
||||||
* Reusable, configuration-driven timeline visualization for time-based metadata
|
* Reusable, configuration-driven timeline visualization for time-based metadata
|
||||||
* Supports multiple modes: TSCM, Listening Post, Bluetooth, WiFi, Monitoring
|
* Supports multiple modes: TSCM, RF Receiver, Bluetooth, WiFi, Monitoring
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ActivityTimeline = (function() {
|
const ActivityTimeline = (function() {
|
||||||
@@ -176,7 +176,7 @@ const ActivityTimeline = (function() {
|
|||||||
*/
|
*/
|
||||||
function categorizeById(id, mode) {
|
function categorizeById(id, mode) {
|
||||||
// RF frequency categorization
|
// RF frequency categorization
|
||||||
if (mode === 'rf' || mode === 'tscm' || mode === 'listening-post') {
|
if (mode === 'rf' || mode === 'tscm' || mode === 'waterfall') {
|
||||||
const f = parseFloat(id);
|
const f = parseFloat(id);
|
||||||
if (!isNaN(f)) {
|
if (!isNaN(f)) {
|
||||||
if (f >= 2400 && f <= 2500) return '2.4 GHz wireless band';
|
if (f >= 2400 && f <= 2500) return '2.4 GHz wireless band';
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* RF Signal Timeline Adapter
|
* RF Signal Timeline Adapter
|
||||||
* Normalizes RF signal data for the Activity Timeline component
|
* Normalizes RF signal data for the Activity Timeline component
|
||||||
* Used by: Listening Post, TSCM
|
* Used by: Spectrum Waterfall, TSCM
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const RFTimelineAdapter = (function() {
|
const RFTimelineAdapter = (function() {
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -157,16 +157,16 @@ const RFTimelineAdapter = (function() {
|
|||||||
return signals.map(normalizer);
|
return signals.map(normalizer);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create timeline configuration for Listening Post mode
|
* Create timeline configuration for spectrum waterfall mode.
|
||||||
*/
|
*/
|
||||||
function getListeningPostConfig() {
|
function getWaterfallConfig() {
|
||||||
return {
|
return {
|
||||||
title: 'Signal Activity',
|
title: 'Spectrum Activity',
|
||||||
mode: 'listening-post',
|
mode: 'waterfall',
|
||||||
visualMode: 'enriched',
|
visualMode: 'enriched',
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
showAnnotations: true,
|
showAnnotations: true,
|
||||||
showLegend: true,
|
showLegend: true,
|
||||||
defaultWindow: '15m',
|
defaultWindow: '15m',
|
||||||
availableWindows: ['5m', '15m', '30m', '1h'],
|
availableWindows: ['5m', '15m', '30m', '1h'],
|
||||||
@@ -184,9 +184,14 @@ const RFTimelineAdapter = (function() {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
maxItems: 50,
|
maxItems: 50,
|
||||||
maxDisplayedLanes: 12
|
maxDisplayedLanes: 12
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backward compatibility alias for legacy callers.
|
||||||
|
function getListeningPostConfig() {
|
||||||
|
return getWaterfallConfig();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create timeline configuration for TSCM mode
|
* Create timeline configuration for TSCM mode
|
||||||
@@ -224,8 +229,9 @@ const RFTimelineAdapter = (function() {
|
|||||||
categorizeFrequency: categorizeFrequency,
|
categorizeFrequency: categorizeFrequency,
|
||||||
|
|
||||||
// Configuration presets
|
// Configuration presets
|
||||||
getListeningPostConfig: getListeningPostConfig,
|
getWaterfallConfig: getWaterfallConfig,
|
||||||
getTscmConfig: getTscmConfig,
|
getListeningPostConfig: getListeningPostConfig,
|
||||||
|
getTscmConfig: getTscmConfig,
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
RSSI_THRESHOLDS: RSSI_THRESHOLDS,
|
RSSI_THRESHOLDS: RSSI_THRESHOLDS,
|
||||||
|
|||||||
+4
-13
@@ -98,7 +98,7 @@ function switchMode(mode) {
|
|||||||
const modeMap = {
|
const modeMap = {
|
||||||
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
|
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
|
||||||
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
|
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
|
||||||
'listening': 'listening', 'meshtastic': 'meshtastic'
|
'meshtastic': 'meshtastic'
|
||||||
};
|
};
|
||||||
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
||||||
const label = btn.querySelector('.nav-label');
|
const label = btn.querySelector('.nav-label');
|
||||||
@@ -114,7 +114,6 @@ function switchMode(mode) {
|
|||||||
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
|
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
|
||||||
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
|
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
|
||||||
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
|
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
|
||||||
document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
|
|
||||||
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
||||||
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
||||||
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
|
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
|
||||||
@@ -143,7 +142,6 @@ function switchMode(mode) {
|
|||||||
'satellite': 'SATELLITE',
|
'satellite': 'SATELLITE',
|
||||||
'wifi': 'WIFI',
|
'wifi': 'WIFI',
|
||||||
'bluetooth': 'BLUETOOTH',
|
'bluetooth': 'BLUETOOTH',
|
||||||
'listening': 'LISTENING POST',
|
|
||||||
'tscm': 'TSCM',
|
'tscm': 'TSCM',
|
||||||
'aprs': 'APRS',
|
'aprs': 'APRS',
|
||||||
'meshtastic': 'MESHTASTIC'
|
'meshtastic': 'MESHTASTIC'
|
||||||
@@ -166,7 +164,6 @@ function switchMode(mode) {
|
|||||||
const showRadar = document.getElementById('adsbEnableMap')?.checked;
|
const showRadar = document.getElementById('adsbEnableMap')?.checked;
|
||||||
document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
|
document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
|
||||||
document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
|
document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
|
||||||
document.getElementById('listeningPostVisuals').style.display = mode === 'listening' ? 'grid' : 'none';
|
|
||||||
|
|
||||||
// Update output panel title based on mode
|
// Update output panel title based on mode
|
||||||
const titles = {
|
const titles = {
|
||||||
@@ -176,7 +173,6 @@ function switchMode(mode) {
|
|||||||
'satellite': 'Satellite Monitor',
|
'satellite': 'Satellite Monitor',
|
||||||
'wifi': 'WiFi Scanner',
|
'wifi': 'WiFi Scanner',
|
||||||
'bluetooth': 'Bluetooth Scanner',
|
'bluetooth': 'Bluetooth Scanner',
|
||||||
'listening': 'Listening Post',
|
|
||||||
'meshtastic': 'Meshtastic Mesh Monitor'
|
'meshtastic': 'Meshtastic Mesh Monitor'
|
||||||
};
|
};
|
||||||
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
|
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
|
||||||
@@ -184,7 +180,7 @@ function switchMode(mode) {
|
|||||||
// Show/hide Device Intelligence for modes that use it
|
// Show/hide Device Intelligence for modes that use it
|
||||||
const reconBtn = document.getElementById('reconBtn');
|
const reconBtn = document.getElementById('reconBtn');
|
||||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||||
if (mode === 'satellite' || mode === 'aircraft' || mode === 'listening') {
|
if (mode === 'satellite' || mode === 'aircraft') {
|
||||||
document.getElementById('reconPanel').style.display = 'none';
|
document.getElementById('reconPanel').style.display = 'none';
|
||||||
if (reconBtn) reconBtn.style.display = 'none';
|
if (reconBtn) reconBtn.style.display = 'none';
|
||||||
if (intelBtn) intelBtn.style.display = 'none';
|
if (intelBtn) intelBtn.style.display = 'none';
|
||||||
@@ -198,7 +194,7 @@ function switchMode(mode) {
|
|||||||
|
|
||||||
// Show RTL-SDR device section for modes that use it
|
// Show RTL-SDR device section for modes that use it
|
||||||
document.getElementById('rtlDeviceSection').style.display =
|
document.getElementById('rtlDeviceSection').style.display =
|
||||||
(mode === 'pager' || mode === 'sensor' || mode === 'aircraft' || mode === 'listening') ? 'block' : 'none';
|
(mode === 'pager' || mode === 'sensor' || mode === 'aircraft') ? 'block' : 'none';
|
||||||
|
|
||||||
// Toggle mode-specific tool status displays
|
// Toggle mode-specific tool status displays
|
||||||
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
|
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
|
||||||
@@ -207,7 +203,7 @@ function switchMode(mode) {
|
|||||||
|
|
||||||
// Hide waterfall and output console for modes with their own visualizations
|
// Hide waterfall and output console for modes with their own visualizations
|
||||||
document.querySelector('.waterfall-container').style.display =
|
document.querySelector('.waterfall-container').style.display =
|
||||||
(mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||||
document.getElementById('output').style.display =
|
document.getElementById('output').style.display =
|
||||||
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||||
document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex';
|
document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex';
|
||||||
@@ -226,11 +222,6 @@ function switchMode(mode) {
|
|||||||
} else if (mode === 'satellite') {
|
} else if (mode === 'satellite') {
|
||||||
if (typeof initPolarPlot === 'function') initPolarPlot();
|
if (typeof initPolarPlot === 'function') initPolarPlot();
|
||||||
if (typeof initSatelliteList === 'function') initSatelliteList();
|
if (typeof initSatelliteList === 'function') initSatelliteList();
|
||||||
} else if (mode === 'listening') {
|
|
||||||
if (typeof checkScannerTools === 'function') checkScannerTools();
|
|
||||||
if (typeof checkAudioTools === 'function') checkAudioTools();
|
|
||||||
if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect();
|
|
||||||
if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect();
|
|
||||||
} else if (mode === 'meshtastic') {
|
} else if (mode === 'meshtastic') {
|
||||||
if (typeof Meshtastic !== 'undefined' && Meshtastic.init) Meshtastic.init();
|
if (typeof Meshtastic !== 'undefined' && Meshtastic.init) Meshtastic.init();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/* INTERCEPT Per-Mode Cheat Sheets */
|
||||||
|
const CheatSheets = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const CONTENT = {
|
||||||
|
pager: { title: 'Pager Decoder', icon: '📟', hardware: 'RTL-SDR dongle', description: 'Decodes POCSAG and FLEX pager protocols via rtl_fm + multimon-ng.', whatToExpect: 'Numeric and alphanumeric pager messages with address codes.', tips: ['Try frequencies 152.240, 157.450, 462.9625 MHz', 'Gain 38–45 dB works well for most dongles', 'POCSAG 512/1200/2400 baud are common'] },
|
||||||
|
sensor: { title: '433MHz Sensors', icon: '🌡️', hardware: 'RTL-SDR dongle', description: 'Decodes 433MHz IoT sensors via rtl_433.', whatToExpect: 'JSON events from weather stations, door sensors, car key fobs.', tips: ['Leave gain on AUTO', 'Walk around to discover hidden sensors', 'Protocol filter narrows false positives'] },
|
||||||
|
wifi: { title: 'WiFi Scanner', icon: '📡', hardware: 'WiFi adapter (monitor mode)', description: 'Scans WiFi networks and clients via airodump-ng or nmcli.', whatToExpect: 'SSIDs, BSSIDs, channel, signal strength, encryption type.', tips: ['Run airmon-ng check kill before monitoring', 'Proximity radar shows signal strength', 'TSCM baseline detects rogue APs'] },
|
||||||
|
bluetooth: { title: 'Bluetooth Scanner', icon: '🔵', hardware: 'Built-in or USB Bluetooth adapter', description: 'Scans BLE and classic Bluetooth devices. Identifies trackers.', whatToExpect: 'Device names, MACs, RSSI, manufacturer, tracker type.', tips: ['Proximity radar shows device distance', 'Known tracker DB has 47K+ fingerprints', 'Use BT Locate to physically find a tracker'] },
|
||||||
|
bt_locate: { title: 'BT Locate (SAR)', icon: '🎯', hardware: 'Bluetooth adapter + optional GPS', description: 'SAR Bluetooth locator. Tracks RSSI over time to triangulate position.', whatToExpect: 'RSSI chart, proximity band (IMMEDIATE/NEAR/FAR), GPS trail.', tips: ['Handoff from Bluetooth mode to lock onto a device', 'Indoor n=3.0 gives better distance estimates', 'Follow the heat trail toward stronger signal'] },
|
||||||
|
meshtastic: { title: 'Meshtastic', icon: '🕸️', hardware: 'Meshtastic LoRa node (USB)', description: 'Monitors Meshtastic LoRa mesh network messages and positions.', whatToExpect: 'Text messages, node map, telemetry.', tips: ['Default channel must match your mesh', 'Long-Fast has best range', 'GPS nodes appear on map automatically'] },
|
||||||
|
adsb: { title: 'ADS-B Aircraft', icon: '✈️', hardware: 'RTL-SDR + 1090MHz antenna', description: 'Tracks aircraft via ADS-B Mode S transponders using dump1090.', whatToExpect: 'Flight numbers, positions, altitude, speed, squawk codes.', tips: ['1090MHz — use a dedicated antenna', 'Emergency squawks: 7500 hijack, 7600 radio fail, 7700 emergency', 'Full Dashboard shows map view'] },
|
||||||
|
ais: { title: 'AIS Vessels', icon: '🚢', hardware: 'RTL-SDR + VHF antenna (162 MHz)', description: 'Tracks marine vessels via AIS using AIS-catcher.', whatToExpect: 'MMSI, vessel names, positions, speed, heading, cargo type.', tips: ['VHF antenna centered at 162MHz works best', 'DSC distress alerts appear in red', 'Coastline range ~40 nautical miles'] },
|
||||||
|
aprs: { title: 'APRS', icon: '📻', hardware: 'RTL-SDR + VHF + direwolf', description: 'Decodes APRS amateur packet radio via direwolf TNC modem.', whatToExpect: 'Station positions, weather reports, messages, telemetry.', tips: ['Primary APRS frequency: 144.390 MHz (North America)', 'direwolf must be running', 'Positions appear on the map'] },
|
||||||
|
satellite: { title: 'Satellite Tracker', icon: '🛰️', hardware: 'None (pass prediction only)', description: 'Predicts satellite pass times using TLE data from CelesTrak.', whatToExpect: 'Pass windows with AOS/LOS times, max elevation, bearing.', tips: ['Set observer location in Settings', 'Plan ISS SSTV using pass times', 'TLEs auto-update every 24 hours'] },
|
||||||
|
sstv: { title: 'ISS SSTV', icon: '🖼️', hardware: 'RTL-SDR + 145MHz antenna', description: 'Receives ISS SSTV images via slowrx.', whatToExpect: 'Color images during ISS SSTV events (PD180 mode).', tips: ['ISS SSTV: 145.800 MHz', 'Check ARISS for active event dates', 'ISS must be overhead — check pass times'] },
|
||||||
|
weathersat: { title: 'Weather Satellites', icon: '🌤️', hardware: 'RTL-SDR + 137MHz turnstile/QFH antenna', description: 'Decodes NOAA APT and Meteor LRPT weather imagery via SatDump.', whatToExpect: 'Infrared/visible cloud imagery.', tips: ['NOAA 15/18/19: 137.1–137.9 MHz APT', 'Meteor M2-3: 137.9 MHz LRPT', 'Use circular polarized antenna (QFH or turnstile)'] },
|
||||||
|
sstv_general:{ title: 'HF SSTV', icon: '📷', hardware: 'RTL-SDR + HF upconverter', description: 'Receives HF SSTV transmissions.', whatToExpect: 'Amateur radio images on 14.230 MHz (USB mode).', tips: ['14.230 MHz USB is primary HF SSTV frequency', 'Scottie 1 and Martin 1 most common', 'Best during daylight hours'] },
|
||||||
|
gps: { title: 'GPS Receiver', icon: '🗺️', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction', 'Verify a 3D fix before relying on altitude'] },
|
||||||
|
spaceweather:{ title: 'Space Weather', icon: '☀️', hardware: 'None (NOAA/SpaceWeatherLive data)', description: 'Monitors solar activity and geomagnetic storm indices.', whatToExpect: 'Kp index, solar flux, X-ray flare alerts, CME tracking.', tips: ['High Kp (≥5) = geomagnetic storm', 'X-class flares cause HF radio blackouts', 'Check before HF or satellite operations'] },
|
||||||
|
tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] },
|
||||||
|
spystations: { title: 'Spy Stations', icon: '🕵️', hardware: 'RTL-SDR + HF antenna', description: 'Database of known number stations, military, and diplomatic HF signals.', whatToExpect: 'Scheduled broadcasts, frequency database, tune-to links.', tips: ['Numbers stations often broadcast on the hour', 'Use Spectrum Waterfall to tune directly', 'STANAG and HF mil signals are common'] },
|
||||||
|
websdr: { title: 'WebSDR', icon: '🌐', hardware: 'None (uses remote SDR servers)', description: 'Access remote WebSDR receivers worldwide for HF shortwave listening.', whatToExpect: 'Live audio from global HF receivers, waterfall display.', tips: ['websdr.org lists available servers', 'Good for HF when local antenna is lacking', 'Use in-app player for seamless experience'] },
|
||||||
|
subghz: { title: 'SubGHz Transceiver', icon: '📡', hardware: 'HackRF One', description: 'Transmit and receive sub-GHz RF signals for IoT and industrial protocols.', whatToExpect: 'Raw signal capture, replay, and protocol analysis.', tips: ['Only use on licensed frequencies', 'Capture mode records raw IQ for replay', 'Common: garage doors, keyfobs, 315/433/868/915 MHz'] },
|
||||||
|
rtlamr: { title: 'Utility Meter Reader', icon: '⚡', hardware: 'RTL-SDR dongle', description: 'Reads AMI/AMR smart utility meter broadcasts via rtlamr.', whatToExpect: 'Meter IDs, consumption readings, interval data.', tips: ['Most meters broadcast on 915 MHz', 'MSG types 5, 7, 13, 21 most common', 'Consumption data is read-only public broadcast'] },
|
||||||
|
waterfall: { title: 'Spectrum Waterfall', icon: '🌊', hardware: 'RTL-SDR or HackRF (WebSocket)', description: 'Full-screen real-time FFT spectrum waterfall display.', whatToExpect: 'Color-coded signal intensity scrolling over time.', tips: ['Turbo palette has best contrast for weak signals', 'Peak hold shows max power in red', 'Hover over waterfall to see frequency'] },
|
||||||
|
};
|
||||||
|
|
||||||
|
function show(mode) {
|
||||||
|
const data = CONTENT[mode];
|
||||||
|
const modal = document.getElementById('cheatSheetModal');
|
||||||
|
const content = document.getElementById('cheatSheetContent');
|
||||||
|
if (!modal || !content) return;
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
content.innerHTML = `<p style="color:var(--text-dim); font-family:var(--font-mono);">No cheat sheet for: ${mode}</p>`;
|
||||||
|
} else {
|
||||||
|
content.innerHTML = `
|
||||||
|
<div style="font-family:var(--font-mono, monospace);">
|
||||||
|
<div style="font-size:24px; margin-bottom:4px;">${data.icon}</div>
|
||||||
|
<h2 style="margin:0 0 8px; font-size:16px; color:var(--accent-cyan, #4aa3ff);">${data.title}</h2>
|
||||||
|
<div style="font-size:11px; color:var(--text-dim); margin-bottom:12px; border-bottom:1px solid rgba(255,255,255,0.08); padding-bottom:8px;">
|
||||||
|
Hardware: <span style="color:var(--text-secondary);">${data.hardware}</span>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:12px; color:var(--text-secondary); margin:0 0 12px;">${data.description}</p>
|
||||||
|
<div style="margin-bottom:12px;">
|
||||||
|
<div style="font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.08em; color:var(--text-dim); margin-bottom:4px;">What to expect</div>
|
||||||
|
<p style="font-size:12px; color:var(--text-secondary); margin:0;">${data.whatToExpect}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.08em; color:var(--text-dim); margin-bottom:6px;">Tips</div>
|
||||||
|
<ul style="margin:0; padding-left:16px; display:flex; flex-direction:column; gap:4px;">
|
||||||
|
${data.tips.map(t => `<li style="font-size:11px; color:var(--text-secondary);">${t}</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
const modal = document.getElementById('cheatSheetModal');
|
||||||
|
if (modal) modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showForCurrentMode() {
|
||||||
|
const mode = document.body.getAttribute('data-mode');
|
||||||
|
if (mode) show(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { show, hide, showForCurrentMode };
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.CheatSheets = CheatSheets;
|
||||||
@@ -12,8 +12,8 @@ const CommandPalette = (function() {
|
|||||||
{ mode: 'pager', label: 'Pager' },
|
{ mode: 'pager', label: 'Pager' },
|
||||||
{ mode: 'sensor', label: '433MHz Sensors' },
|
{ mode: 'sensor', label: '433MHz Sensors' },
|
||||||
{ mode: 'rtlamr', label: 'Meters' },
|
{ mode: 'rtlamr', label: 'Meters' },
|
||||||
{ mode: 'listening', label: 'Listening Post' },
|
|
||||||
{ mode: 'subghz', label: 'SubGHz' },
|
{ mode: 'subghz', label: 'SubGHz' },
|
||||||
|
{ mode: 'waterfall', label: 'Spectrum Waterfall' },
|
||||||
{ mode: 'aprs', label: 'APRS' },
|
{ mode: 'aprs', label: 'APRS' },
|
||||||
{ mode: 'wifi', label: 'WiFi Scanner' },
|
{ mode: 'wifi', label: 'WiFi Scanner' },
|
||||||
{ mode: 'bluetooth', label: 'Bluetooth Scanner' },
|
{ mode: 'bluetooth', label: 'Bluetooth Scanner' },
|
||||||
@@ -25,7 +25,6 @@ const CommandPalette = (function() {
|
|||||||
{ mode: 'gps', label: 'GPS' },
|
{ mode: 'gps', label: 'GPS' },
|
||||||
{ mode: 'meshtastic', label: 'Meshtastic' },
|
{ mode: 'meshtastic', label: 'Meshtastic' },
|
||||||
{ mode: 'websdr', label: 'WebSDR' },
|
{ mode: 'websdr', label: 'WebSDR' },
|
||||||
{ mode: 'analytics', label: 'Analytics' },
|
|
||||||
{ mode: 'spaceweather', label: 'Space Weather' },
|
{ mode: 'spaceweather', label: 'Space Weather' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -188,13 +187,39 @@ const CommandPalette = (function() {
|
|||||||
title: 'View Aircraft Dashboard',
|
title: 'View Aircraft Dashboard',
|
||||||
description: 'Open dedicated ADS-B dashboard page',
|
description: 'Open dedicated ADS-B dashboard page',
|
||||||
keyword: 'aircraft adsb dashboard',
|
keyword: 'aircraft adsb dashboard',
|
||||||
run: () => { window.location.href = '/adsb/dashboard'; }
|
run: () => {
|
||||||
|
if (window.InterceptNavPerf && typeof window.InterceptNavPerf.markStart === 'function') {
|
||||||
|
window.InterceptNavPerf.markStart({
|
||||||
|
targetPath: '/adsb/dashboard',
|
||||||
|
trigger: 'command-palette',
|
||||||
|
sourceMode: (typeof currentMode === 'string' && currentMode) ? currentMode : null,
|
||||||
|
activeScans: (typeof getActiveScanSummary === 'function') ? getActiveScanSummary() : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof stopActiveLocalScansForNavigation === 'function') {
|
||||||
|
stopActiveLocalScansForNavigation();
|
||||||
|
}
|
||||||
|
window.location.href = '/adsb/dashboard';
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'View Vessel Dashboard',
|
title: 'View Vessel Dashboard',
|
||||||
description: 'Open dedicated AIS dashboard page',
|
description: 'Open dedicated AIS dashboard page',
|
||||||
keyword: 'vessel ais dashboard',
|
keyword: 'vessel ais dashboard',
|
||||||
run: () => { window.location.href = '/ais/dashboard'; }
|
run: () => {
|
||||||
|
if (window.InterceptNavPerf && typeof window.InterceptNavPerf.markStart === 'function') {
|
||||||
|
window.InterceptNavPerf.markStart({
|
||||||
|
targetPath: '/ais/dashboard',
|
||||||
|
trigger: 'command-palette',
|
||||||
|
sourceMode: (typeof currentMode === 'string' && currentMode) ? currentMode : null,
|
||||||
|
activeScans: (typeof getActiveScanSummary === 'function') ? getActiveScanSummary() : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof stopActiveLocalScansForNavigation === 'function') {
|
||||||
|
stopActiveLocalScansForNavigation();
|
||||||
|
}
|
||||||
|
window.location.href = '/ais/dashboard';
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Kill All Running Processes',
|
title: 'Kill All Running Processes',
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ const FirstRunSetup = (function() {
|
|||||||
['pager', 'Pager'],
|
['pager', 'Pager'],
|
||||||
['sensor', '433MHz'],
|
['sensor', '433MHz'],
|
||||||
['rtlamr', 'Meters'],
|
['rtlamr', 'Meters'],
|
||||||
['listening', 'Listening Post'],
|
['waterfall', 'Waterfall'],
|
||||||
['wifi', 'WiFi'],
|
['wifi', 'WiFi'],
|
||||||
['bluetooth', 'Bluetooth'],
|
['bluetooth', 'Bluetooth'],
|
||||||
['bt_locate', 'BT Locate'],
|
['bt_locate', 'BT Locate'],
|
||||||
@@ -139,7 +139,6 @@ const FirstRunSetup = (function() {
|
|||||||
['sstv', 'ISS SSTV'],
|
['sstv', 'ISS SSTV'],
|
||||||
['weathersat', 'Weather Sat'],
|
['weathersat', 'Weather Sat'],
|
||||||
['sstv_general', 'HF SSTV'],
|
['sstv_general', 'HF SSTV'],
|
||||||
['analytics', 'Analytics'],
|
|
||||||
];
|
];
|
||||||
for (const [value, label] of modes) {
|
for (const [value, label] of modes) {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
@@ -150,7 +149,11 @@ const FirstRunSetup = (function() {
|
|||||||
|
|
||||||
const savedDefaultMode = localStorage.getItem(DEFAULT_MODE_KEY);
|
const savedDefaultMode = localStorage.getItem(DEFAULT_MODE_KEY);
|
||||||
if (savedDefaultMode) {
|
if (savedDefaultMode) {
|
||||||
modeSelectEl.value = savedDefaultMode;
|
const normalizedMode = savedDefaultMode === 'listening' ? 'waterfall' : savedDefaultMode;
|
||||||
|
modeSelectEl.value = normalizedMode;
|
||||||
|
if (normalizedMode !== savedDefaultMode) {
|
||||||
|
localStorage.setItem(DEFAULT_MODE_KEY, normalizedMode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actionsEl.appendChild(modeSelectEl);
|
actionsEl.appendChild(modeSelectEl);
|
||||||
|
|||||||
@@ -18,6 +18,18 @@
|
|||||||
if (menuLink) {
|
if (menuLink) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
try {
|
||||||
|
const target = new URL(menuLink.href, window.location.href);
|
||||||
|
if (window.InterceptNavPerf && typeof window.InterceptNavPerf.markStart === 'function') {
|
||||||
|
window.InterceptNavPerf.markStart({
|
||||||
|
targetPath: target.pathname,
|
||||||
|
trigger: 'global-nav',
|
||||||
|
sourceMode: document.body?.getAttribute('data-mode') || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore malformed link targets.
|
||||||
|
}
|
||||||
window.location.href = menuLink.href;
|
window.location.href = menuLink.href;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/* INTERCEPT Keyboard Shortcuts — global hotkey handler + help modal */
|
||||||
|
const KeyboardShortcuts = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const GUARD_SELECTOR = 'input, textarea, select, [contenteditable], .CodeMirror *';
|
||||||
|
let _handler = null;
|
||||||
|
|
||||||
|
function _handle(e) {
|
||||||
|
if (e.target.matches(GUARD_SELECTOR)) return;
|
||||||
|
|
||||||
|
if (e.altKey) {
|
||||||
|
switch (e.code) {
|
||||||
|
case 'KeyW': e.preventDefault(); window.switchMode && switchMode('waterfall'); break;
|
||||||
|
case 'KeyM': e.preventDefault(); window.VoiceAlerts && VoiceAlerts.toggleMute(); break;
|
||||||
|
case 'KeyS': e.preventDefault(); _toggleSidebar(); break;
|
||||||
|
case 'KeyK': e.preventDefault(); showHelp(); break;
|
||||||
|
case 'KeyC': e.preventDefault(); window.CheatSheets && CheatSheets.showForCurrentMode(); break;
|
||||||
|
default:
|
||||||
|
if (e.code >= 'Digit1' && e.code <= 'Digit9') {
|
||||||
|
e.preventDefault();
|
||||||
|
_switchToNthMode(parseInt(e.code.replace('Digit', '')) - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!e.ctrlKey && !e.metaKey) {
|
||||||
|
if (e.key === '?') { showHelp(); }
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
const kbModal = document.getElementById('kbShortcutsModal');
|
||||||
|
if (kbModal && kbModal.style.display !== 'none') { hideHelp(); return; }
|
||||||
|
const csModal = document.getElementById('cheatSheetModal');
|
||||||
|
if (csModal && csModal.style.display !== 'none') {
|
||||||
|
window.CheatSheets && CheatSheets.hide(); return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _toggleSidebar() {
|
||||||
|
const mc = document.querySelector('.main-content');
|
||||||
|
if (mc) mc.classList.toggle('sidebar-collapsed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _switchToNthMode(n) {
|
||||||
|
if (!window.interceptModeCatalog) return;
|
||||||
|
const mode = document.body.getAttribute('data-mode');
|
||||||
|
if (!mode) return;
|
||||||
|
const catalog = window.interceptModeCatalog;
|
||||||
|
const entry = catalog[mode];
|
||||||
|
if (!entry) return;
|
||||||
|
const groupModes = Object.keys(catalog).filter(k => catalog[k].group === entry.group);
|
||||||
|
if (groupModes[n]) window.switchMode && switchMode(groupModes[n]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showHelp() {
|
||||||
|
const modal = document.getElementById('kbShortcutsModal');
|
||||||
|
if (modal) modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideHelp() {
|
||||||
|
const modal = document.getElementById('kbShortcutsModal');
|
||||||
|
if (modal) modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (_handler) document.removeEventListener('keydown', _handler);
|
||||||
|
_handler = _handle;
|
||||||
|
document.addEventListener('keydown', _handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init, showHelp, hideHelp };
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.KeyboardShortcuts = KeyboardShortcuts;
|
||||||
@@ -114,13 +114,7 @@ const RecordingUI = (function() {
|
|||||||
|
|
||||||
function openReplay(sessionId) {
|
function openReplay(sessionId) {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
localStorage.setItem('analyticsReplaySession', sessionId);
|
window.open(`/recordings/${sessionId}/download`, '_blank');
|
||||||
if (typeof hideSettings === 'function') hideSettings();
|
|
||||||
if (typeof switchMode === 'function') {
|
|
||||||
switchMode('analytics', { updateUrl: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.location.href = '/?mode=analytics';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
|
|||||||
@@ -98,24 +98,15 @@ const Settings = {
|
|||||||
localStorage.setItem('intercept_map_theme_pref', pref);
|
localStorage.setItem('intercept_map_theme_pref', pref);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether Cyber map theme should be considered active globally.
|
|
||||||
* @param {Object} [config]
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
_isCyberThemeEnabled(config) {
|
|
||||||
const resolvedConfig = config || this.getTileConfig();
|
|
||||||
return this._getMapThemeClass(resolvedConfig) === 'map-theme-cyber';
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle root class used for hard global Leaflet theming.
|
* Toggle root class used for hard global Leaflet theming.
|
||||||
* @param {Object} [config]
|
* @param {Object} [config]
|
||||||
*/
|
*/
|
||||||
_syncRootMapThemeClass(config) {
|
_syncRootMapThemeClass(config) {
|
||||||
if (typeof document === 'undefined' || !document.documentElement) return;
|
if (typeof document === 'undefined' || !document.documentElement) return;
|
||||||
const enabled = this._isCyberThemeEnabled(config);
|
const resolvedConfig = config || this.getTileConfig();
|
||||||
document.documentElement.classList.toggle('map-cyber-enabled', enabled);
|
const themeClass = this._getMapThemeClass(resolvedConfig);
|
||||||
|
document.documentElement.classList.toggle('map-cyber-enabled', themeClass === 'map-theme-cyber');
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -381,17 +372,19 @@ const Settings = {
|
|||||||
|
|
||||||
container.classList.add(themeClass);
|
container.classList.add(themeClass);
|
||||||
|
|
||||||
if (container.style) {
|
if (themeClass === 'map-theme-cyber') {
|
||||||
container.style.background = '#020813';
|
if (container.style) {
|
||||||
}
|
container.style.background = '#020813';
|
||||||
if (tilePane && tilePane.style) {
|
}
|
||||||
tilePane.style.filter = 'sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08)';
|
if (tilePane && tilePane.style) {
|
||||||
tilePane.style.opacity = '1';
|
tilePane.style.filter = 'sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08)';
|
||||||
tilePane.style.willChange = 'filter';
|
tilePane.style.opacity = '1';
|
||||||
|
tilePane.style.willChange = 'filter';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grid/glow overlays are rendered via CSS pseudo elements on
|
// Map overlays are rendered via CSS pseudo elements on
|
||||||
// `html.map-cyber-enabled .leaflet-container` for consistent stacking.
|
// `html.map-*-enabled .leaflet-container` for consistent stacking.
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1265,6 +1258,7 @@ function switchSettingsTab(tabName) {
|
|||||||
} else if (tabName === 'location') {
|
} else if (tabName === 'location') {
|
||||||
loadObserverLocation();
|
loadObserverLocation();
|
||||||
} else if (tabName === 'alerts') {
|
} else if (tabName === 'alerts') {
|
||||||
|
loadVoiceAlertConfig();
|
||||||
if (typeof AlertCenter !== 'undefined') {
|
if (typeof AlertCenter !== 'undefined') {
|
||||||
AlertCenter.loadFeed();
|
AlertCenter.loadFeed();
|
||||||
}
|
}
|
||||||
@@ -1277,6 +1271,61 @@ function switchSettingsTab(tabName) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load voice alert configuration into Settings > Alerts tab
|
||||||
|
*/
|
||||||
|
function loadVoiceAlertConfig() {
|
||||||
|
if (typeof VoiceAlerts === 'undefined') return;
|
||||||
|
const cfg = VoiceAlerts.getConfig();
|
||||||
|
|
||||||
|
const pager = document.getElementById('voiceCfgPager');
|
||||||
|
const tscm = document.getElementById('voiceCfgTscm');
|
||||||
|
const tracker = document.getElementById('voiceCfgTracker');
|
||||||
|
const squawk = document.getElementById('voiceCfgSquawk');
|
||||||
|
const rate = document.getElementById('voiceCfgRate');
|
||||||
|
const pitch = document.getElementById('voiceCfgPitch');
|
||||||
|
const rateVal = document.getElementById('voiceCfgRateVal');
|
||||||
|
const pitchVal = document.getElementById('voiceCfgPitchVal');
|
||||||
|
|
||||||
|
if (pager) pager.checked = cfg.streams.pager !== false;
|
||||||
|
if (tscm) tscm.checked = cfg.streams.tscm !== false;
|
||||||
|
if (tracker) tracker.checked = cfg.streams.bluetooth !== false;
|
||||||
|
if (squawk) squawk.checked = cfg.streams.squawks !== false;
|
||||||
|
if (rate) rate.value = cfg.rate;
|
||||||
|
if (pitch) pitch.value = cfg.pitch;
|
||||||
|
if (rateVal) rateVal.textContent = cfg.rate;
|
||||||
|
if (pitchVal) pitchVal.textContent = cfg.pitch;
|
||||||
|
|
||||||
|
// Populate voice dropdown
|
||||||
|
VoiceAlerts.getAvailableVoices().then(function (voices) {
|
||||||
|
var sel = document.getElementById('voiceCfgVoice');
|
||||||
|
if (!sel) return;
|
||||||
|
sel.innerHTML = '<option value="">Default</option>' +
|
||||||
|
voices.filter(function (v) { return v.lang.startsWith('en'); }).map(function (v) {
|
||||||
|
return '<option value="' + v.name + '"' + (v.name === cfg.voiceName ? ' selected' : '') + '>' + v.name + '</option>';
|
||||||
|
}).join('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveVoiceAlertConfig() {
|
||||||
|
if (typeof VoiceAlerts === 'undefined') return;
|
||||||
|
VoiceAlerts.setConfig({
|
||||||
|
rate: parseFloat(document.getElementById('voiceCfgRate')?.value) || 1.1,
|
||||||
|
pitch: parseFloat(document.getElementById('voiceCfgPitch')?.value) || 0.9,
|
||||||
|
voiceName: document.getElementById('voiceCfgVoice')?.value || '',
|
||||||
|
streams: {
|
||||||
|
pager: !!document.getElementById('voiceCfgPager')?.checked,
|
||||||
|
tscm: !!document.getElementById('voiceCfgTscm')?.checked,
|
||||||
|
bluetooth: !!document.getElementById('voiceCfgTracker')?.checked,
|
||||||
|
squawks: !!document.getElementById('voiceCfgSquawk')?.checked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function testVoiceAlert() {
|
||||||
|
if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.testVoice();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load API key status into the API Keys settings tab
|
* Load API key status into the API Keys settings tab
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
/* INTERCEPT Voice Alerts — Web Speech API queue with priority system */
|
||||||
|
const VoiceAlerts = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const PRIORITY = { LOW: 0, MEDIUM: 1, HIGH: 2 };
|
||||||
|
let _enabled = true;
|
||||||
|
let _muted = false;
|
||||||
|
let _queue = [];
|
||||||
|
let _speaking = false;
|
||||||
|
let _sources = {};
|
||||||
|
const STORAGE_KEY = 'intercept-voice-muted';
|
||||||
|
const CONFIG_KEY = 'intercept-voice-config';
|
||||||
|
const RATE_MIN = 0.5;
|
||||||
|
const RATE_MAX = 2.0;
|
||||||
|
const PITCH_MIN = 0.5;
|
||||||
|
const PITCH_MAX = 2.0;
|
||||||
|
|
||||||
|
// Default config
|
||||||
|
let _config = {
|
||||||
|
rate: 1.1,
|
||||||
|
pitch: 0.9,
|
||||||
|
voiceName: '',
|
||||||
|
streams: { pager: true, tscm: true, bluetooth: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
function _toNumberInRange(value, fallback, min, max) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _normalizeConfig() {
|
||||||
|
_config.rate = _toNumberInRange(_config.rate, 1.1, RATE_MIN, RATE_MAX);
|
||||||
|
_config.pitch = _toNumberInRange(_config.pitch, 0.9, PITCH_MIN, PITCH_MAX);
|
||||||
|
_config.voiceName = typeof _config.voiceName === 'string' ? _config.voiceName : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isSpeechSupported() {
|
||||||
|
return !!(window.speechSynthesis && typeof window.SpeechSynthesisUtterance !== 'undefined');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showVoiceToast(title, message, type) {
|
||||||
|
if (typeof window.showAppToast === 'function') {
|
||||||
|
window.showAppToast(title, message, type || 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _loadConfig() {
|
||||||
|
_muted = localStorage.getItem(STORAGE_KEY) === 'true';
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(CONFIG_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
_config.rate = parsed.rate ?? _config.rate;
|
||||||
|
_config.pitch = parsed.pitch ?? _config.pitch;
|
||||||
|
_config.voiceName = parsed.voiceName ?? _config.voiceName;
|
||||||
|
if (parsed.streams) {
|
||||||
|
Object.assign(_config.streams, parsed.streams);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
_normalizeConfig();
|
||||||
|
_updateMuteButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateMuteButton() {
|
||||||
|
const btn = document.getElementById('voiceMuteBtn');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.classList.toggle('voice-muted', _muted);
|
||||||
|
btn.title = _muted ? 'Unmute voice alerts' : 'Mute voice alerts';
|
||||||
|
btn.style.opacity = _muted ? '0.4' : '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getVoice() {
|
||||||
|
if (!_config.voiceName) return null;
|
||||||
|
const voices = window.speechSynthesis ? speechSynthesis.getVoices() : [];
|
||||||
|
return voices.find(v => v.name === _config.voiceName) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _createUtterance(text) {
|
||||||
|
const utt = new SpeechSynthesisUtterance(text);
|
||||||
|
utt.rate = _toNumberInRange(_config.rate, 1.1, RATE_MIN, RATE_MAX);
|
||||||
|
utt.pitch = _toNumberInRange(_config.pitch, 0.9, PITCH_MIN, PITCH_MAX);
|
||||||
|
const voice = _getVoice();
|
||||||
|
if (voice) utt.voice = voice;
|
||||||
|
return utt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function speak(text, priority) {
|
||||||
|
if (priority === undefined) priority = PRIORITY.MEDIUM;
|
||||||
|
if (!_enabled || _muted) return;
|
||||||
|
if (!window.speechSynthesis) return;
|
||||||
|
if (priority === PRIORITY.LOW && _speaking) return;
|
||||||
|
if (priority === PRIORITY.HIGH && _speaking) {
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
_queue = [];
|
||||||
|
_speaking = false;
|
||||||
|
}
|
||||||
|
_queue.push({ text, priority });
|
||||||
|
if (!_speaking) _dequeue();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _dequeue() {
|
||||||
|
if (_queue.length === 0) { _speaking = false; return; }
|
||||||
|
_speaking = true;
|
||||||
|
const item = _queue.shift();
|
||||||
|
const utt = _createUtterance(item.text);
|
||||||
|
utt.onend = () => { _speaking = false; _dequeue(); };
|
||||||
|
utt.onerror = () => { _speaking = false; _dequeue(); };
|
||||||
|
window.speechSynthesis.speak(utt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMute() {
|
||||||
|
_muted = !_muted;
|
||||||
|
localStorage.setItem(STORAGE_KEY, _muted ? 'true' : 'false');
|
||||||
|
_updateMuteButton();
|
||||||
|
if (_muted && window.speechSynthesis) window.speechSynthesis.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _openStream(url, handler, key) {
|
||||||
|
if (_sources[key]) return;
|
||||||
|
const es = new EventSource(url);
|
||||||
|
es.onmessage = handler;
|
||||||
|
es.onerror = () => { es.close(); delete _sources[key]; };
|
||||||
|
_sources[key] = es;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _startStreams() {
|
||||||
|
if (!_enabled) return;
|
||||||
|
|
||||||
|
// Pager stream
|
||||||
|
if (_config.streams.pager) {
|
||||||
|
_openStream('/stream', (ev) => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(ev.data);
|
||||||
|
if (d.address && d.message) {
|
||||||
|
speak(`Pager message to ${d.address}: ${String(d.message).slice(0, 60)}`, PRIORITY.MEDIUM);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}, 'pager');
|
||||||
|
}
|
||||||
|
|
||||||
|
// TSCM stream
|
||||||
|
if (_config.streams.tscm) {
|
||||||
|
_openStream('/tscm/sweep/stream', (ev) => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(ev.data);
|
||||||
|
if (d.threat_level && d.description) {
|
||||||
|
speak(`TSCM alert: ${d.threat_level} — ${d.description}`, PRIORITY.HIGH);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}, 'tscm');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bluetooth stream — tracker detection only
|
||||||
|
if (_config.streams.bluetooth) {
|
||||||
|
_openStream('/api/bluetooth/stream', (ev) => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(ev.data);
|
||||||
|
if (d.service_data && d.service_data.tracker_type) {
|
||||||
|
speak(`Tracker detected: ${d.service_data.tracker_type}`, PRIORITY.HIGH);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}, 'bluetooth');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stopStreams() {
|
||||||
|
Object.values(_sources).forEach(es => { try { es.close(); } catch (_) {} });
|
||||||
|
_sources = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
_loadConfig();
|
||||||
|
if (_isSpeechSupported()) {
|
||||||
|
// Prime voices list early so user-triggered test calls are less likely to be silent.
|
||||||
|
speechSynthesis.getVoices();
|
||||||
|
}
|
||||||
|
_startStreams();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEnabled(val) {
|
||||||
|
_enabled = val;
|
||||||
|
if (!val) {
|
||||||
|
_stopStreams();
|
||||||
|
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
||||||
|
} else {
|
||||||
|
_startStreams();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config API (used by Ops Center voice config panel) ─────────────
|
||||||
|
|
||||||
|
function getConfig() {
|
||||||
|
return JSON.parse(JSON.stringify(_config));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConfig(cfg) {
|
||||||
|
if (cfg.rate !== undefined) _config.rate = _toNumberInRange(cfg.rate, _config.rate, RATE_MIN, RATE_MAX);
|
||||||
|
if (cfg.pitch !== undefined) _config.pitch = _toNumberInRange(cfg.pitch, _config.pitch, PITCH_MIN, PITCH_MAX);
|
||||||
|
if (cfg.voiceName !== undefined) _config.voiceName = cfg.voiceName;
|
||||||
|
if (cfg.streams) Object.assign(_config.streams, cfg.streams);
|
||||||
|
_normalizeConfig();
|
||||||
|
localStorage.setItem(CONFIG_KEY, JSON.stringify(_config));
|
||||||
|
// Restart streams to apply per-stream toggle changes
|
||||||
|
_stopStreams();
|
||||||
|
_startStreams();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvailableVoices() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (!window.speechSynthesis) { resolve([]); return; }
|
||||||
|
let voices = speechSynthesis.getVoices();
|
||||||
|
if (voices.length > 0) { resolve(voices); return; }
|
||||||
|
speechSynthesis.onvoiceschanged = () => {
|
||||||
|
resolve(speechSynthesis.getVoices());
|
||||||
|
};
|
||||||
|
// Timeout fallback
|
||||||
|
setTimeout(() => resolve(speechSynthesis.getVoices()), 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function testVoice(text) {
|
||||||
|
if (!_isSpeechSupported()) {
|
||||||
|
_showVoiceToast('Voice Unavailable', 'This browser does not support speech synthesis.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make the test immediate and recover from a paused/stalled synthesis engine.
|
||||||
|
try {
|
||||||
|
speechSynthesis.getVoices();
|
||||||
|
if (speechSynthesis.paused) speechSynthesis.resume();
|
||||||
|
speechSynthesis.cancel();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
const utt = _createUtterance(text || 'Voice alert test. All systems nominal.');
|
||||||
|
let started = false;
|
||||||
|
utt.onstart = () => { started = true; };
|
||||||
|
utt.onerror = () => {
|
||||||
|
_showVoiceToast('Voice Test Failed', 'Speech synthesis failed to start. Check browser audio output.', 'warning');
|
||||||
|
};
|
||||||
|
speechSynthesis.speak(utt);
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (!started && !speechSynthesis.speaking && !speechSynthesis.pending) {
|
||||||
|
_showVoiceToast('No Voice Output', 'Test speech did not play. Verify browser audio and selected voice.', 'warning');
|
||||||
|
}
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init, speak, toggleMute, setEnabled, getConfig, setConfig, getAvailableVoices, testVoice, PRIORITY };
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.VoiceAlerts = VoiceAlerts;
|
||||||
@@ -1,549 +0,0 @@
|
|||||||
/**
|
|
||||||
* Analytics Dashboard Module
|
|
||||||
* Cross-mode summary, sparklines, alerts, correlations, target view, and replay.
|
|
||||||
*/
|
|
||||||
const Analytics = (function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
let refreshTimer = null;
|
|
||||||
let replayTimer = null;
|
|
||||||
let replaySessions = [];
|
|
||||||
let replayEvents = [];
|
|
||||||
let replayIndex = 0;
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
refresh();
|
|
||||||
loadReplaySessions();
|
|
||||||
if (!refreshTimer) {
|
|
||||||
refreshTimer = setInterval(refresh, 5000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function destroy() {
|
|
||||||
if (refreshTimer) {
|
|
||||||
clearInterval(refreshTimer);
|
|
||||||
refreshTimer = null;
|
|
||||||
}
|
|
||||||
pauseReplay();
|
|
||||||
}
|
|
||||||
|
|
||||||
function refresh() {
|
|
||||||
Promise.all([
|
|
||||||
fetch('/analytics/summary').then(r => r.json()).catch(() => null),
|
|
||||||
fetch('/analytics/activity').then(r => r.json()).catch(() => null),
|
|
||||||
fetch('/analytics/insights').then(r => r.json()).catch(() => null),
|
|
||||||
fetch('/analytics/patterns').then(r => r.json()).catch(() => null),
|
|
||||||
fetch('/alerts/events?limit=20').then(r => r.json()).catch(() => null),
|
|
||||||
fetch('/correlation').then(r => r.json()).catch(() => null),
|
|
||||||
fetch('/analytics/geofences').then(r => r.json()).catch(() => null),
|
|
||||||
]).then(([summary, activity, insights, patterns, alerts, correlations, geofences]) => {
|
|
||||||
if (summary) renderSummary(summary);
|
|
||||||
if (activity) renderSparklines(activity.sparklines || {});
|
|
||||||
if (insights) renderInsights(insights);
|
|
||||||
if (patterns) renderPatterns(patterns.patterns || []);
|
|
||||||
if (alerts) renderAlerts(alerts.events || []);
|
|
||||||
if (correlations) renderCorrelations(correlations);
|
|
||||||
if (geofences) renderGeofences(geofences.zones || []);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSummary(data) {
|
|
||||||
const counts = data.counts || {};
|
|
||||||
_setText('analyticsCountAdsb', counts.adsb || 0);
|
|
||||||
_setText('analyticsCountAis', counts.ais || 0);
|
|
||||||
_setText('analyticsCountWifi', counts.wifi || 0);
|
|
||||||
_setText('analyticsCountBt', counts.bluetooth || 0);
|
|
||||||
_setText('analyticsCountDsc', counts.dsc || 0);
|
|
||||||
_setText('analyticsCountAcars', counts.acars || 0);
|
|
||||||
_setText('analyticsCountVdl2', counts.vdl2 || 0);
|
|
||||||
_setText('analyticsCountAprs', counts.aprs || 0);
|
|
||||||
_setText('analyticsCountMesh', counts.meshtastic || 0);
|
|
||||||
|
|
||||||
const health = data.health || {};
|
|
||||||
const container = document.getElementById('analyticsHealth');
|
|
||||||
if (container) {
|
|
||||||
let html = '';
|
|
||||||
const modeLabels = {
|
|
||||||
pager: 'Pager', sensor: '433MHz', adsb: 'ADS-B', ais: 'AIS',
|
|
||||||
acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', wifi: 'WiFi',
|
|
||||||
bluetooth: 'BT', dsc: 'DSC', meshtastic: 'Mesh'
|
|
||||||
};
|
|
||||||
for (const [mode, info] of Object.entries(health)) {
|
|
||||||
if (mode === 'sdr_devices') continue;
|
|
||||||
const running = info && info.running;
|
|
||||||
const label = modeLabels[mode] || mode;
|
|
||||||
html += '<div class="health-item"><span class="health-dot' + (running ? ' running' : '') + '"></span>' + _esc(label) + '</div>';
|
|
||||||
}
|
|
||||||
container.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
const squawks = data.squawks || [];
|
|
||||||
const sqSection = document.getElementById('analyticsSquawkSection');
|
|
||||||
const sqList = document.getElementById('analyticsSquawkList');
|
|
||||||
if (sqSection && sqList) {
|
|
||||||
if (squawks.length > 0) {
|
|
||||||
sqSection.style.display = '';
|
|
||||||
sqList.innerHTML = squawks.map(s =>
|
|
||||||
'<div class="squawk-item"><strong>' + _esc(s.squawk) + '</strong> ' +
|
|
||||||
_esc(s.meaning) + ' - ' + _esc(s.callsign || s.icao) + '</div>'
|
|
||||||
).join('');
|
|
||||||
} else {
|
|
||||||
sqSection.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSparklines(sparklines) {
|
|
||||||
const map = {
|
|
||||||
adsb: 'analyticsSparkAdsb',
|
|
||||||
ais: 'analyticsSparkAis',
|
|
||||||
wifi: 'analyticsSparkWifi',
|
|
||||||
bluetooth: 'analyticsSparkBt',
|
|
||||||
dsc: 'analyticsSparkDsc',
|
|
||||||
acars: 'analyticsSparkAcars',
|
|
||||||
vdl2: 'analyticsSparkVdl2',
|
|
||||||
aprs: 'analyticsSparkAprs',
|
|
||||||
meshtastic: 'analyticsSparkMesh',
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const [mode, elId] of Object.entries(map)) {
|
|
||||||
const el = document.getElementById(elId);
|
|
||||||
if (!el) continue;
|
|
||||||
const data = sparklines[mode] || [];
|
|
||||||
if (data.length < 2) {
|
|
||||||
el.innerHTML = '';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const max = Math.max(...data, 1);
|
|
||||||
const w = 100;
|
|
||||||
const h = 24;
|
|
||||||
const step = w / (data.length - 1);
|
|
||||||
const points = data.map((v, i) =>
|
|
||||||
(i * step).toFixed(1) + ',' + (h - (v / max) * (h - 2)).toFixed(1)
|
|
||||||
).join(' ');
|
|
||||||
el.innerHTML = '<svg viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none"><polyline points="' + points + '"/></svg>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderInsights(data) {
|
|
||||||
const cards = data.cards || [];
|
|
||||||
const topChanges = data.top_changes || [];
|
|
||||||
const cardsEl = document.getElementById('analyticsInsights');
|
|
||||||
const changesEl = document.getElementById('analyticsTopChanges');
|
|
||||||
|
|
||||||
if (cardsEl) {
|
|
||||||
if (!cards.length) {
|
|
||||||
cardsEl.innerHTML = '<div class="analytics-empty">No insight data available</div>';
|
|
||||||
} else {
|
|
||||||
cardsEl.innerHTML = cards.map(c => {
|
|
||||||
const sev = _esc(c.severity || 'low');
|
|
||||||
const title = _esc(c.title || 'Insight');
|
|
||||||
const value = _esc(c.value || '--');
|
|
||||||
const label = _esc(c.label || '');
|
|
||||||
const detail = _esc(c.detail || '');
|
|
||||||
return '<div class="analytics-insight-card ' + sev + '">' +
|
|
||||||
'<div class="insight-title">' + title + '</div>' +
|
|
||||||
'<div class="insight-value">' + value + '</div>' +
|
|
||||||
'<div class="insight-label">' + label + '</div>' +
|
|
||||||
'<div class="insight-detail">' + detail + '</div>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changesEl) {
|
|
||||||
if (!topChanges.length) {
|
|
||||||
changesEl.innerHTML = '<div class="analytics-empty">No change signals yet</div>';
|
|
||||||
} else {
|
|
||||||
changesEl.innerHTML = topChanges.map(item => {
|
|
||||||
const mode = _esc(item.mode_label || item.mode || '');
|
|
||||||
const deltaRaw = Number(item.delta || 0);
|
|
||||||
const trendClass = deltaRaw > 0 ? 'up' : (deltaRaw < 0 ? 'down' : 'flat');
|
|
||||||
const delta = _esc(item.signed_delta || String(deltaRaw));
|
|
||||||
const recentAvg = _esc(item.recent_avg);
|
|
||||||
const prevAvg = _esc(item.previous_avg);
|
|
||||||
return '<div class="analytics-change-row">' +
|
|
||||||
'<span class="mode">' + mode + '</span>' +
|
|
||||||
'<span class="delta ' + trendClass + '">' + delta + '</span>' +
|
|
||||||
'<span class="avg">avg ' + recentAvg + ' vs ' + prevAvg + '</span>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPatterns(patterns) {
|
|
||||||
const container = document.getElementById('analyticsPatternList');
|
|
||||||
if (!container) return;
|
|
||||||
if (!patterns || patterns.length === 0) {
|
|
||||||
container.innerHTML = '<div class="analytics-empty">No recurring patterns detected</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const modeLabels = {
|
|
||||||
adsb: 'ADS-B', ais: 'AIS', wifi: 'WiFi', bluetooth: 'Bluetooth',
|
|
||||||
dsc: 'DSC', acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', meshtastic: 'Meshtastic',
|
|
||||||
};
|
|
||||||
|
|
||||||
const sorted = patterns
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => (b.confidence || 0) - (a.confidence || 0))
|
|
||||||
.slice(0, 20);
|
|
||||||
|
|
||||||
container.innerHTML = sorted.map(p => {
|
|
||||||
const confidencePct = Math.round((Number(p.confidence || 0)) * 100);
|
|
||||||
const mode = modeLabels[p.mode] || (p.mode || '--').toUpperCase();
|
|
||||||
const period = _humanPeriod(Number(p.period_seconds || 0));
|
|
||||||
const occurrences = Number(p.occurrences || 0);
|
|
||||||
const deviceId = _shortId(p.device_id || '--');
|
|
||||||
return '<div class="analytics-pattern-item">' +
|
|
||||||
'<div class="pattern-main">' +
|
|
||||||
'<span class="pattern-mode">' + _esc(mode) + '</span>' +
|
|
||||||
'<span class="pattern-device">' + _esc(deviceId) + '</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="pattern-meta">' +
|
|
||||||
'<span>Period: ' + _esc(period) + '</span>' +
|
|
||||||
'<span>Hits: ' + _esc(occurrences) + '</span>' +
|
|
||||||
'<span class="pattern-confidence">' + _esc(confidencePct) + '%</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAlerts(events) {
|
|
||||||
const container = document.getElementById('analyticsAlertFeed');
|
|
||||||
if (!container) return;
|
|
||||||
if (!events || events.length === 0) {
|
|
||||||
container.innerHTML = '<div class="analytics-empty">No recent alerts</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.innerHTML = events.slice(0, 20).map(e => {
|
|
||||||
const sev = e.severity || 'medium';
|
|
||||||
const title = e.title || e.event_type || 'Alert';
|
|
||||||
const time = e.created_at ? new Date(e.created_at).toLocaleTimeString() : '';
|
|
||||||
return '<div class="analytics-alert-item">' +
|
|
||||||
'<span class="alert-severity ' + _esc(sev) + '">' + _esc(sev) + '</span>' +
|
|
||||||
'<span>' + _esc(title) + '</span>' +
|
|
||||||
'<span style="margin-left:auto;color:var(--text-dim)">' + _esc(time) + '</span>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCorrelations(data) {
|
|
||||||
const container = document.getElementById('analyticsCorrelations');
|
|
||||||
if (!container) return;
|
|
||||||
const pairs = (data && data.correlations) || [];
|
|
||||||
if (pairs.length === 0) {
|
|
||||||
container.innerHTML = '<div class="analytics-empty">No correlations detected</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.innerHTML = pairs.slice(0, 20).map(p => {
|
|
||||||
const conf = Math.round((p.confidence || 0) * 100);
|
|
||||||
return '<div class="analytics-correlation-pair">' +
|
|
||||||
'<span>' + _esc(p.wifi_mac || '') + '</span>' +
|
|
||||||
'<span style="color:var(--text-dim)">↔</span>' +
|
|
||||||
'<span>' + _esc(p.bt_mac || '') + '</span>' +
|
|
||||||
'<div class="confidence-bar"><div class="confidence-fill" style="width:' + conf + '%"></div></div>' +
|
|
||||||
'<span style="color:var(--text-dim)">' + conf + '%</span>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderGeofences(zones) {
|
|
||||||
const container = document.getElementById('analyticsGeofenceList');
|
|
||||||
if (!container) return;
|
|
||||||
if (!zones || zones.length === 0) {
|
|
||||||
container.innerHTML = '<div class="analytics-empty">No geofence zones defined</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.innerHTML = zones.map(z =>
|
|
||||||
'<div class="geofence-zone-item">' +
|
|
||||||
'<span class="zone-name">' + _esc(z.name) + '</span>' +
|
|
||||||
'<span class="zone-radius">' + z.radius_m + 'm</span>' +
|
|
||||||
'<button class="zone-delete" onclick="Analytics.deleteGeofence(' + z.id + ')">DEL</button>' +
|
|
||||||
'</div>'
|
|
||||||
).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function addGeofence() {
|
|
||||||
const name = prompt('Zone name:');
|
|
||||||
if (!name) return;
|
|
||||||
const lat = parseFloat(prompt('Latitude:', '0'));
|
|
||||||
const lon = parseFloat(prompt('Longitude:', '0'));
|
|
||||||
const radius = parseFloat(prompt('Radius (meters):', '1000'));
|
|
||||||
if (isNaN(lat) || isNaN(lon) || isNaN(radius)) {
|
|
||||||
alert('Invalid input');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fetch('/analytics/geofences', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name, lat, lon, radius_m: radius }),
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(() => refresh());
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteGeofence(id) {
|
|
||||||
if (!confirm('Delete this geofence zone?')) return;
|
|
||||||
fetch('/analytics/geofences/' + id, { method: 'DELETE' })
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(() => refresh());
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportData(mode) {
|
|
||||||
const m = mode || (document.getElementById('exportMode') || {}).value || 'adsb';
|
|
||||||
const f = (document.getElementById('exportFormat') || {}).value || 'json';
|
|
||||||
window.open('/analytics/export/' + encodeURIComponent(m) + '?format=' + encodeURIComponent(f), '_blank');
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchTarget() {
|
|
||||||
const input = document.getElementById('analyticsTargetQuery');
|
|
||||||
const summaryEl = document.getElementById('analyticsTargetSummary');
|
|
||||||
const q = (input && input.value || '').trim();
|
|
||||||
if (!q) {
|
|
||||||
if (summaryEl) summaryEl.textContent = 'Enter a search value to correlate entities';
|
|
||||||
renderTargetResults([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch('/analytics/target?q=' + encodeURIComponent(q) + '&limit=120')
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
const results = data.results || [];
|
|
||||||
if (summaryEl) {
|
|
||||||
const modeCounts = data.mode_counts || {};
|
|
||||||
const bits = Object.entries(modeCounts).map(([mode, count]) => `${mode}: ${count}`).join(' | ');
|
|
||||||
summaryEl.textContent = `${results.length} results${bits ? ' | ' + bits : ''}`;
|
|
||||||
}
|
|
||||||
renderTargetResults(results);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (summaryEl) summaryEl.textContent = 'Search failed';
|
|
||||||
if (typeof reportActionableError === 'function') {
|
|
||||||
reportActionableError('Target View Search', err, { onRetry: searchTarget });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTargetResults(results) {
|
|
||||||
const container = document.getElementById('analyticsTargetResults');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
if (!results || !results.length) {
|
|
||||||
container.innerHTML = '<div class="analytics-empty">No matching entities</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = results.map((item) => {
|
|
||||||
const title = _esc(item.title || item.id || 'Entity');
|
|
||||||
const subtitle = _esc(item.subtitle || '');
|
|
||||||
const mode = _esc(item.mode || 'unknown');
|
|
||||||
const confidence = item.confidence != null ? `Confidence ${_esc(Math.round(Number(item.confidence) * 100))}%` : '';
|
|
||||||
const lastSeen = _esc(item.last_seen || '');
|
|
||||||
return '<div class="analytics-target-item">' +
|
|
||||||
'<div class="title"><span class="mode">' + mode + '</span><span>' + title + '</span></div>' +
|
|
||||||
'<div class="meta"><span>' + subtitle + '</span>' +
|
|
||||||
(lastSeen ? '<span>Last seen ' + lastSeen + '</span>' : '') +
|
|
||||||
(confidence ? '<span>' + confidence + '</span>' : '') +
|
|
||||||
'</div>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadReplaySessions() {
|
|
||||||
const select = document.getElementById('analyticsReplaySelect');
|
|
||||||
if (!select) return;
|
|
||||||
|
|
||||||
fetch('/recordings?limit=60')
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
replaySessions = (data.recordings || []).filter((rec) => Number(rec.event_count || 0) > 0);
|
|
||||||
|
|
||||||
if (!replaySessions.length) {
|
|
||||||
select.innerHTML = '<option value="">No recordings</option>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
select.innerHTML = replaySessions.map((rec) => {
|
|
||||||
const label = `${rec.mode} | ${(rec.label || 'session')} | ${new Date(rec.started_at).toLocaleString()}`;
|
|
||||||
return `<option value="${_esc(rec.id)}">${_esc(label)}</option>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
const pendingReplay = localStorage.getItem('analyticsReplaySession');
|
|
||||||
if (pendingReplay && replaySessions.some((rec) => rec.id === pendingReplay)) {
|
|
||||||
select.value = pendingReplay;
|
|
||||||
localStorage.removeItem('analyticsReplaySession');
|
|
||||||
loadReplay();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (typeof reportActionableError === 'function') {
|
|
||||||
reportActionableError('Load Replay Sessions', err, { onRetry: loadReplaySessions });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadReplay() {
|
|
||||||
pauseReplay();
|
|
||||||
replayEvents = [];
|
|
||||||
replayIndex = 0;
|
|
||||||
|
|
||||||
const select = document.getElementById('analyticsReplaySelect');
|
|
||||||
const meta = document.getElementById('analyticsReplayMeta');
|
|
||||||
const timeline = document.getElementById('analyticsReplayTimeline');
|
|
||||||
if (!select || !meta || !timeline) return;
|
|
||||||
|
|
||||||
const id = select.value;
|
|
||||||
if (!id) {
|
|
||||||
meta.textContent = 'Select a recording';
|
|
||||||
timeline.innerHTML = '<div class="analytics-empty">No recording selected</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
meta.textContent = 'Loading replay events...';
|
|
||||||
|
|
||||||
fetch('/recordings/' + encodeURIComponent(id) + '/events?limit=600')
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
replayEvents = data.events || [];
|
|
||||||
replayIndex = 0;
|
|
||||||
if (!replayEvents.length) {
|
|
||||||
meta.textContent = 'No events found in selected recording';
|
|
||||||
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rec = replaySessions.find((s) => s.id === id);
|
|
||||||
const mode = rec ? rec.mode : (data.recording && data.recording.mode) || 'unknown';
|
|
||||||
meta.textContent = `${replayEvents.length} events loaded | mode ${mode}`;
|
|
||||||
renderReplayWindow();
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
meta.textContent = 'Replay load failed';
|
|
||||||
if (typeof reportActionableError === 'function') {
|
|
||||||
reportActionableError('Load Replay', err, { onRetry: loadReplay });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function playReplay() {
|
|
||||||
if (!replayEvents.length) {
|
|
||||||
loadReplay();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (replayTimer) return;
|
|
||||||
|
|
||||||
replayTimer = setInterval(() => {
|
|
||||||
if (replayIndex >= replayEvents.length - 1) {
|
|
||||||
pauseReplay();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
replayIndex += 1;
|
|
||||||
renderReplayWindow();
|
|
||||||
}, 260);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pauseReplay() {
|
|
||||||
if (replayTimer) {
|
|
||||||
clearInterval(replayTimer);
|
|
||||||
replayTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stepReplay() {
|
|
||||||
if (!replayEvents.length) {
|
|
||||||
loadReplay();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
pauseReplay();
|
|
||||||
replayIndex = Math.min(replayIndex + 1, replayEvents.length - 1);
|
|
||||||
renderReplayWindow();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderReplayWindow() {
|
|
||||||
const timeline = document.getElementById('analyticsReplayTimeline');
|
|
||||||
const meta = document.getElementById('analyticsReplayMeta');
|
|
||||||
if (!timeline || !meta) return;
|
|
||||||
|
|
||||||
const total = replayEvents.length;
|
|
||||||
if (!total) {
|
|
||||||
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = Math.max(0, replayIndex - 15);
|
|
||||||
const end = Math.min(total, replayIndex + 20);
|
|
||||||
const windowed = replayEvents.slice(start, end);
|
|
||||||
|
|
||||||
timeline.innerHTML = windowed.map((row, i) => {
|
|
||||||
const absolute = start + i;
|
|
||||||
const active = absolute === replayIndex;
|
|
||||||
const eventType = _esc(row.event_type || 'event');
|
|
||||||
const mode = _esc(row.mode || '--');
|
|
||||||
const ts = _esc(row.timestamp ? new Date(row.timestamp).toLocaleTimeString() : '--');
|
|
||||||
const detail = summarizeReplayEvent(row.event || {});
|
|
||||||
return '<div class="analytics-replay-item" style="opacity:' + (active ? '1' : '0.65') + ';">' +
|
|
||||||
'<div class="title"><span class="mode">' + mode + '</span><span>' + eventType + '</span></div>' +
|
|
||||||
'<div class="meta"><span>' + ts + '</span><span>' + _esc(detail) + '</span></div>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
meta.textContent = `Event ${replayIndex + 1}/${total}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeReplayEvent(event) {
|
|
||||||
if (!event || typeof event !== 'object') return 'No details';
|
|
||||||
if (event.callsign) return `Callsign ${event.callsign}`;
|
|
||||||
if (event.icao) return `ICAO ${event.icao}`;
|
|
||||||
if (event.ssid) return `SSID ${event.ssid}`;
|
|
||||||
if (event.bssid) return `BSSID ${event.bssid}`;
|
|
||||||
if (event.address) return `Address ${event.address}`;
|
|
||||||
if (event.name) return `Name ${event.name}`;
|
|
||||||
const keys = Object.keys(event);
|
|
||||||
if (!keys.length) return 'No fields';
|
|
||||||
return `${keys[0]}=${String(event[keys[0]]).slice(0, 40)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _setText(id, val) {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (el) el.textContent = val;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _esc(s) {
|
|
||||||
if (typeof s !== 'string') s = String(s == null ? '' : s);
|
|
||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
function _shortId(value) {
|
|
||||||
const text = String(value || '');
|
|
||||||
if (text.length <= 18) return text;
|
|
||||||
return text.slice(0, 8) + '...' + text.slice(-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _humanPeriod(seconds) {
|
|
||||||
if (!isFinite(seconds) || seconds <= 0) return '--';
|
|
||||||
if (seconds < 60) return Math.round(seconds) + 's';
|
|
||||||
const mins = seconds / 60;
|
|
||||||
if (mins < 60) return mins.toFixed(mins < 10 ? 1 : 0) + 'm';
|
|
||||||
const hours = mins / 60;
|
|
||||||
return hours.toFixed(hours < 10 ? 1 : 0) + 'h';
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
init,
|
|
||||||
destroy,
|
|
||||||
refresh,
|
|
||||||
addGeofence,
|
|
||||||
deleteGeofence,
|
|
||||||
exportData,
|
|
||||||
searchTarget,
|
|
||||||
loadReplay,
|
|
||||||
playReplay,
|
|
||||||
pauseReplay,
|
|
||||||
stepReplay,
|
|
||||||
loadReplaySessions,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -944,21 +944,36 @@ const BluetoothMode = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopScan() {
|
async function stopScan() {
|
||||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
|
const timeoutMs = isAgentMode ? 8000 : 2200;
|
||||||
try {
|
const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
||||||
if (isAgentMode) {
|
const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
||||||
await fetch(`/controller/agents/${currentAgent}/bluetooth/stop`, { method: 'POST' });
|
|
||||||
} else {
|
// Optimistic UI teardown keeps mode changes responsive.
|
||||||
await fetch('/api/bluetooth/scan/stop', { method: 'POST' });
|
setScanning(false);
|
||||||
}
|
stopEventStream();
|
||||||
setScanning(false);
|
|
||||||
stopEventStream();
|
try {
|
||||||
} catch (err) {
|
if (isAgentMode) {
|
||||||
console.error('Failed to stop scan:', err);
|
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) {
|
function setScanning(scanning) {
|
||||||
isScanning = scanning;
|
isScanning = scanning;
|
||||||
|
|||||||
+486
-459
File diff suppressed because it is too large
Load Diff
+799
-156
@@ -4,11 +4,14 @@
|
|||||||
* position/velocity/DOP readout. Connects to gpsd via backend SSE stream.
|
* position/velocity/DOP readout. Connects to gpsd via backend SSE stream.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const GPS = (function() {
|
const GPS = (function() {
|
||||||
let connected = false;
|
let connected = false;
|
||||||
let lastPosition = null;
|
let lastPosition = null;
|
||||||
let lastSky = null;
|
let lastSky = null;
|
||||||
let skyPollTimer = null;
|
let skyPollTimer = null;
|
||||||
|
let themeObserver = null;
|
||||||
|
let skyRenderer = null;
|
||||||
|
let skyRendererInitAttempted = false;
|
||||||
|
|
||||||
// Constellation color map
|
// Constellation color map
|
||||||
const CONST_COLORS = {
|
const CONST_COLORS = {
|
||||||
@@ -20,20 +23,45 @@ const GPS = (function() {
|
|||||||
'QZSS': '#cc66ff',
|
'QZSS': '#cc66ff',
|
||||||
};
|
};
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
drawEmptySkyView();
|
initSkyRenderer();
|
||||||
connect();
|
drawEmptySkyView();
|
||||||
|
if (!connected) connect();
|
||||||
// Redraw sky view when theme changes
|
|
||||||
const observer = new MutationObserver(() => {
|
// Redraw sky view when theme changes
|
||||||
if (lastSky) {
|
if (!themeObserver) {
|
||||||
drawSkyView(lastSky.satellites || []);
|
themeObserver = new MutationObserver(() => {
|
||||||
} else {
|
if (skyRenderer && typeof skyRenderer.requestRender === 'function') {
|
||||||
drawEmptySkyView();
|
skyRenderer.requestRender();
|
||||||
}
|
}
|
||||||
});
|
if (lastSky) {
|
||||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
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() {
|
function connect() {
|
||||||
updateConnectionUI(false, false, 'connecting');
|
updateConnectionUI(false, false, 'connecting');
|
||||||
@@ -252,139 +280,745 @@ const GPS = (function() {
|
|||||||
if (el) el.textContent = val;
|
if (el) el.textContent = val;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================
|
// ========================
|
||||||
// Sky View Polar Plot
|
// Sky View Globe (WebGL with 2D fallback)
|
||||||
// ========================
|
// ========================
|
||||||
|
|
||||||
function drawEmptySkyView() {
|
function drawEmptySkyView() {
|
||||||
const canvas = document.getElementById('gpsSkyCanvas');
|
if (!skyRendererInitAttempted) {
|
||||||
if (!canvas) return;
|
initSkyRenderer();
|
||||||
drawSkyViewBase(canvas);
|
}
|
||||||
}
|
|
||||||
|
if (skyRenderer) {
|
||||||
function drawSkyView(satellites) {
|
skyRenderer.setSatellites([]);
|
||||||
const canvas = document.getElementById('gpsSkyCanvas');
|
return;
|
||||||
if (!canvas) return;
|
}
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
const canvas = document.getElementById('gpsSkyCanvas');
|
||||||
const w = canvas.width;
|
if (!canvas) return;
|
||||||
const h = canvas.height;
|
drawSkyViewBase2D(canvas);
|
||||||
const cx = w / 2;
|
}
|
||||||
const cy = h / 2;
|
|
||||||
const r = Math.min(cx, cy) - 24;
|
function drawSkyView(satellites) {
|
||||||
|
if (!skyRendererInitAttempted) {
|
||||||
drawSkyViewBase(canvas);
|
initSkyRenderer();
|
||||||
|
}
|
||||||
// Plot satellites
|
|
||||||
satellites.forEach(sat => {
|
const sats = Array.isArray(satellites) ? satellites : [];
|
||||||
if (sat.elevation == null || sat.azimuth == null) return;
|
|
||||||
|
if (skyRenderer) {
|
||||||
const elRad = (90 - sat.elevation) / 90;
|
skyRenderer.setSatellites(sats);
|
||||||
const azRad = (sat.azimuth - 90) * Math.PI / 180; // N = up
|
return;
|
||||||
const px = cx + r * elRad * Math.cos(azRad);
|
}
|
||||||
const py = cy + r * elRad * Math.sin(azRad);
|
|
||||||
|
const canvas = document.getElementById('gpsSkyCanvas');
|
||||||
const color = CONST_COLORS[sat.constellation] || CONST_COLORS['GPS'];
|
if (!canvas) return;
|
||||||
const dotSize = sat.used ? 6 : 4;
|
|
||||||
|
drawSkyViewBase2D(canvas);
|
||||||
// Draw dot
|
|
||||||
ctx.beginPath();
|
const ctx = canvas.getContext('2d');
|
||||||
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
|
if (!ctx) return;
|
||||||
if (sat.used) {
|
|
||||||
ctx.fillStyle = color;
|
const w = canvas.width;
|
||||||
ctx.fill();
|
const h = canvas.height;
|
||||||
} else {
|
const cx = w / 2;
|
||||||
ctx.strokeStyle = color;
|
const cy = h / 2;
|
||||||
ctx.lineWidth = 1.5;
|
const r = Math.min(cx, cy) - 24;
|
||||||
ctx.stroke();
|
|
||||||
}
|
sats.forEach(sat => {
|
||||||
|
if (sat.elevation == null || sat.azimuth == null) return;
|
||||||
// PRN label
|
|
||||||
ctx.fillStyle = color;
|
const elRad = (90 - sat.elevation) / 90;
|
||||||
ctx.font = '8px Roboto Condensed, monospace';
|
const azRad = (sat.azimuth - 90) * Math.PI / 180;
|
||||||
ctx.textAlign = 'center';
|
const px = cx + r * elRad * Math.cos(azRad);
|
||||||
ctx.textBaseline = 'bottom';
|
const py = cy + r * elRad * Math.sin(azRad);
|
||||||
ctx.fillText(sat.prn, px, py - dotSize - 2);
|
|
||||||
|
const color = CONST_COLORS[sat.constellation] || CONST_COLORS.GPS;
|
||||||
// SNR value
|
const dotSize = sat.used ? 6 : 4;
|
||||||
if (sat.snr != null) {
|
|
||||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
ctx.beginPath();
|
||||||
ctx.font = '7px Roboto Condensed, monospace';
|
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
|
||||||
ctx.textBaseline = 'top';
|
if (sat.used) {
|
||||||
ctx.fillText(Math.round(sat.snr), px, py + dotSize + 1);
|
ctx.fillStyle = color;
|
||||||
}
|
ctx.fill();
|
||||||
});
|
} else {
|
||||||
}
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
function drawSkyViewBase(canvas) {
|
ctx.stroke();
|
||||||
const ctx = canvas.getContext('2d');
|
}
|
||||||
const w = canvas.width;
|
|
||||||
const h = canvas.height;
|
ctx.fillStyle = color;
|
||||||
const cx = w / 2;
|
ctx.font = '8px Roboto Condensed, monospace';
|
||||||
const cy = h / 2;
|
ctx.textAlign = 'center';
|
||||||
const r = Math.min(cx, cy) - 24;
|
ctx.textBaseline = 'bottom';
|
||||||
|
ctx.fillText(sat.prn, px, py - dotSize - 2);
|
||||||
ctx.clearRect(0, 0, w, h);
|
|
||||||
|
if (sat.snr != null) {
|
||||||
const cs = getComputedStyle(document.documentElement);
|
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||||
const bgColor = cs.getPropertyValue('--bg-card').trim() || '#0d1117';
|
ctx.font = '7px Roboto Condensed, monospace';
|
||||||
const gridColor = cs.getPropertyValue('--border-color').trim() || '#2a3040';
|
ctx.textBaseline = 'top';
|
||||||
const dimColor = cs.getPropertyValue('--text-dim').trim() || '#555';
|
ctx.fillText(Math.round(sat.snr), px, py + dotSize + 1);
|
||||||
const secondaryColor = cs.getPropertyValue('--text-secondary').trim() || '#888';
|
}
|
||||||
|
});
|
||||||
// Background
|
}
|
||||||
ctx.fillStyle = bgColor;
|
|
||||||
ctx.fillRect(0, 0, w, h);
|
function drawSkyViewBase2D(canvas) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
// Elevation rings (0, 30, 60, 90)
|
if (!ctx) return;
|
||||||
ctx.strokeStyle = gridColor;
|
|
||||||
ctx.lineWidth = 0.5;
|
const w = canvas.width;
|
||||||
[90, 60, 30].forEach(el => {
|
const h = canvas.height;
|
||||||
const gr = r * (1 - el / 90);
|
const cx = w / 2;
|
||||||
ctx.beginPath();
|
const cy = h / 2;
|
||||||
ctx.arc(cx, cy, gr, 0, Math.PI * 2);
|
const r = Math.min(cx, cy) - 24;
|
||||||
ctx.stroke();
|
|
||||||
// Label
|
ctx.clearRect(0, 0, w, h);
|
||||||
ctx.fillStyle = dimColor;
|
|
||||||
ctx.font = '9px Roboto Condensed, monospace';
|
const cs = getComputedStyle(document.documentElement);
|
||||||
ctx.textAlign = 'left';
|
const bgColor = cs.getPropertyValue('--bg-card').trim() || '#0d1117';
|
||||||
ctx.textBaseline = 'middle';
|
const gridColor = cs.getPropertyValue('--border-color').trim() || '#2a3040';
|
||||||
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
|
const dimColor = cs.getPropertyValue('--text-dim').trim() || '#555';
|
||||||
});
|
const secondaryColor = cs.getPropertyValue('--text-secondary').trim() || '#888';
|
||||||
|
|
||||||
// Horizon circle
|
ctx.fillStyle = bgColor;
|
||||||
ctx.strokeStyle = gridColor;
|
ctx.fillRect(0, 0, w, h);
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.beginPath();
|
ctx.strokeStyle = gridColor;
|
||||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
ctx.lineWidth = 0.5;
|
||||||
ctx.stroke();
|
[90, 60, 30].forEach(el => {
|
||||||
|
const gr = r * (1 - el / 90);
|
||||||
// Cardinal directions
|
ctx.beginPath();
|
||||||
ctx.fillStyle = secondaryColor;
|
ctx.arc(cx, cy, gr, 0, Math.PI * 2);
|
||||||
ctx.font = 'bold 11px Roboto Condensed, monospace';
|
ctx.stroke();
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
ctx.fillStyle = dimColor;
|
||||||
ctx.fillText('N', cx, cy - r - 12);
|
ctx.font = '9px Roboto Condensed, monospace';
|
||||||
ctx.fillText('S', cx, cy + r + 12);
|
ctx.textAlign = 'left';
|
||||||
ctx.fillText('E', cx + r + 12, cy);
|
ctx.textBaseline = 'middle';
|
||||||
ctx.fillText('W', cx - r - 12, cy);
|
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
|
||||||
|
});
|
||||||
// Crosshairs
|
|
||||||
ctx.strokeStyle = gridColor;
|
ctx.strokeStyle = gridColor;
|
||||||
ctx.lineWidth = 0.5;
|
ctx.lineWidth = 1;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(cx, cy - r);
|
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||||
ctx.lineTo(cx, cy + r);
|
ctx.stroke();
|
||||||
ctx.moveTo(cx - r, cy);
|
|
||||||
ctx.lineTo(cx + r, cy);
|
ctx.fillStyle = secondaryColor;
|
||||||
ctx.stroke();
|
ctx.font = 'bold 11px Roboto Condensed, monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
// Zenith dot
|
ctx.textBaseline = 'middle';
|
||||||
ctx.fillStyle = dimColor;
|
ctx.fillText('N', cx, cy - r - 12);
|
||||||
ctx.beginPath();
|
ctx.fillText('S', cx, cy + r + 12);
|
||||||
ctx.arc(cx, cy, 2, 0, Math.PI * 2);
|
ctx.fillText('E', cx + r + 12, cy);
|
||||||
ctx.fill();
|
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
|
// Signal Strength Bars
|
||||||
@@ -439,10 +1073,19 @@ const GPS = (function() {
|
|||||||
// Cleanup
|
// Cleanup
|
||||||
// ========================
|
// ========================
|
||||||
|
|
||||||
function destroy() {
|
function destroy() {
|
||||||
unsubscribeFromStream();
|
unsubscribeFromStream();
|
||||||
stopSkyPolling();
|
stopSkyPolling();
|
||||||
}
|
if (themeObserver) {
|
||||||
|
themeObserver.disconnect();
|
||||||
|
themeObserver = null;
|
||||||
|
}
|
||||||
|
if (skyRenderer) {
|
||||||
|
skyRenderer.destroy();
|
||||||
|
skyRenderer = null;
|
||||||
|
}
|
||||||
|
skyRendererInitAttempted = false;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
init: init,
|
init: init,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -269,12 +269,10 @@ const SpyStations = (function() {
|
|||||||
*/
|
*/
|
||||||
function tuneToStation(stationId, freqKhz) {
|
function tuneToStation(stationId, freqKhz) {
|
||||||
const freqMhz = freqKhz / 1000;
|
const freqMhz = freqKhz / 1000;
|
||||||
sessionStorage.setItem('tuneFrequency', freqMhz.toString());
|
|
||||||
|
|
||||||
// Find the station and determine mode
|
// Find the station and determine mode
|
||||||
const station = stations.find(s => s.id === stationId);
|
const station = stations.find(s => s.id === stationId);
|
||||||
const tuneMode = station ? getModeFromStation(station.mode) : 'usb';
|
const tuneMode = station ? getModeFromStation(station.mode) : 'usb';
|
||||||
sessionStorage.setItem('tuneMode', tuneMode);
|
|
||||||
|
|
||||||
const stationName = station ? station.name : 'Station';
|
const stationName = station ? station.name : 'Station';
|
||||||
|
|
||||||
@@ -282,12 +280,18 @@ const SpyStations = (function() {
|
|||||||
showNotification('Tuning to ' + stationName, formatFrequency(freqKhz) + ' (' + tuneMode.toUpperCase() + ')');
|
showNotification('Tuning to ' + stationName, formatFrequency(freqKhz) + ' (' + tuneMode.toUpperCase() + ')');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch to listening post mode
|
// Switch to spectrum waterfall mode and tune after mode init.
|
||||||
if (typeof selectMode === 'function') {
|
if (typeof switchMode === 'function') {
|
||||||
selectMode('listening');
|
switchMode('waterfall');
|
||||||
} else if (typeof switchMode === 'function') {
|
} else if (typeof selectMode === 'function') {
|
||||||
switchMode('listening');
|
selectMode('waterfall');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (typeof Waterfall !== 'undefined' && typeof Waterfall.quickTune === 'function') {
|
||||||
|
Waterfall.quickTune(freqMhz, tuneMode);
|
||||||
|
}
|
||||||
|
}, 220);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -305,7 +309,7 @@ const SpyStations = (function() {
|
|||||||
* Check if we arrived from another page with a tune request
|
* Check if we arrived from another page with a tune request
|
||||||
*/
|
*/
|
||||||
function checkTuneFrequency() {
|
function checkTuneFrequency() {
|
||||||
// This is for the listening post to check - spy stations sets, listening post reads
|
// Reserved for cross-mode tune handoff behavior.
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -445,7 +449,7 @@ const SpyStations = (function() {
|
|||||||
<div class="signal-details-section">
|
<div class="signal-details-section">
|
||||||
<div class="signal-details-title">How to Listen</div>
|
<div class="signal-details-title">How to Listen</div>
|
||||||
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
|
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
|
||||||
Click "Tune In" on any station to open the Listening Post with the frequency pre-configured.
|
Click "Tune In" on any station to open Spectrum Waterfall with the frequency pre-configured.
|
||||||
Most number stations use USB (Upper Sideband) mode. You'll need an SDR capable of receiving
|
Most number stations use USB (Upper Sideband) mode. You'll need an SDR capable of receiving
|
||||||
HF frequencies (typically 3-30 MHz) and an appropriate antenna.
|
HF frequencies (typically 3-30 MHz) and an appropriate antenna.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+141
-23
@@ -15,13 +15,21 @@ const SSTVGeneral = (function() {
|
|||||||
let sstvGeneralScopeCtx = null;
|
let sstvGeneralScopeCtx = null;
|
||||||
let sstvGeneralScopeAnim = null;
|
let sstvGeneralScopeAnim = null;
|
||||||
let sstvGeneralScopeHistory = [];
|
let sstvGeneralScopeHistory = [];
|
||||||
|
let sstvGeneralScopeWaveBuffer = [];
|
||||||
|
let sstvGeneralScopeDisplayWave = [];
|
||||||
const SSTV_GENERAL_SCOPE_LEN = 200;
|
const SSTV_GENERAL_SCOPE_LEN = 200;
|
||||||
|
const SSTV_GENERAL_SCOPE_WAVE_BUFFER_LEN = 2048;
|
||||||
|
const SSTV_GENERAL_SCOPE_WAVE_INPUT_SMOOTH_ALPHA = 0.55;
|
||||||
|
const SSTV_GENERAL_SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA = 0.22;
|
||||||
|
const SSTV_GENERAL_SCOPE_WAVE_IDLE_DECAY = 0.96;
|
||||||
let sstvGeneralScopeRms = 0;
|
let sstvGeneralScopeRms = 0;
|
||||||
let sstvGeneralScopePeak = 0;
|
let sstvGeneralScopePeak = 0;
|
||||||
let sstvGeneralScopeTargetRms = 0;
|
let sstvGeneralScopeTargetRms = 0;
|
||||||
let sstvGeneralScopeTargetPeak = 0;
|
let sstvGeneralScopeTargetPeak = 0;
|
||||||
let sstvGeneralScopeMsgBurst = 0;
|
let sstvGeneralScopeMsgBurst = 0;
|
||||||
let sstvGeneralScopeTone = null;
|
let sstvGeneralScopeTone = null;
|
||||||
|
let sstvGeneralScopeLastWaveAt = 0;
|
||||||
|
let sstvGeneralScopeLastInputSample = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the SSTV General mode
|
* Initialize the SSTV General mode
|
||||||
@@ -205,20 +213,64 @@ const SSTVGeneral = (function() {
|
|||||||
/**
|
/**
|
||||||
* Initialize signal scope canvas
|
* Initialize signal scope canvas
|
||||||
*/
|
*/
|
||||||
|
function resizeSstvGeneralScopeCanvas(canvas) {
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const width = Math.max(1, Math.floor(rect.width * dpr));
|
||||||
|
const height = Math.max(1, Math.floor(rect.height * dpr));
|
||||||
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySstvGeneralScopeData(scopeData) {
|
||||||
|
if (!scopeData || typeof scopeData !== 'object') return;
|
||||||
|
|
||||||
|
sstvGeneralScopeTargetRms = Number(scopeData.rms) || 0;
|
||||||
|
sstvGeneralScopeTargetPeak = Number(scopeData.peak) || 0;
|
||||||
|
if (scopeData.tone !== undefined) {
|
||||||
|
sstvGeneralScopeTone = scopeData.tone;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(scopeData.waveform) && scopeData.waveform.length) {
|
||||||
|
for (const packedSample of scopeData.waveform) {
|
||||||
|
const sample = Number(packedSample);
|
||||||
|
if (!Number.isFinite(sample)) continue;
|
||||||
|
const normalized = Math.max(-127, Math.min(127, sample)) / 127;
|
||||||
|
sstvGeneralScopeLastInputSample += (normalized - sstvGeneralScopeLastInputSample) * SSTV_GENERAL_SCOPE_WAVE_INPUT_SMOOTH_ALPHA;
|
||||||
|
sstvGeneralScopeWaveBuffer.push(sstvGeneralScopeLastInputSample);
|
||||||
|
}
|
||||||
|
if (sstvGeneralScopeWaveBuffer.length > SSTV_GENERAL_SCOPE_WAVE_BUFFER_LEN) {
|
||||||
|
sstvGeneralScopeWaveBuffer.splice(0, sstvGeneralScopeWaveBuffer.length - SSTV_GENERAL_SCOPE_WAVE_BUFFER_LEN);
|
||||||
|
}
|
||||||
|
sstvGeneralScopeLastWaveAt = performance.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initSstvGeneralScope() {
|
function initSstvGeneralScope() {
|
||||||
const canvas = document.getElementById('sstvGeneralScopeCanvas');
|
const canvas = document.getElementById('sstvGeneralScopeCanvas');
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
canvas.width = rect.width * (window.devicePixelRatio || 1);
|
if (sstvGeneralScopeAnim) {
|
||||||
canvas.height = rect.height * (window.devicePixelRatio || 1);
|
cancelAnimationFrame(sstvGeneralScopeAnim);
|
||||||
|
sstvGeneralScopeAnim = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeSstvGeneralScopeCanvas(canvas);
|
||||||
sstvGeneralScopeCtx = canvas.getContext('2d');
|
sstvGeneralScopeCtx = canvas.getContext('2d');
|
||||||
sstvGeneralScopeHistory = new Array(SSTV_GENERAL_SCOPE_LEN).fill(0);
|
sstvGeneralScopeHistory = new Array(SSTV_GENERAL_SCOPE_LEN).fill(0);
|
||||||
|
sstvGeneralScopeWaveBuffer = [];
|
||||||
|
sstvGeneralScopeDisplayWave = [];
|
||||||
sstvGeneralScopeRms = 0;
|
sstvGeneralScopeRms = 0;
|
||||||
sstvGeneralScopePeak = 0;
|
sstvGeneralScopePeak = 0;
|
||||||
sstvGeneralScopeTargetRms = 0;
|
sstvGeneralScopeTargetRms = 0;
|
||||||
sstvGeneralScopeTargetPeak = 0;
|
sstvGeneralScopeTargetPeak = 0;
|
||||||
sstvGeneralScopeMsgBurst = 0;
|
sstvGeneralScopeMsgBurst = 0;
|
||||||
sstvGeneralScopeTone = null;
|
sstvGeneralScopeTone = null;
|
||||||
|
sstvGeneralScopeLastWaveAt = 0;
|
||||||
|
sstvGeneralScopeLastInputSample = 0;
|
||||||
drawSstvGeneralScope();
|
drawSstvGeneralScope();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,12 +280,14 @@ const SSTVGeneral = (function() {
|
|||||||
function drawSstvGeneralScope() {
|
function drawSstvGeneralScope() {
|
||||||
const ctx = sstvGeneralScopeCtx;
|
const ctx = sstvGeneralScopeCtx;
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
|
resizeSstvGeneralScopeCanvas(ctx.canvas);
|
||||||
const W = ctx.canvas.width;
|
const W = ctx.canvas.width;
|
||||||
const H = ctx.canvas.height;
|
const H = ctx.canvas.height;
|
||||||
const midY = H / 2;
|
const midY = H / 2;
|
||||||
|
|
||||||
// Phosphor persistence
|
// Phosphor persistence
|
||||||
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
|
ctx.fillStyle = 'rgba(5, 5, 16, 0.26)';
|
||||||
ctx.fillRect(0, 0, W, H);
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
// Smooth towards target
|
// Smooth towards target
|
||||||
@@ -256,32 +310,84 @@ const SSTVGeneral = (function() {
|
|||||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Waveform
|
// Envelope
|
||||||
const stepX = W / (SSTV_GENERAL_SCOPE_LEN - 1);
|
const envStepX = W / (SSTV_GENERAL_SCOPE_LEN - 1);
|
||||||
ctx.strokeStyle = '#c080ff';
|
ctx.strokeStyle = 'rgba(168, 110, 255, 0.45)';
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1;
|
||||||
ctx.shadowColor = '#c080ff';
|
|
||||||
ctx.shadowBlur = 4;
|
|
||||||
|
|
||||||
// Upper half
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
||||||
const x = i * stepX;
|
const x = i * envStepX;
|
||||||
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
|
const amp = sstvGeneralScopeHistory[i] * midY * 0.85;
|
||||||
const y = midY - amp;
|
const y = midY - amp;
|
||||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Lower half (mirror)
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
||||||
const x = i * stepX;
|
const x = i * envStepX;
|
||||||
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
|
const amp = sstvGeneralScopeHistory[i] * midY * 0.85;
|
||||||
const y = midY + amp;
|
const y = midY + amp;
|
||||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Actual waveform trace
|
||||||
|
const waveformPointCount = Math.min(Math.max(120, Math.floor(W / 3.2)), 420);
|
||||||
|
if (sstvGeneralScopeWaveBuffer.length > 1) {
|
||||||
|
const waveIsFresh = (performance.now() - sstvGeneralScopeLastWaveAt) < 1000;
|
||||||
|
const sourceLen = sstvGeneralScopeWaveBuffer.length;
|
||||||
|
const sourceWindow = Math.min(sourceLen, 1536);
|
||||||
|
const sourceStart = sourceLen - sourceWindow;
|
||||||
|
|
||||||
|
if (sstvGeneralScopeDisplayWave.length !== waveformPointCount) {
|
||||||
|
sstvGeneralScopeDisplayWave = new Array(waveformPointCount).fill(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < waveformPointCount; i++) {
|
||||||
|
const a = sourceStart + Math.floor((i / waveformPointCount) * sourceWindow);
|
||||||
|
const b = sourceStart + Math.floor(((i + 1) / waveformPointCount) * sourceWindow);
|
||||||
|
const start = Math.max(sourceStart, Math.min(sourceLen - 1, a));
|
||||||
|
const end = Math.max(start + 1, Math.min(sourceLen, b));
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (let j = start; j < end; j++) {
|
||||||
|
sum += sstvGeneralScopeWaveBuffer[j];
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
const targetSample = count > 0 ? (sum / count) : 0;
|
||||||
|
sstvGeneralScopeDisplayWave[i] += (targetSample - sstvGeneralScopeDisplayWave[i]) * SSTV_GENERAL_SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.strokeStyle = waveIsFresh ? '#c080ff' : 'rgba(192, 128, 255, 0.45)';
|
||||||
|
ctx.lineWidth = 1.7;
|
||||||
|
ctx.shadowColor = '#c080ff';
|
||||||
|
ctx.shadowBlur = waveIsFresh ? 6 : 2;
|
||||||
|
|
||||||
|
const stepX = waveformPointCount > 1 ? (W / (waveformPointCount - 1)) : W;
|
||||||
|
ctx.beginPath();
|
||||||
|
const firstY = midY - (sstvGeneralScopeDisplayWave[0] * midY * 0.9);
|
||||||
|
ctx.moveTo(0, firstY);
|
||||||
|
for (let i = 1; i < waveformPointCount - 1; i++) {
|
||||||
|
const x = i * stepX;
|
||||||
|
const y = midY - (sstvGeneralScopeDisplayWave[i] * midY * 0.9);
|
||||||
|
const nx = (i + 1) * stepX;
|
||||||
|
const ny = midY - (sstvGeneralScopeDisplayWave[i + 1] * midY * 0.9);
|
||||||
|
const cx = (x + nx) / 2;
|
||||||
|
const cy = (y + ny) / 2;
|
||||||
|
ctx.quadraticCurveTo(x, y, cx, cy);
|
||||||
|
}
|
||||||
|
const lastX = (waveformPointCount - 1) * stepX;
|
||||||
|
const lastY = midY - (sstvGeneralScopeDisplayWave[waveformPointCount - 1] * midY * 0.9);
|
||||||
|
ctx.lineTo(lastX, lastY);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
if (!waveIsFresh) {
|
||||||
|
for (let i = 0; i < sstvGeneralScopeDisplayWave.length; i++) {
|
||||||
|
sstvGeneralScopeDisplayWave[i] *= SSTV_GENERAL_SCOPE_WAVE_IDLE_DECAY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
ctx.shadowBlur = 0;
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
// Peak indicator
|
// Peak indicator
|
||||||
@@ -317,8 +423,17 @@ const SSTVGeneral = (function() {
|
|||||||
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
|
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
|
||||||
}
|
}
|
||||||
if (statusLabel) {
|
if (statusLabel) {
|
||||||
if (sstvGeneralScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
|
const waveIsFresh = (performance.now() - sstvGeneralScopeLastWaveAt) < 1000;
|
||||||
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
|
if (sstvGeneralScopeRms > 900 && waveIsFresh) {
|
||||||
|
statusLabel.textContent = 'DEMODULATING';
|
||||||
|
statusLabel.style.color = '#c080ff';
|
||||||
|
} else if (sstvGeneralScopeRms > 500) {
|
||||||
|
statusLabel.textContent = 'CARRIER';
|
||||||
|
statusLabel.style.color = '#e0b8ff';
|
||||||
|
} else {
|
||||||
|
statusLabel.textContent = 'QUIET';
|
||||||
|
statusLabel.style.color = '#555';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sstvGeneralScopeAnim = requestAnimationFrame(drawSstvGeneralScope);
|
sstvGeneralScopeAnim = requestAnimationFrame(drawSstvGeneralScope);
|
||||||
@@ -330,6 +445,11 @@ const SSTVGeneral = (function() {
|
|||||||
function stopSstvGeneralScope() {
|
function stopSstvGeneralScope() {
|
||||||
if (sstvGeneralScopeAnim) { cancelAnimationFrame(sstvGeneralScopeAnim); sstvGeneralScopeAnim = null; }
|
if (sstvGeneralScopeAnim) { cancelAnimationFrame(sstvGeneralScopeAnim); sstvGeneralScopeAnim = null; }
|
||||||
sstvGeneralScopeCtx = null;
|
sstvGeneralScopeCtx = null;
|
||||||
|
sstvGeneralScopeWaveBuffer = [];
|
||||||
|
sstvGeneralScopeDisplayWave = [];
|
||||||
|
sstvGeneralScopeHistory = [];
|
||||||
|
sstvGeneralScopeLastWaveAt = 0;
|
||||||
|
sstvGeneralScopeLastInputSample = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -353,9 +473,7 @@ const SSTVGeneral = (function() {
|
|||||||
if (data.type === 'sstv_progress') {
|
if (data.type === 'sstv_progress') {
|
||||||
handleProgress(data);
|
handleProgress(data);
|
||||||
} else if (data.type === 'sstv_scope') {
|
} else if (data.type === 'sstv_scope') {
|
||||||
sstvGeneralScopeTargetRms = data.rms;
|
applySstvGeneralScopeData(data);
|
||||||
sstvGeneralScopeTargetPeak = data.peak;
|
|
||||||
if (data.tone !== undefined) sstvGeneralScopeTone = data.tone;
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse SSE message:', err);
|
console.error('Failed to parse SSE message:', err);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+442
-39
@@ -9,6 +9,20 @@ let websdrMarkers = [];
|
|||||||
let websdrReceivers = [];
|
let websdrReceivers = [];
|
||||||
let websdrInitialized = false;
|
let websdrInitialized = false;
|
||||||
let websdrSpyStationsLoaded = false;
|
let websdrSpyStationsLoaded = false;
|
||||||
|
let websdrMapType = null;
|
||||||
|
let websdrGlobe = null;
|
||||||
|
let websdrGlobePopup = null;
|
||||||
|
let websdrSelectedReceiverIndex = null;
|
||||||
|
let websdrGlobeScriptPromise = null;
|
||||||
|
let websdrResizeObserver = null;
|
||||||
|
let websdrResizeHooked = false;
|
||||||
|
let websdrGlobeFallbackNotified = false;
|
||||||
|
|
||||||
|
const WEBSDR_GLOBE_SCRIPT_URLS = [
|
||||||
|
'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js',
|
||||||
|
'https://cdn.jsdelivr.net/npm/globe.gl@2.33.1/dist/globe.gl.min.js',
|
||||||
|
];
|
||||||
|
const WEBSDR_GLOBE_TEXTURE_URL = '/static/images/globe/earth-dark.jpg';
|
||||||
|
|
||||||
// KiwiSDR audio state
|
// KiwiSDR audio state
|
||||||
let kiwiWebSocket = null;
|
let kiwiWebSocket = null;
|
||||||
@@ -29,54 +43,50 @@ const KIWI_SAMPLE_RATE = 12000;
|
|||||||
|
|
||||||
async function initWebSDR() {
|
async function initWebSDR() {
|
||||||
if (websdrInitialized) {
|
if (websdrInitialized) {
|
||||||
if (websdrMap) {
|
setTimeout(invalidateWebSDRViewport, 100);
|
||||||
setTimeout(() => websdrMap.invalidateSize(), 100);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapEl = document.getElementById('websdrMap');
|
const mapEl = document.getElementById('websdrMap');
|
||||||
if (!mapEl || typeof L === 'undefined') return;
|
if (!mapEl) return;
|
||||||
|
|
||||||
// Calculate minimum zoom so tiles fill the container vertically
|
const globeReady = await ensureWebsdrGlobeLibrary();
|
||||||
const mapHeight = mapEl.clientHeight || 500;
|
|
||||||
const minZoom = Math.ceil(Math.log2(mapHeight / 256));
|
|
||||||
|
|
||||||
websdrMap = L.map('websdrMap', {
|
// Wait for a paint frame so the browser computes layout after the
|
||||||
center: [20, 0],
|
// display:flex change in switchMode. Without this, Globe()(mapEl) can
|
||||||
zoom: Math.max(minZoom, 2),
|
// run before clientWidth/clientHeight are non-zero (especially when
|
||||||
minZoom: Math.max(minZoom, 2),
|
// scripts are served from cache and resolve before the first layout pass).
|
||||||
zoomControl: true,
|
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||||
maxBounds: [[-85, -360], [85, 360]],
|
|
||||||
maxBoundsViscosity: 1.0,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
|
// If the mode was switched away while scripts were loading, abort so
|
||||||
await Settings.init();
|
// websdrInitialized stays false and we retry cleanly next time.
|
||||||
Settings.createTileLayer().addTo(websdrMap);
|
if (!mapEl.clientWidth || !mapEl.clientHeight) return;
|
||||||
Settings.registerMap(websdrMap);
|
|
||||||
|
if (globeReady && initWebsdrGlobe(mapEl)) {
|
||||||
|
websdrMapType = 'globe';
|
||||||
|
} else if (typeof L !== 'undefined' && await initWebsdrLeaflet(mapEl)) {
|
||||||
|
websdrMapType = 'leaflet';
|
||||||
|
if (!websdrGlobeFallbackNotified && typeof showNotification === 'function') {
|
||||||
|
showNotification('WebSDR', '3D globe unavailable, using fallback map');
|
||||||
|
websdrGlobeFallbackNotified = true;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
console.error('[WEBSDR] Unable to initialize globe or map renderer');
|
||||||
attribution: '© OpenStreetMap contributors © CARTO',
|
return;
|
||||||
subdomains: 'abcd',
|
|
||||||
maxZoom: 19,
|
|
||||||
className: 'tile-layer-cyan',
|
|
||||||
}).addTo(websdrMap);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match background to tile ocean color so any remaining edge is seamless
|
|
||||||
mapEl.style.background = '#1a1d29';
|
|
||||||
|
|
||||||
websdrInitialized = true;
|
websdrInitialized = true;
|
||||||
|
|
||||||
if (!websdrSpyStationsLoaded) {
|
if (!websdrSpyStationsLoaded) {
|
||||||
loadSpyStationPresets();
|
loadSpyStationPresets();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupWebsdrResizeHandling(mapEl);
|
||||||
|
if (websdrReceivers.length > 0) {
|
||||||
|
plotReceiversOnMap(websdrReceivers);
|
||||||
|
}
|
||||||
[100, 300, 600, 1000].forEach(delay => {
|
[100, 300, 600, 1000].forEach(delay => {
|
||||||
setTimeout(() => {
|
setTimeout(invalidateWebSDRViewport, delay);
|
||||||
if (websdrMap) websdrMap.invalidateSize();
|
|
||||||
}, delay);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +104,8 @@ function searchReceivers(refresh) {
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
websdrReceivers = data.receivers || [];
|
websdrReceivers = data.receivers || [];
|
||||||
|
websdrSelectedReceiverIndex = null;
|
||||||
|
hideWebsdrGlobePopup();
|
||||||
renderReceiverList(websdrReceivers);
|
renderReceiverList(websdrReceivers);
|
||||||
plotReceiversOnMap(websdrReceivers);
|
plotReceiversOnMap(websdrReceivers);
|
||||||
|
|
||||||
@@ -107,6 +119,11 @@ function searchReceivers(refresh) {
|
|||||||
// ============== MAP ==============
|
// ============== MAP ==============
|
||||||
|
|
||||||
function plotReceiversOnMap(receivers) {
|
function plotReceiversOnMap(receivers) {
|
||||||
|
if (websdrMapType === 'globe' && websdrGlobe) {
|
||||||
|
plotReceiversOnGlobe(receivers);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!websdrMap) return;
|
if (!websdrMap) return;
|
||||||
|
|
||||||
websdrMarkers.forEach(m => websdrMap.removeLayer(m));
|
websdrMarkers.forEach(m => websdrMap.removeLayer(m));
|
||||||
@@ -144,6 +161,369 @@ function plotReceiversOnMap(receivers) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureWebsdrGlobeLibrary() {
|
||||||
|
if (typeof window.Globe === 'function') return true;
|
||||||
|
if (!isWebglSupported()) return false;
|
||||||
|
|
||||||
|
if (!websdrGlobeScriptPromise) {
|
||||||
|
websdrGlobeScriptPromise = WEBSDR_GLOBE_SCRIPT_URLS
|
||||||
|
.reduce(
|
||||||
|
(promise, src) => promise.then(() => loadWebsdrScript(src)),
|
||||||
|
Promise.resolve()
|
||||||
|
)
|
||||||
|
.then(() => typeof window.Globe === 'function')
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn('[WEBSDR] Failed to load globe scripts:', error);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = await websdrGlobeScriptPromise;
|
||||||
|
if (!loaded) {
|
||||||
|
websdrGlobeScriptPromise = null;
|
||||||
|
}
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadWebsdrScript(src) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const selector = `script[data-websdr-src="${src}"]`;
|
||||||
|
const existing = document.querySelector(selector);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.dataset.loaded === 'true') {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (existing.dataset.failed === 'true') {
|
||||||
|
existing.remove();
|
||||||
|
} else {
|
||||||
|
existing.addEventListener('load', () => resolve(), { once: true });
|
||||||
|
existing.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)), { once: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = src;
|
||||||
|
script.async = true;
|
||||||
|
script.crossOrigin = 'anonymous';
|
||||||
|
script.dataset.websdrSrc = src;
|
||||||
|
script.onload = () => {
|
||||||
|
script.dataset.loaded = 'true';
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
script.onerror = () => {
|
||||||
|
script.dataset.failed = 'true';
|
||||||
|
reject(new Error(`Failed to load ${src}`));
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWebglSupported() {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
return !!(canvas.getContext('webgl') || canvas.getContext('experimental-webgl'));
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initWebsdrGlobe(mapEl) {
|
||||||
|
if (typeof window.Globe !== 'function' || !isWebglSupported()) return false;
|
||||||
|
|
||||||
|
mapEl.innerHTML = '';
|
||||||
|
mapEl.style.background = 'radial-gradient(circle at 30% 20%, rgba(14, 42, 68, 0.9), rgba(4, 9, 16, 0.95) 58%, rgba(2, 4, 9, 0.98) 100%)';
|
||||||
|
mapEl.style.cursor = 'grab';
|
||||||
|
|
||||||
|
websdrGlobe = window.Globe()(mapEl)
|
||||||
|
.backgroundColor('rgba(0,0,0,0)')
|
||||||
|
.globeImageUrl(WEBSDR_GLOBE_TEXTURE_URL)
|
||||||
|
.showAtmosphere(true)
|
||||||
|
.atmosphereColor('#3bb9ff')
|
||||||
|
.atmosphereAltitude(0.17)
|
||||||
|
.pointRadius('radius')
|
||||||
|
.pointAltitude('altitude')
|
||||||
|
.pointColor('color')
|
||||||
|
.pointsTransitionDuration(250)
|
||||||
|
.pointLabel(point => point.label || '')
|
||||||
|
.onPointHover(point => {
|
||||||
|
mapEl.style.cursor = point ? 'pointer' : 'grab';
|
||||||
|
})
|
||||||
|
.onPointClick((point, event) => {
|
||||||
|
if (!point) return;
|
||||||
|
showWebsdrGlobePopup(point, event);
|
||||||
|
});
|
||||||
|
|
||||||
|
const controls = websdrGlobe.controls();
|
||||||
|
if (controls) {
|
||||||
|
controls.autoRotate = true;
|
||||||
|
controls.autoRotateSpeed = 0.25;
|
||||||
|
controls.enablePan = false;
|
||||||
|
controls.minDistance = 140;
|
||||||
|
controls.maxDistance = 380;
|
||||||
|
controls.rotateSpeed = 0.7;
|
||||||
|
controls.zoomSpeed = 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureWebsdrGlobePopup(mapEl);
|
||||||
|
resizeWebsdrGlobe();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initWebsdrLeaflet(mapEl) {
|
||||||
|
if (typeof L === 'undefined') return false;
|
||||||
|
|
||||||
|
mapEl.innerHTML = '';
|
||||||
|
const mapHeight = mapEl.clientHeight || 500;
|
||||||
|
const minZoom = Math.ceil(Math.log2(mapHeight / 256));
|
||||||
|
|
||||||
|
websdrMap = L.map('websdrMap', {
|
||||||
|
center: [20, 0],
|
||||||
|
zoom: Math.max(minZoom, 2),
|
||||||
|
minZoom: Math.max(minZoom, 2),
|
||||||
|
zoomControl: true,
|
||||||
|
maxBounds: [[-85, -360], [85, 360]],
|
||||||
|
maxBoundsViscosity: 1.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
|
||||||
|
await Settings.init();
|
||||||
|
Settings.createTileLayer().addTo(websdrMap);
|
||||||
|
Settings.registerMap(websdrMap);
|
||||||
|
} else {
|
||||||
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||||
|
attribution: '© OpenStreetMap contributors © CARTO',
|
||||||
|
subdomains: 'abcd',
|
||||||
|
maxZoom: 19,
|
||||||
|
className: 'tile-layer-cyan',
|
||||||
|
}).addTo(websdrMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
mapEl.style.background = '#1a1d29';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupWebsdrResizeHandling(mapEl) {
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
if (websdrResizeObserver) {
|
||||||
|
websdrResizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
websdrResizeObserver = new ResizeObserver(() => invalidateWebSDRViewport());
|
||||||
|
websdrResizeObserver.observe(mapEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!websdrResizeHooked) {
|
||||||
|
window.addEventListener('resize', invalidateWebSDRViewport);
|
||||||
|
window.addEventListener('orientationchange', () => setTimeout(invalidateWebSDRViewport, 120));
|
||||||
|
websdrResizeHooked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateWebSDRViewport() {
|
||||||
|
if (websdrMapType === 'globe') {
|
||||||
|
resizeWebsdrGlobe();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (websdrMap && typeof websdrMap.invalidateSize === 'function') {
|
||||||
|
websdrMap.invalidateSize({ pan: false, animate: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeWebsdrGlobe() {
|
||||||
|
if (!websdrGlobe) return;
|
||||||
|
const mapEl = document.getElementById('websdrMap');
|
||||||
|
if (!mapEl) return;
|
||||||
|
|
||||||
|
const width = mapEl.clientWidth;
|
||||||
|
const height = mapEl.clientHeight;
|
||||||
|
if (!width || !height) return;
|
||||||
|
|
||||||
|
websdrGlobe.width(width);
|
||||||
|
websdrGlobe.height(height);
|
||||||
|
}
|
||||||
|
|
||||||
|
function plotReceiversOnGlobe(receivers) {
|
||||||
|
if (!websdrGlobe) return;
|
||||||
|
|
||||||
|
const points = [];
|
||||||
|
receivers.forEach((rx, idx) => {
|
||||||
|
const lat = Number(rx.lat);
|
||||||
|
const lon = Number(rx.lon);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
|
||||||
|
|
||||||
|
const selected = idx === websdrSelectedReceiverIndex;
|
||||||
|
points.push({
|
||||||
|
lat: lat,
|
||||||
|
lng: lon,
|
||||||
|
receiverIndex: idx,
|
||||||
|
radius: selected ? 0.52 : 0.38,
|
||||||
|
altitude: selected ? 0.1 : 0.04,
|
||||||
|
color: selected ? '#00ff88' : (rx.available ? '#00d4ff' : '#5f6976'),
|
||||||
|
label: buildWebsdrPointLabel(rx, idx),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
websdrGlobe.pointsData(points);
|
||||||
|
|
||||||
|
if (points.length > 0) {
|
||||||
|
if (websdrSelectedReceiverIndex != null) {
|
||||||
|
const selectedPoint = points.find(point => point.receiverIndex === websdrSelectedReceiverIndex);
|
||||||
|
if (selectedPoint) {
|
||||||
|
websdrGlobe.pointOfView({ lat: selectedPoint.lat, lng: selectedPoint.lng, altitude: 1.45 }, 900);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = computeWebsdrGlobeCenter(points);
|
||||||
|
websdrGlobe.pointOfView(center, 900);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeWebsdrGlobeCenter(points) {
|
||||||
|
if (!points.length) return { lat: 20, lng: 0, altitude: 2.1 };
|
||||||
|
|
||||||
|
let x = 0;
|
||||||
|
let y = 0;
|
||||||
|
let z = 0;
|
||||||
|
points.forEach(point => {
|
||||||
|
const latRad = point.lat * Math.PI / 180;
|
||||||
|
const lonRad = point.lng * Math.PI / 180;
|
||||||
|
x += Math.cos(latRad) * Math.cos(lonRad);
|
||||||
|
y += Math.cos(latRad) * Math.sin(lonRad);
|
||||||
|
z += Math.sin(latRad);
|
||||||
|
});
|
||||||
|
|
||||||
|
const count = points.length;
|
||||||
|
x /= count;
|
||||||
|
y /= count;
|
||||||
|
z /= count;
|
||||||
|
|
||||||
|
const hyp = Math.sqrt((x * x) + (y * y));
|
||||||
|
const centerLat = Math.atan2(z, hyp) * 180 / Math.PI;
|
||||||
|
const centerLng = Math.atan2(y, x) * 180 / Math.PI;
|
||||||
|
|
||||||
|
let meanAngularDistance = 0;
|
||||||
|
const centerLatRad = centerLat * Math.PI / 180;
|
||||||
|
const centerLngRad = centerLng * Math.PI / 180;
|
||||||
|
points.forEach(point => {
|
||||||
|
const latRad = point.lat * Math.PI / 180;
|
||||||
|
const lonRad = point.lng * Math.PI / 180;
|
||||||
|
const cosAngle = (
|
||||||
|
(Math.sin(centerLatRad) * Math.sin(latRad)) +
|
||||||
|
(Math.cos(centerLatRad) * Math.cos(latRad) * Math.cos(lonRad - centerLngRad))
|
||||||
|
);
|
||||||
|
const safeCos = Math.max(-1, Math.min(1, cosAngle));
|
||||||
|
meanAngularDistance += Math.acos(safeCos) * 180 / Math.PI;
|
||||||
|
});
|
||||||
|
meanAngularDistance /= count;
|
||||||
|
|
||||||
|
const altitude = Math.min(2.9, Math.max(1.35, 1.35 + (meanAngularDistance / 45)));
|
||||||
|
return { lat: centerLat, lng: centerLng, altitude: altitude };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureWebsdrGlobePopup(mapEl) {
|
||||||
|
if (websdrGlobePopup) {
|
||||||
|
if (websdrGlobePopup.parentElement !== mapEl) {
|
||||||
|
mapEl.appendChild(websdrGlobePopup);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
websdrGlobePopup = document.createElement('div');
|
||||||
|
websdrGlobePopup.id = 'websdrGlobePopup';
|
||||||
|
websdrGlobePopup.style.position = 'absolute';
|
||||||
|
websdrGlobePopup.style.minWidth = '220px';
|
||||||
|
websdrGlobePopup.style.maxWidth = '260px';
|
||||||
|
websdrGlobePopup.style.padding = '10px';
|
||||||
|
websdrGlobePopup.style.borderRadius = '8px';
|
||||||
|
websdrGlobePopup.style.border = '1px solid rgba(0, 212, 255, 0.35)';
|
||||||
|
websdrGlobePopup.style.background = 'rgba(5, 13, 20, 0.92)';
|
||||||
|
websdrGlobePopup.style.backdropFilter = 'blur(4px)';
|
||||||
|
websdrGlobePopup.style.boxShadow = '0 8px 24px rgba(0, 0, 0, 0.4)';
|
||||||
|
websdrGlobePopup.style.color = 'var(--text-primary)';
|
||||||
|
websdrGlobePopup.style.display = 'none';
|
||||||
|
websdrGlobePopup.style.zIndex = '20';
|
||||||
|
mapEl.appendChild(websdrGlobePopup);
|
||||||
|
|
||||||
|
if (!mapEl.dataset.websdrPopupHooked) {
|
||||||
|
mapEl.addEventListener('click', (event) => {
|
||||||
|
if (!websdrGlobePopup || websdrGlobePopup.style.display === 'none') return;
|
||||||
|
if (event.target.closest('#websdrGlobePopup')) return;
|
||||||
|
hideWebsdrGlobePopup();
|
||||||
|
});
|
||||||
|
mapEl.dataset.websdrPopupHooked = 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWebsdrGlobePopup(point, event) {
|
||||||
|
if (!websdrGlobePopup || !point || point.receiverIndex == null) return;
|
||||||
|
const rx = websdrReceivers[point.receiverIndex];
|
||||||
|
if (!rx) return;
|
||||||
|
|
||||||
|
const mapEl = document.getElementById('websdrMap');
|
||||||
|
if (!mapEl) return;
|
||||||
|
|
||||||
|
websdrSelectedReceiverIndex = point.receiverIndex;
|
||||||
|
renderReceiverList(websdrReceivers);
|
||||||
|
plotReceiversOnGlobe(websdrReceivers);
|
||||||
|
|
||||||
|
websdrGlobePopup.innerHTML = `
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: start; gap: 10px; margin-bottom: 6px;">
|
||||||
|
<strong style="font-size: 12px; color: var(--accent-cyan);">${escapeHtmlWebsdr(rx.name)}</strong>
|
||||||
|
<button type="button" data-websdr-popup-close style="border: none; background: transparent; color: var(--text-muted); cursor: pointer; font-size: 14px; line-height: 1;">×</button>
|
||||||
|
</div>
|
||||||
|
${rx.location ? `<div style="font-size: 10px; color: var(--text-secondary); margin-bottom: 3px;">${escapeHtmlWebsdr(rx.location)}</div>` : ''}
|
||||||
|
<div style="font-size: 10px; color: var(--text-muted); margin-bottom: 2px;">Antenna: ${escapeHtmlWebsdr(rx.antenna || 'Unknown')}</div>
|
||||||
|
<div style="font-size: 10px; color: var(--text-muted); margin-bottom: 10px;">Users: ${rx.users}/${rx.users_max}</div>
|
||||||
|
<button type="button" data-websdr-listen style="width: 100%; padding: 5px 10px; background: #00d4ff; color: #041018; border: none; border-radius: 4px; cursor: pointer; font-weight: 700;">Listen</button>
|
||||||
|
`;
|
||||||
|
websdrGlobePopup.style.display = 'block';
|
||||||
|
|
||||||
|
const rect = mapEl.getBoundingClientRect();
|
||||||
|
const x = event && Number.isFinite(event.clientX) ? (event.clientX - rect.left) : (rect.width / 2);
|
||||||
|
const y = event && Number.isFinite(event.clientY) ? (event.clientY - rect.top) : (rect.height / 2);
|
||||||
|
const popupWidth = 260;
|
||||||
|
const popupHeight = 155;
|
||||||
|
const left = Math.max(12, Math.min(rect.width - popupWidth - 12, x + 12));
|
||||||
|
const top = Math.max(12, Math.min(rect.height - popupHeight - 12, y + 12));
|
||||||
|
websdrGlobePopup.style.left = `${left}px`;
|
||||||
|
websdrGlobePopup.style.top = `${top}px`;
|
||||||
|
|
||||||
|
const closeBtn = websdrGlobePopup.querySelector('[data-websdr-popup-close]');
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.onclick = () => hideWebsdrGlobePopup();
|
||||||
|
}
|
||||||
|
const listenBtn = websdrGlobePopup.querySelector('[data-websdr-listen]');
|
||||||
|
if (listenBtn) {
|
||||||
|
listenBtn.onclick = () => selectReceiver(point.receiverIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event && typeof event.stopPropagation === 'function') {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideWebsdrGlobePopup() {
|
||||||
|
if (websdrGlobePopup) {
|
||||||
|
websdrGlobePopup.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWebsdrPointLabel(rx, idx) {
|
||||||
|
const location = rx.location ? escapeHtmlWebsdr(rx.location) : 'Unknown location';
|
||||||
|
const antenna = escapeHtmlWebsdr(rx.antenna || 'Unknown antenna');
|
||||||
|
return `
|
||||||
|
<div style="padding: 4px 6px; font-size: 11px; background: rgba(4, 12, 19, 0.9); border: 1px solid rgba(0,212,255,0.28); border-radius: 4px;">
|
||||||
|
<div style="color: #00d4ff; font-weight: 600;">${escapeHtmlWebsdr(rx.name)}</div>
|
||||||
|
<div style="color: #a5b1c3;">${location}</div>
|
||||||
|
<div style="color: #8f9fb3;">${antenna} · ${rx.users}/${rx.users_max}</div>
|
||||||
|
<div style="color: #7a899b; margin-top: 2px;">Receiver #${idx + 1}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
// ============== RECEIVER LIST ==============
|
// ============== RECEIVER LIST ==============
|
||||||
|
|
||||||
function renderReceiverList(receivers) {
|
function renderReceiverList(receivers) {
|
||||||
@@ -155,12 +535,16 @@ function renderReceiverList(receivers) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = receivers.slice(0, 50).map((rx, idx) => `
|
container.innerHTML = receivers.slice(0, 50).map((rx, idx) => {
|
||||||
<div style="padding: 8px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; transition: background 0.2s;"
|
const selected = idx === websdrSelectedReceiverIndex;
|
||||||
onmouseover="this.style.background='rgba(0,212,255,0.05)'" onmouseout="this.style.background='transparent'"
|
const baseBg = selected ? 'rgba(0,212,255,0.14)' : 'transparent';
|
||||||
|
const hoverBg = selected ? 'rgba(0,212,255,0.18)' : 'rgba(0,212,255,0.05)';
|
||||||
|
return `
|
||||||
|
<div style="padding: 8px 8px 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; transition: background 0.2s; border-left: 2px solid ${selected ? 'var(--accent-cyan)' : 'transparent'}; background: ${baseBg};"
|
||||||
|
onmouseover="this.style.background='${hoverBg}'" onmouseout="this.style.background='${baseBg}'"
|
||||||
onclick="selectReceiver(${idx})">
|
onclick="selectReceiver(${idx})">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
<strong style="font-size: 11px; color: var(--text-primary);">${escapeHtmlWebsdr(rx.name)}</strong>
|
<strong style="font-size: 11px; color: ${selected ? 'var(--accent-cyan)' : 'var(--text-primary)'};">${escapeHtmlWebsdr(rx.name)}</strong>
|
||||||
<span style="font-size: 9px; padding: 1px 6px; background: ${rx.available ? 'rgba(0,230,118,0.15)' : 'rgba(158,158,158,0.15)'}; color: ${rx.available ? '#00e676' : '#9e9e9e'}; border-radius: 3px;">${rx.users}/${rx.users_max}</span>
|
<span style="font-size: 9px; padding: 1px 6px; background: ${rx.available ? 'rgba(0,230,118,0.15)' : 'rgba(158,158,158,0.15)'}; color: ${rx.available ? '#00e676' : '#9e9e9e'}; border-radius: 3px;">${rx.users}/${rx.users_max}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size: 9px; color: var(--text-muted); margin-top: 2px;">
|
<div style="font-size: 9px; color: var(--text-muted); margin-top: 2px;">
|
||||||
@@ -168,7 +552,8 @@ function renderReceiverList(receivers) {
|
|||||||
${rx.distance_km !== undefined ? ` · ${rx.distance_km} km` : ''}
|
${rx.distance_km !== undefined ? ` · ${rx.distance_km} km` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============== SELECT RECEIVER ==============
|
// ============== SELECT RECEIVER ==============
|
||||||
@@ -180,14 +565,30 @@ function selectReceiver(index) {
|
|||||||
const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 7000);
|
const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 7000);
|
||||||
const mode = document.getElementById('websdrMode_select')?.value || 'am';
|
const mode = document.getElementById('websdrMode_select')?.value || 'am';
|
||||||
|
|
||||||
|
websdrSelectedReceiverIndex = index;
|
||||||
|
renderReceiverList(websdrReceivers);
|
||||||
|
focusReceiverOnMap(rx);
|
||||||
|
hideWebsdrGlobePopup();
|
||||||
|
|
||||||
kiwiReceiverName = rx.name;
|
kiwiReceiverName = rx.name;
|
||||||
|
|
||||||
// Connect via backend proxy
|
// Connect via backend proxy
|
||||||
connectToReceiver(rx.url, freqKhz, mode);
|
connectToReceiver(rx.url, freqKhz, mode);
|
||||||
|
}
|
||||||
|
|
||||||
// Highlight on map
|
function focusReceiverOnMap(rx) {
|
||||||
if (websdrMap && rx.lat != null && rx.lon != null) {
|
const lat = Number(rx.lat);
|
||||||
websdrMap.setView([rx.lat, rx.lon], 6);
|
const lon = Number(rx.lon);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
|
||||||
|
|
||||||
|
if (websdrMapType === 'globe' && websdrGlobe) {
|
||||||
|
plotReceiversOnGlobe(websdrReceivers);
|
||||||
|
websdrGlobe.pointOfView({ lat: lat, lng: lon, altitude: 1.4 }, 900);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (websdrMap) {
|
||||||
|
websdrMap.setView([lat, lon], 6);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,6 +952,8 @@ function tuneToSpyStation(stationId, freqKhz) {
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
websdrReceivers = data.receivers || [];
|
websdrReceivers = data.receivers || [];
|
||||||
|
websdrSelectedReceiverIndex = null;
|
||||||
|
hideWebsdrGlobePopup();
|
||||||
renderReceiverList(websdrReceivers);
|
renderReceiverList(websdrReceivers);
|
||||||
plotReceiversOnMap(websdrReceivers);
|
plotReceiversOnMap(websdrReceivers);
|
||||||
|
|
||||||
|
|||||||
+37
-22
@@ -572,8 +572,8 @@ const WiFiMode = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopScan() {
|
async function stopScan() {
|
||||||
console.log('[WiFiMode] Stopping scan...');
|
console.log('[WiFiMode] Stopping scan...');
|
||||||
|
|
||||||
// Stop polling
|
// Stop polling
|
||||||
if (pollTimer) {
|
if (pollTimer) {
|
||||||
@@ -585,26 +585,41 @@ const WiFiMode = (function() {
|
|||||||
stopAgentDeepScanPolling();
|
stopAgentDeepScanPolling();
|
||||||
|
|
||||||
// Close event stream
|
// Close event stream
|
||||||
if (eventSource) {
|
if (eventSource) {
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
eventSource = null;
|
eventSource = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop scan on server (local or agent)
|
// Update UI immediately so mode transitions are responsive even if the
|
||||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
// backend needs extra time to terminate subprocesses.
|
||||||
|
setScanning(false);
|
||||||
try {
|
|
||||||
if (isAgentMode) {
|
// Stop scan on server (local or agent)
|
||||||
await fetch(`/controller/agents/${currentAgent}/wifi/stop`, { method: 'POST' });
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
} else if (scanMode === 'deep') {
|
const timeoutMs = isAgentMode ? 8000 : 2200;
|
||||||
await fetch(`${CONFIG.apiBase}/scan/stop`, { method: 'POST' });
|
const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
||||||
}
|
const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
||||||
} catch (error) {
|
|
||||||
console.warn('[WiFiMode] Error stopping scan:', error);
|
try {
|
||||||
}
|
if (isAgentMode) {
|
||||||
|
await fetch(`/controller/agents/${currentAgent}/wifi/stop`, {
|
||||||
setScanning(false);
|
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) {
|
function setScanning(scanning, mode = null) {
|
||||||
isScanning = scanning;
|
isScanning = scanning;
|
||||||
|
|||||||
Vendored
+297
@@ -0,0 +1,297 @@
|
|||||||
|
/*
|
||||||
|
* Leaflet.heat — a tiny, fast Leaflet heatmap plugin
|
||||||
|
* https://github.com/Leaflet/Leaflet.heat
|
||||||
|
* (c) 2014, Vladimir Agafonkin
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Bundled local copy for INTERCEPT — avoids CDN dependency.
|
||||||
|
* Includes simpleheat (https://github.com/mourner/simpleheat), MIT License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---- simpleheat ----
|
||||||
|
(function (global, factory) {
|
||||||
|
typeof define === 'function' && define.amd ? define(factory) :
|
||||||
|
typeof exports !== 'undefined' ? module.exports = factory() :
|
||||||
|
global.simpleheat = factory();
|
||||||
|
}(this, function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function simpleheat(canvas) {
|
||||||
|
if (!(this instanceof simpleheat)) return new simpleheat(canvas);
|
||||||
|
this._canvas = canvas = typeof canvas === 'string' ? document.getElementById(canvas) : canvas;
|
||||||
|
this._ctx = canvas.getContext('2d');
|
||||||
|
this._width = canvas.width;
|
||||||
|
this._height = canvas.height;
|
||||||
|
this._max = 1;
|
||||||
|
this._data = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
simpleheat.prototype = {
|
||||||
|
defaultRadius: 25,
|
||||||
|
defaultGradient: {
|
||||||
|
0.4: 'blue',
|
||||||
|
0.6: 'cyan',
|
||||||
|
0.7: 'lime',
|
||||||
|
0.8: 'yellow',
|
||||||
|
1.0: 'red'
|
||||||
|
},
|
||||||
|
|
||||||
|
data: function (data) {
|
||||||
|
this._data = data;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
max: function (max) {
|
||||||
|
this._max = max;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
add: function (point) {
|
||||||
|
this._data.push(point);
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
clear: function () {
|
||||||
|
this._data = [];
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
radius: function (r, blur) {
|
||||||
|
blur = blur === undefined ? 15 : blur;
|
||||||
|
var circle = this._circle = this._createCanvas(),
|
||||||
|
ctx = circle.getContext('2d'),
|
||||||
|
r2 = this._r = r + blur;
|
||||||
|
circle.width = circle.height = r2 * 2;
|
||||||
|
ctx.shadowOffsetX = ctx.shadowOffsetY = r2 * 2;
|
||||||
|
ctx.shadowBlur = blur;
|
||||||
|
ctx.shadowColor = 'black';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(-r2, -r2, r, 0, Math.PI * 2, true);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
resize: function () {
|
||||||
|
this._width = this._canvas.width;
|
||||||
|
this._height = this._canvas.height;
|
||||||
|
},
|
||||||
|
|
||||||
|
gradient: function (grad) {
|
||||||
|
var canvas = this._createCanvas(),
|
||||||
|
ctx = canvas.getContext('2d'),
|
||||||
|
gradient = ctx.createLinearGradient(0, 0, 0, 256);
|
||||||
|
canvas.width = 1;
|
||||||
|
canvas.height = 256;
|
||||||
|
for (var i in grad) {
|
||||||
|
gradient.addColorStop(+i, grad[i]);
|
||||||
|
}
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, 1, 256);
|
||||||
|
this._grad = ctx.getImageData(0, 0, 1, 256).data;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
draw: function (minOpacity) {
|
||||||
|
if (!this._circle) this.radius(this.defaultRadius);
|
||||||
|
if (!this._grad) this.gradient(this.defaultGradient);
|
||||||
|
|
||||||
|
var ctx = this._ctx;
|
||||||
|
ctx.clearRect(0, 0, this._width, this._height);
|
||||||
|
|
||||||
|
for (var i = 0, len = this._data.length, p; i < len; i++) {
|
||||||
|
p = this._data[i];
|
||||||
|
ctx.globalAlpha = Math.min(Math.max(p[2] / this._max, minOpacity === undefined ? 0.05 : minOpacity), 1);
|
||||||
|
ctx.drawImage(this._circle, p[0] - this._r, p[1] - this._r);
|
||||||
|
}
|
||||||
|
|
||||||
|
var colored = ctx.getImageData(0, 0, this._width, this._height);
|
||||||
|
this._colorize(colored.data, this._grad);
|
||||||
|
ctx.putImageData(colored, 0, 0);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
_colorize: function (pixels, gradient) {
|
||||||
|
for (var i = 3, len = pixels.length, j; i < len; i += 4) {
|
||||||
|
j = pixels[i] * 4;
|
||||||
|
if (j) {
|
||||||
|
pixels[i - 3] = gradient[j];
|
||||||
|
pixels[i - 2] = gradient[j + 1];
|
||||||
|
pixels[i - 1] = gradient[j + 2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_createCanvas: function () {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
return document.createElement('canvas');
|
||||||
|
}
|
||||||
|
return { getContext: function () {} };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return simpleheat;
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---- Leaflet.heat plugin ----
|
||||||
|
(function () {
|
||||||
|
if (typeof L === 'undefined') return;
|
||||||
|
|
||||||
|
L.HeatLayer = (L.Layer ? L.Layer : L.Class).extend({
|
||||||
|
initialize: function (latlngs, options) {
|
||||||
|
this._latlngs = latlngs;
|
||||||
|
L.setOptions(this, options);
|
||||||
|
},
|
||||||
|
|
||||||
|
setLatLngs: function (latlngs) {
|
||||||
|
this._latlngs = latlngs;
|
||||||
|
return this.redraw();
|
||||||
|
},
|
||||||
|
|
||||||
|
addLatLng: function (latlng) {
|
||||||
|
this._latlngs.push(latlng);
|
||||||
|
return this.redraw();
|
||||||
|
},
|
||||||
|
|
||||||
|
setOptions: function (options) {
|
||||||
|
L.setOptions(this, options);
|
||||||
|
if (this._heat) this._updateOptions();
|
||||||
|
return this.redraw();
|
||||||
|
},
|
||||||
|
|
||||||
|
redraw: function () {
|
||||||
|
if (this._heat && !this._frame && this._map && !this._map._animating) {
|
||||||
|
this._frame = L.Util.requestAnimFrame(this._redraw, this);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
onAdd: function (map) {
|
||||||
|
this._map = map;
|
||||||
|
if (!this._canvas) this._initCanvas();
|
||||||
|
if (this.options.pane) this.getPane().appendChild(this._canvas);
|
||||||
|
else map._panes.overlayPane.appendChild(this._canvas);
|
||||||
|
map.on('moveend', this._reset, this);
|
||||||
|
if (map.options.zoomAnimation && L.Browser.any3d) {
|
||||||
|
map.on('zoomanim', this._animateZoom, this);
|
||||||
|
}
|
||||||
|
this._reset();
|
||||||
|
},
|
||||||
|
|
||||||
|
onRemove: function (map) {
|
||||||
|
if (this.options.pane) this.getPane().removeChild(this._canvas);
|
||||||
|
else map.getPanes().overlayPane.removeChild(this._canvas);
|
||||||
|
map.off('moveend', this._reset, this);
|
||||||
|
if (map.options.zoomAnimation) {
|
||||||
|
map.off('zoomanim', this._animateZoom, this);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addTo: function (map) {
|
||||||
|
map.addLayer(this);
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
_initCanvas: function () {
|
||||||
|
var canvas = this._canvas = L.DomUtil.create('canvas', 'leaflet-heatmap-layer leaflet-layer');
|
||||||
|
var originProp = L.DomUtil.testProp(['transformOrigin', 'WebkitTransformOrigin', 'msTransformOrigin']);
|
||||||
|
canvas.style[originProp] = '50% 50%';
|
||||||
|
var size = this._map.getSize();
|
||||||
|
canvas.width = size.x;
|
||||||
|
canvas.height = size.y;
|
||||||
|
var animated = this._map.options.zoomAnimation && L.Browser.any3d;
|
||||||
|
L.DomUtil.addClass(canvas, 'leaflet-zoom-' + (animated ? 'animated' : 'hide'));
|
||||||
|
this._heat = simpleheat(canvas);
|
||||||
|
this._updateOptions();
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateOptions: function () {
|
||||||
|
this._heat.radius(this.options.radius || this._heat.defaultRadius, this.options.blur);
|
||||||
|
if (this.options.gradient) this._heat.gradient(this.options.gradient);
|
||||||
|
if (this.options.minOpacity) this._heat.minOpacity = this.options.minOpacity;
|
||||||
|
},
|
||||||
|
|
||||||
|
_reset: function () {
|
||||||
|
var topLeft = this._map.containerPointToLayerPoint([0, 0]);
|
||||||
|
L.DomUtil.setPosition(this._canvas, topLeft);
|
||||||
|
var size = this._map.getSize();
|
||||||
|
if (this._heat._width !== size.x) {
|
||||||
|
this._canvas.width = this._heat._width = size.x;
|
||||||
|
}
|
||||||
|
if (this._heat._height !== size.y) {
|
||||||
|
this._canvas.height = this._heat._height = size.y;
|
||||||
|
}
|
||||||
|
this._redraw();
|
||||||
|
},
|
||||||
|
|
||||||
|
_redraw: function () {
|
||||||
|
this._frame = null;
|
||||||
|
if (!this._map) return;
|
||||||
|
var data = [],
|
||||||
|
r = this._heat._r,
|
||||||
|
size = this._map.getSize(),
|
||||||
|
bounds = new L.Bounds(L.point([-r, -r]), size.add([r, r])),
|
||||||
|
max = this.options.max === undefined ? 1 : this.options.max,
|
||||||
|
maxZoom = this.options.maxZoom === undefined ? this._map.getMaxZoom() : this.options.maxZoom,
|
||||||
|
v = 1 / Math.pow(2, Math.max(0, Math.min(maxZoom - this._map.getZoom(), 12))),
|
||||||
|
cellSize = r / 2,
|
||||||
|
grid = [],
|
||||||
|
panePos = this._map._getMapPanePos(),
|
||||||
|
offsetX = panePos.x % cellSize,
|
||||||
|
offsetY = panePos.y % cellSize,
|
||||||
|
i, len, p, cell, x, y, j, len2, k;
|
||||||
|
|
||||||
|
for (i = 0, len = this._latlngs.length; i < len; i++) {
|
||||||
|
p = this._map.latLngToContainerPoint(this._latlngs[i]);
|
||||||
|
if (bounds.contains(p)) {
|
||||||
|
x = Math.floor((p.x - offsetX) / cellSize) + 2;
|
||||||
|
y = Math.floor((p.y - offsetY) / cellSize) + 2;
|
||||||
|
var alt = this._latlngs[i].alt !== undefined ? this._latlngs[i].alt :
|
||||||
|
this._latlngs[i][2] !== undefined ? +this._latlngs[i][2] : 1;
|
||||||
|
k = alt * v;
|
||||||
|
grid[y] = grid[y] || [];
|
||||||
|
cell = grid[y][x];
|
||||||
|
if (!cell) {
|
||||||
|
grid[y][x] = [p.x, p.y, k];
|
||||||
|
} else {
|
||||||
|
cell[0] = (cell[0] * cell[2] + p.x * k) / (cell[2] + k);
|
||||||
|
cell[1] = (cell[1] * cell[2] + p.y * k) / (cell[2] + k);
|
||||||
|
cell[2] += k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i = 0, len = grid.length; i < len; i++) {
|
||||||
|
if (grid[i]) {
|
||||||
|
for (j = 0, len2 = grid[i].length; j < len2; j++) {
|
||||||
|
cell = grid[i][j];
|
||||||
|
if (cell) {
|
||||||
|
data.push([
|
||||||
|
Math.round(cell[0]),
|
||||||
|
Math.round(cell[1]),
|
||||||
|
Math.min(cell[2], max)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._heat.data(data).draw(this.options.minOpacity);
|
||||||
|
},
|
||||||
|
|
||||||
|
_animateZoom: function (e) {
|
||||||
|
var scale = this._map.getZoomScale(e.zoom),
|
||||||
|
offset = this._map._getCenterOffset(e.center)._multiplyBy(-scale).subtract(this._map._getMapPanePos());
|
||||||
|
if (L.DomUtil.setTransform) {
|
||||||
|
L.DomUtil.setTransform(this._canvas, offset, scale);
|
||||||
|
} else {
|
||||||
|
this._canvas.style[L.DomUtil.TRANSFORM] = L.DomUtil.getTranslateString(offset) + ' scale(' + scale + ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
L.heatLayer = function (latlngs, options) {
|
||||||
|
return new L.HeatLayer(latlngs, options);
|
||||||
|
};
|
||||||
|
}());
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "INTERCEPT Signal Intelligence",
|
||||||
|
"short_name": "INTERCEPT",
|
||||||
|
"description": "Unified SIGINT platform for software-defined radio analysis",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0b1118",
|
||||||
|
"theme_color": "#0b1118",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
/* INTERCEPT Service Worker — cache-first static, network-only for API/SSE/WS */
|
||||||
|
const CACHE_NAME = 'intercept-v3';
|
||||||
|
|
||||||
|
const NETWORK_ONLY_PREFIXES = [
|
||||||
|
'/stream', '/ws/', '/api/', '/gps/', '/wifi/', '/bluetooth/',
|
||||||
|
'/adsb/', '/ais/', '/acars/', '/aprs/', '/tscm/', '/satellite/',
|
||||||
|
'/meshtastic/', '/bt_locate/', '/receiver/', '/sensor/', '/pager/',
|
||||||
|
'/sstv/', '/weather-sat/', '/subghz/', '/rtlamr/', '/dsc/', '/vdl2/',
|
||||||
|
'/spy/', '/space-weather/', '/websdr/', '/analytics/', '/correlation/',
|
||||||
|
'/recordings/', '/controller/', '/ops/',
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATIC_PREFIXES = [
|
||||||
|
'/static/css/',
|
||||||
|
'/static/js/',
|
||||||
|
'/static/icons/',
|
||||||
|
'/static/fonts/',
|
||||||
|
];
|
||||||
|
|
||||||
|
const CACHE_EXACT = ['/manifest.json'];
|
||||||
|
|
||||||
|
function isHttpRequest(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNetworkOnly(req) {
|
||||||
|
if (req.method !== 'GET') return true;
|
||||||
|
const accept = req.headers.get('Accept') || '';
|
||||||
|
if (accept.includes('text/event-stream')) return true;
|
||||||
|
const url = new URL(req.url);
|
||||||
|
return NETWORK_ONLY_PREFIXES.some(p => url.pathname.startsWith(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStaticAsset(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (CACHE_EXACT.includes(url.pathname)) return true;
|
||||||
|
return STATIC_PREFIXES.some(p => url.pathname.startsWith(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackResponse(req, status = 503) {
|
||||||
|
const accept = req.headers.get('Accept') || '';
|
||||||
|
if (accept.includes('application/json')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ status: 'error', message: 'Network unavailable' }),
|
||||||
|
{
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accept.includes('text/event-stream')) {
|
||||||
|
return new Response('', {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'text/event-stream' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Offline', {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('install', (e) => {
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (e) => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches.keys().then(keys =>
|
||||||
|
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
|
||||||
|
).then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (e) => {
|
||||||
|
const req = e.request;
|
||||||
|
|
||||||
|
// Ignore non-HTTP(S) requests so extensions/browser-internal URLs are untouched.
|
||||||
|
if (!isHttpRequest(req)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always bypass service worker for non-GET and streaming routes
|
||||||
|
if (isNetworkOnly(req)) {
|
||||||
|
e.respondWith(
|
||||||
|
fetch(req).catch(() => fallbackResponse(req, 503))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache-first for static assets
|
||||||
|
if (isStaticAsset(req)) {
|
||||||
|
e.respondWith(
|
||||||
|
caches.open(CACHE_NAME).then(cache =>
|
||||||
|
cache.match(req).then(cached => {
|
||||||
|
if (cached) {
|
||||||
|
// Revalidate in background
|
||||||
|
fetch(req).then(res => {
|
||||||
|
if (res && res.status === 200) cache.put(req, res.clone());
|
||||||
|
}).catch(() => {});
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
return fetch(req).then(res => {
|
||||||
|
if (res && res.status === 200) cache.put(req, res.clone());
|
||||||
|
return res;
|
||||||
|
}).catch(() => fallbackResponse(req, 504));
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network-first for HTML pages
|
||||||
|
e.respondWith(
|
||||||
|
fetch(req).catch(() =>
|
||||||
|
caches.match(req).then(cached => cached || new Response('Offline', { status: 503 }))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
+158
-30
@@ -258,6 +258,10 @@
|
|||||||
<div class="display-container">
|
<div class="display-container">
|
||||||
<div id="radarMap">
|
<div id="radarMap">
|
||||||
</div>
|
</div>
|
||||||
|
<div id="mapCrosshairOverlay" class="map-crosshair-overlay" aria-hidden="true">
|
||||||
|
<div class="map-crosshair-line map-crosshair-vertical"></div>
|
||||||
|
<div class="map-crosshair-line map-crosshair-horizontal"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -429,6 +433,17 @@
|
|||||||
let alertsEnabled = true;
|
let alertsEnabled = true;
|
||||||
let detectionSoundEnabled = localStorage.getItem('adsb_detectionSound') !== 'false'; // Default on
|
let detectionSoundEnabled = localStorage.getItem('adsb_detectionSound') !== 'false'; // Default on
|
||||||
let soundedAircraft = {}; // Track aircraft we've played detection sound for
|
let soundedAircraft = {}; // Track aircraft we've played detection sound for
|
||||||
|
const MAP_CROSSHAIR_DURATION_MS = 1500;
|
||||||
|
const PANEL_SELECTION_BASE_ZOOM = 10;
|
||||||
|
const PANEL_SELECTION_MAX_ZOOM = 12;
|
||||||
|
const PANEL_SELECTION_ZOOM_INCREMENT = 1.4;
|
||||||
|
const PANEL_SELECTION_STAGE1_DURATION_SEC = 1.05;
|
||||||
|
const PANEL_SELECTION_STAGE2_DURATION_SEC = 1.15;
|
||||||
|
const PANEL_SELECTION_STAGE_GAP_MS = 180;
|
||||||
|
let mapCrosshairResetTimer = null;
|
||||||
|
let panelSelectionFallbackTimer = null;
|
||||||
|
let panelSelectionStageTimer = null;
|
||||||
|
let mapCrosshairRequestId = 0;
|
||||||
// Watchlist - persisted to localStorage
|
// Watchlist - persisted to localStorage
|
||||||
let watchlist = JSON.parse(localStorage.getItem('adsb_watchlist') || '[]');
|
let watchlist = JSON.parse(localStorage.getItem('adsb_watchlist') || '[]');
|
||||||
|
|
||||||
@@ -2620,7 +2635,7 @@ sudo make install</code>
|
|||||||
} else {
|
} else {
|
||||||
markers[icao] = L.marker([ac.lat, ac.lon], { icon: createMarkerIcon(rotation, color, iconType, isSelected) })
|
markers[icao] = L.marker([ac.lat, ac.lon], { icon: createMarkerIcon(rotation, color, iconType, isSelected) })
|
||||||
.addTo(radarMap)
|
.addTo(radarMap)
|
||||||
.on('click', () => selectAircraft(icao));
|
.on('click', () => selectAircraft(icao, 'map'));
|
||||||
markers[icao].bindTooltip(`${callsign}<br>${alt}`, {
|
markers[icao].bindTooltip(`${callsign}<br>${alt}`, {
|
||||||
permanent: false, direction: 'top', className: 'aircraft-tooltip'
|
permanent: false, direction: 'top', className: 'aircraft-tooltip'
|
||||||
});
|
});
|
||||||
@@ -2724,7 +2739,7 @@ sudo make install</code>
|
|||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = `aircraft-item ${selectedIcao === ac.icao ? 'selected' : ''} ${isOnWatchlist(ac) ? 'watched' : ''}`;
|
div.className = `aircraft-item ${selectedIcao === ac.icao ? 'selected' : ''} ${isOnWatchlist(ac) ? 'watched' : ''}`;
|
||||||
div.setAttribute('data-icao', ac.icao);
|
div.setAttribute('data-icao', ac.icao);
|
||||||
div.onclick = () => selectAircraft(ac.icao);
|
div.onclick = () => selectAircraft(ac.icao, 'panel');
|
||||||
div.innerHTML = buildAircraftItemHTML(ac);
|
div.innerHTML = buildAircraftItemHTML(ac);
|
||||||
fragment.appendChild(div);
|
fragment.appendChild(div);
|
||||||
});
|
});
|
||||||
@@ -2797,34 +2812,139 @@ sudo make install</code>
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectAircraft(icao) {
|
function triggerMapCrosshairAnimation(lat, lon, durationMs = MAP_CROSSHAIR_DURATION_MS, lockToMapCenter = false) {
|
||||||
// Toggle: clicking the same aircraft deselects it
|
if (!radarMap) return;
|
||||||
if (selectedIcao === icao) {
|
const overlay = document.getElementById('mapCrosshairOverlay');
|
||||||
const prev = selectedIcao;
|
if (!overlay) return;
|
||||||
selectedIcao = null;
|
|
||||||
// Reset previous marker icon
|
const size = radarMap.getSize();
|
||||||
if (prev && markers[prev] && aircraft[prev]) {
|
let targetX;
|
||||||
const ac = aircraft[prev];
|
let targetY;
|
||||||
const militaryInfo = isMilitaryAircraft(prev, ac.callsign);
|
|
||||||
const rotation = Math.round((ac.heading || 0) / 5) * 5;
|
if (lockToMapCenter) {
|
||||||
const color = militaryInfo.military ? '#556b2f' : getAltitudeColor(ac.altitude);
|
targetX = size.x / 2;
|
||||||
const iconType = getAircraftIconType(ac.type_code, militaryInfo.military);
|
targetY = size.y / 2;
|
||||||
markers[prev].setIcon(createMarkerIcon(rotation, color, iconType, false));
|
} else {
|
||||||
if (markerState[prev]) markerState[prev].isSelected = false;
|
const point = radarMap.latLngToContainerPoint([lat, lon]);
|
||||||
}
|
targetX = Math.max(0, Math.min(size.x, point.x));
|
||||||
renderAircraftList();
|
targetY = Math.max(0, Math.min(size.y, point.y));
|
||||||
showAircraftDetails(null);
|
}
|
||||||
updateFlightLookupBtn();
|
|
||||||
highlightSidebarMessages(null);
|
const startX = size.x + 8;
|
||||||
if (acarsMessageTimer) {
|
const startY = size.y + 8;
|
||||||
clearInterval(acarsMessageTimer);
|
|
||||||
acarsMessageTimer = null;
|
overlay.style.setProperty('--crosshair-x-start', `${startX}px`);
|
||||||
}
|
overlay.style.setProperty('--crosshair-y-start', `${startY}px`);
|
||||||
|
overlay.style.setProperty('--crosshair-x-end', `${targetX}px`);
|
||||||
|
overlay.style.setProperty('--crosshair-y-end', `${targetY}px`);
|
||||||
|
overlay.style.setProperty('--crosshair-duration', `${durationMs}ms`);
|
||||||
|
overlay.classList.remove('active');
|
||||||
|
void overlay.offsetWidth;
|
||||||
|
overlay.classList.add('active');
|
||||||
|
|
||||||
|
if (mapCrosshairResetTimer) {
|
||||||
|
clearTimeout(mapCrosshairResetTimer);
|
||||||
|
}
|
||||||
|
mapCrosshairResetTimer = setTimeout(() => {
|
||||||
|
overlay.classList.remove('active');
|
||||||
|
mapCrosshairResetTimer = null;
|
||||||
|
}, durationMs + 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPanelSelectionFinalZoom() {
|
||||||
|
if (!radarMap) return PANEL_SELECTION_BASE_ZOOM;
|
||||||
|
const currentZoom = radarMap.getZoom();
|
||||||
|
const maxZoom = typeof radarMap.getMaxZoom === 'function' ? radarMap.getMaxZoom() : PANEL_SELECTION_MAX_ZOOM;
|
||||||
|
return Math.min(
|
||||||
|
PANEL_SELECTION_MAX_ZOOM,
|
||||||
|
maxZoom,
|
||||||
|
Math.max(PANEL_SELECTION_BASE_ZOOM, currentZoom + PANEL_SELECTION_ZOOM_INCREMENT)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPanelSelectionIntermediateZoom(finalZoom) {
|
||||||
|
if (!radarMap) return finalZoom;
|
||||||
|
const currentZoom = radarMap.getZoom();
|
||||||
|
if (finalZoom - currentZoom < 0.8) {
|
||||||
|
return finalZoom;
|
||||||
|
}
|
||||||
|
const midpointZoom = currentZoom + ((finalZoom - currentZoom) * 0.55);
|
||||||
|
return Math.min(finalZoom - 0.45, midpointZoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPanelSelectionAnimation(lat, lon, requestId) {
|
||||||
|
if (!radarMap) return;
|
||||||
|
|
||||||
|
const finalZoom = getPanelSelectionFinalZoom();
|
||||||
|
const intermediateZoom = getPanelSelectionIntermediateZoom(finalZoom);
|
||||||
|
const sequenceDurationMs = Math.round(
|
||||||
|
((PANEL_SELECTION_STAGE1_DURATION_SEC + PANEL_SELECTION_STAGE2_DURATION_SEC) * 1000) +
|
||||||
|
PANEL_SELECTION_STAGE_GAP_MS + 260
|
||||||
|
);
|
||||||
|
const startSecondStage = () => {
|
||||||
|
if (requestId !== mapCrosshairRequestId) return;
|
||||||
|
radarMap.flyTo([lat, lon], finalZoom, {
|
||||||
|
animate: true,
|
||||||
|
duration: PANEL_SELECTION_STAGE2_DURATION_SEC,
|
||||||
|
easeLinearity: 0.2
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
triggerMapCrosshairAnimation(
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
Math.max(MAP_CROSSHAIR_DURATION_MS, sequenceDurationMs),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (intermediateZoom >= finalZoom - 0.1) {
|
||||||
|
radarMap.flyTo([lat, lon], finalZoom, {
|
||||||
|
animate: true,
|
||||||
|
duration: PANEL_SELECTION_STAGE2_DURATION_SEC,
|
||||||
|
easeLinearity: 0.2
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let stage1Handled = false;
|
||||||
|
const finishStage1 = () => {
|
||||||
|
if (stage1Handled || requestId !== mapCrosshairRequestId) return;
|
||||||
|
stage1Handled = true;
|
||||||
|
if (panelSelectionFallbackTimer) {
|
||||||
|
clearTimeout(panelSelectionFallbackTimer);
|
||||||
|
panelSelectionFallbackTimer = null;
|
||||||
|
}
|
||||||
|
panelSelectionStageTimer = setTimeout(() => {
|
||||||
|
panelSelectionStageTimer = null;
|
||||||
|
startSecondStage();
|
||||||
|
}, PANEL_SELECTION_STAGE_GAP_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
radarMap.once('moveend', finishStage1);
|
||||||
|
panelSelectionFallbackTimer = setTimeout(
|
||||||
|
finishStage1,
|
||||||
|
Math.round(PANEL_SELECTION_STAGE1_DURATION_SEC * 1000) + 160
|
||||||
|
);
|
||||||
|
|
||||||
|
radarMap.flyTo([lat, lon], intermediateZoom, {
|
||||||
|
animate: true,
|
||||||
|
duration: PANEL_SELECTION_STAGE1_DURATION_SEC,
|
||||||
|
easeLinearity: 0.2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAircraft(icao, source = 'map') {
|
||||||
const prevSelected = selectedIcao;
|
const prevSelected = selectedIcao;
|
||||||
selectedIcao = icao;
|
selectedIcao = icao;
|
||||||
|
mapCrosshairRequestId += 1;
|
||||||
|
if (panelSelectionFallbackTimer) {
|
||||||
|
clearTimeout(panelSelectionFallbackTimer);
|
||||||
|
panelSelectionFallbackTimer = null;
|
||||||
|
}
|
||||||
|
if (panelSelectionStageTimer) {
|
||||||
|
clearTimeout(panelSelectionStageTimer);
|
||||||
|
panelSelectionStageTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Update marker icons for both previous and new selection
|
// Update marker icons for both previous and new selection
|
||||||
[prevSelected, icao].forEach(targetIcao => {
|
[prevSelected, icao].forEach(targetIcao => {
|
||||||
@@ -2850,7 +2970,15 @@ sudo make install</code>
|
|||||||
|
|
||||||
const ac = aircraft[icao];
|
const ac = aircraft[icao];
|
||||||
if (ac && ac.lat !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) {
|
if (ac && ac.lat !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) {
|
||||||
radarMap.setView([ac.lat, ac.lon], 10);
|
const targetLat = ac.lat;
|
||||||
|
const targetLon = ac.lon;
|
||||||
|
|
||||||
|
if (source === 'panel' && radarMap) {
|
||||||
|
runPanelSelectionAnimation(targetLat, targetLon, mapCrosshairRequestId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
radarMap.setView([targetLat, targetLon], 10);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3264,7 +3392,7 @@ sudo make install</code>
|
|||||||
|
|
||||||
function initAirband() {
|
function initAirband() {
|
||||||
// Check if audio tools are available
|
// Check if audio tools are available
|
||||||
fetch('/listening/tools')
|
fetch('/receiver/tools')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
const missingTools = [];
|
const missingTools = [];
|
||||||
@@ -3414,7 +3542,7 @@ sudo make install</code>
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Start audio on backend
|
// Start audio on backend
|
||||||
const response = await fetch('/listening/audio/start', {
|
const response = await fetch('/receiver/audio/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -3451,7 +3579,7 @@ sudo make install</code>
|
|||||||
audioPlayer.load();
|
audioPlayer.load();
|
||||||
|
|
||||||
// Connect to stream
|
// Connect to stream
|
||||||
const streamUrl = `/listening/audio/stream?t=${Date.now()}`;
|
const streamUrl = `/receiver/audio/stream?t=${Date.now()}`;
|
||||||
console.log('[AIRBAND] Connecting to stream:', streamUrl);
|
console.log('[AIRBAND] Connecting to stream:', streamUrl);
|
||||||
audioPlayer.src = streamUrl;
|
audioPlayer.src = streamUrl;
|
||||||
|
|
||||||
@@ -3495,7 +3623,7 @@ sudo make install</code>
|
|||||||
audioPlayer.pause();
|
audioPlayer.pause();
|
||||||
audioPlayer.src = '';
|
audioPlayer.src = '';
|
||||||
|
|
||||||
fetch('/listening/audio/stop', { method: 'POST' })
|
fetch('/receiver/audio/stop', { method: 'POST' })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(() => {
|
.then(() => {
|
||||||
isAirbandPlaying = false;
|
isAirbandPlaying = false;
|
||||||
|
|||||||
+908
-708
File diff suppressed because it is too large
Load Diff
@@ -4,20 +4,20 @@
|
|||||||
#}
|
#}
|
||||||
|
|
||||||
<!-- Help Modal -->
|
<!-- 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 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">
|
<div class="help-content" tabindex="-1">
|
||||||
<button type="button" class="help-close" onclick="hideHelp()" aria-label="Close help">×</button>
|
<button type="button" class="help-close" onclick="hideHelp()" aria-label="Close help">×</button>
|
||||||
<h2 id="helpModalTitle">iNTERCEPT Help</h2>
|
<h2 id="helpModalTitle">iNTERCEPT Help</h2>
|
||||||
|
|
||||||
<div class="help-tabs" role="tablist" aria-label="Help sections">
|
<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 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="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="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>
|
<button type="button" class="help-tab" data-tab="tips" onclick="switchHelpTab('tips')" role="tab" aria-controls="help-tips" aria-selected="false">Tips</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Icons Section -->
|
<!-- Icons Section -->
|
||||||
<div id="help-icons" class="help-section active" role="tabpanel">
|
<div id="help-icons" class="help-section active" role="tabpanel">
|
||||||
<h3>Stats Bar Icons</h3>
|
<h3>Stats Bar Icons</h3>
|
||||||
<div class="icon-grid">
|
<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>
|
<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="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span><span class="desc">Aircraft - ADS-B tracking & history</span></div>
|
||||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg></span><span class="desc">Vessels - AIS & VHF DSC distress</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg></span><span class="desc">Vessels - AIS & VHF DSC distress</span></div>
|
||||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span><span class="desc">APRS - Amateur radio tracking</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span><span class="desc">APRS - Amateur radio tracking</span></div>
|
||||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span><span class="desc">Listening Post - SDR scanner</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M2 18h20" opacity="0.4"/><path d="M2 21h20" opacity="0.2"/></svg></span><span class="desc">Waterfall - SDR receiver + signal ID</span></div>
|
||||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span><span class="desc">Spy Stations - Number stations database</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span><span class="desc">Spy Stations - Number stations database</span></div>
|
||||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span><span class="desc">Meshtastic - LoRa mesh networking</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span><span class="desc">Meshtastic - LoRa mesh networking</span></div>
|
||||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span><span class="desc">WebSDR - Remote SDR receivers</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span><span class="desc">WebSDR - Remote SDR receivers</span></div>
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modes Section -->
|
<!-- 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>
|
<h3>Pager Mode</h3>
|
||||||
<ul class="tip-list">
|
<ul class="tip-list">
|
||||||
<li>Decodes POCSAG and FLEX pager signals using RTL-SDR</li>
|
<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>
|
<li>Interactive map shows station positions in real-time</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Listening Post Mode</h3>
|
<h3>Spectrum Waterfall Mode</h3>
|
||||||
<ul class="tip-list">
|
<ul class="tip-list">
|
||||||
<li>Wideband SDR scanner with spectrum visualization</li>
|
<li>Wideband SDR scanner with spectrum visualization</li>
|
||||||
<li>Tune to any frequency supported by your SDR hardware</li>
|
<li>Tune to any frequency supported by your SDR hardware</li>
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
<li>Browse stations from priyom.org with frequencies and schedules</li>
|
<li>Browse stations from priyom.org with frequencies and schedules</li>
|
||||||
<li>Filter by type (number/diplomatic), country, and mode</li>
|
<li>Filter by type (number/diplomatic), country, and mode</li>
|
||||||
<li>Famous stations: UVB-76 "The Buzzer", Cuban HM01, Israeli E17z</li>
|
<li>Famous stations: UVB-76 "The Buzzer", Cuban HM01, Israeli E17z</li>
|
||||||
<li>Click "Tune" to listen via Listening Post mode</li>
|
<li>Click "Tune" to listen via Spectrum Waterfall mode</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Meshtastic Mode</h3>
|
<h3>Meshtastic Mode</h3>
|
||||||
@@ -166,11 +166,27 @@
|
|||||||
<li>View next pass predictions with elevation and duration</li>
|
<li>View next pass predictions with elevation and duration</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<h3>ACARS Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Decodes Aircraft Communications Addressing and Reporting System messages via acarsdec</li>
|
||||||
|
<li>Receives operational, weather, and position reports on 129-136 MHz</li>
|
||||||
|
<li>Supports North America, Europe, and Asia-Pacific regional frequency presets</li>
|
||||||
|
<li>Filter by message type, flight ID, or aircraft registration</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>VDL2 Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Decodes VHF Data Link Mode 2 aircraft datalink messages via dumpvdl2</li>
|
||||||
|
<li>Captures ACARS-over-AVLC frames with full signal analysis (SNR, burst length)</li>
|
||||||
|
<li>Monitor multiple VDL2 frequencies simultaneously (136.725, 136.775, 136.975 MHz)</li>
|
||||||
|
<li>Export captured messages to CSV or JSON for offline analysis</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<h3>ISS SSTV Mode</h3>
|
<h3>ISS SSTV Mode</h3>
|
||||||
<ul class="tip-list">
|
<ul class="tip-list">
|
||||||
<li>Decodes Slow Scan Television (SSTV) images from the International Space Station</li>
|
<li>Decodes Slow Scan Television (SSTV) images from the International Space Station</li>
|
||||||
<li>Automated ISS pass tracking with Doppler correction on 145.800 MHz</li>
|
<li>Automated ISS pass tracking with Doppler correction on 145.800 MHz</li>
|
||||||
<li>Images decoded in real-time using slowrx</li>
|
<li>Images decoded in real-time using the built-in pure Python decoder</li>
|
||||||
<li>Gallery view with timestamped decoded images</li>
|
<li>Gallery view with timestamped decoded images</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -254,7 +270,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- WiFi Section -->
|
<!-- 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>
|
<h3>Monitor Mode</h3>
|
||||||
<ul class="tip-list">
|
<ul class="tip-list">
|
||||||
<li><strong>Enable Monitor:</strong> Puts WiFi adapter in monitor mode for passive scanning</li>
|
<li><strong>Enable Monitor:</strong> Puts WiFi adapter in monitor mode for passive scanning</li>
|
||||||
@@ -302,7 +318,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tips Section -->
|
<!-- 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>
|
<h3>General Tips</h3>
|
||||||
<ul class="tip-list">
|
<ul class="tip-list">
|
||||||
<li><strong>Collapsible sections:</strong> Click any section header (∇) to collapse/expand</li>
|
<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>Aircraft (ACARS):</strong> Second RTL-SDR, acarsdec</li>
|
||||||
<li><strong>Vessels (AIS):</strong> RTL-SDR, AIS-catcher</li>
|
<li><strong>Vessels (AIS):</strong> RTL-SDR, AIS-catcher</li>
|
||||||
<li><strong>APRS:</strong> RTL-SDR, direwolf or multimon-ng</li>
|
<li><strong>APRS:</strong> RTL-SDR, direwolf or multimon-ng</li>
|
||||||
<li><strong>Listening Post:</strong> RTL-SDR or SoapySDR-compatible hardware</li>
|
<li><strong>Spectrum Waterfall:</strong> RTL-SDR or SoapySDR-compatible hardware</li>
|
||||||
<li><strong>Spy Stations:</strong> Internet connection (database lookup)</li>
|
<li><strong>Spy Stations:</strong> Internet connection (database lookup)</li>
|
||||||
<li><strong>Meshtastic:</strong> Meshtastic LoRa device, <code>pip install meshtastic</code></li>
|
<li><strong>Meshtastic:</strong> Meshtastic LoRa device, <code>pip install meshtastic</code></li>
|
||||||
<li><strong>WebSDR:</strong> Internet connection (remote receivers)</li>
|
<li><strong>WebSDR:</strong> Internet connection (remote receivers)</li>
|
||||||
<li><strong>SubGHz:</strong> RTL-SDR or compatible SDR hardware</li>
|
<li><strong>SubGHz:</strong> RTL-SDR or compatible SDR hardware</li>
|
||||||
<li><strong>Satellite:</strong> Internet for Celestrak (optional), skyfield</li>
|
<li><strong>Satellite:</strong> Internet for Celestrak (optional), skyfield</li>
|
||||||
<li><strong>ISS SSTV:</strong> RTL-SDR, slowrx</li>
|
<li><strong>ACARS:</strong> RTL-SDR, acarsdec</li>
|
||||||
|
<li><strong>VDL2:</strong> RTL-SDR, dumpvdl2</li>
|
||||||
|
<li><strong>ISS SSTV:</strong> RTL-SDR (pure Python decoder — no external tools needed)</li>
|
||||||
<li><strong>Weather Sat:</strong> RTL-SDR, SatDump</li>
|
<li><strong>Weather Sat:</strong> RTL-SDR, SatDump</li>
|
||||||
<li><strong>HF SSTV:</strong> RTL-SDR or SoapySDR-compatible hardware, slowrx</li>
|
<li><strong>HF SSTV:</strong> RTL-SDR or SoapySDR-compatible hardware (pure Python decoder)</li>
|
||||||
<li><strong>GPS:</strong> RTL-SDR or GPS-capable SDR</li>
|
<li><strong>GPS:</strong> RTL-SDR or GPS-capable SDR</li>
|
||||||
<li><strong>Space Weather:</strong> Internet connection (public APIs)</li>
|
<li><strong>Space Weather:</strong> Internet connection (public APIs)</li>
|
||||||
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>
|
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>
|
||||||
@@ -360,62 +378,62 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Help modal functions - defined here so all pages have them
|
// Help modal functions - defined here so all pages have them
|
||||||
(function() {
|
(function() {
|
||||||
let lastHelpFocusEl = null;
|
let lastHelpFocusEl = null;
|
||||||
|
|
||||||
// Only define if not already defined (index.html defines its own)
|
// Only define if not already defined (index.html defines its own)
|
||||||
if (typeof window.showHelp === 'undefined') {
|
if (typeof window.showHelp === 'undefined') {
|
||||||
window.showHelp = function() {
|
window.showHelp = function() {
|
||||||
const modal = document.getElementById('helpModal');
|
const modal = document.getElementById('helpModal');
|
||||||
lastHelpFocusEl = document.activeElement;
|
lastHelpFocusEl = document.activeElement;
|
||||||
modal.classList.add('active');
|
modal.classList.add('active');
|
||||||
modal.setAttribute('aria-hidden', 'false');
|
modal.setAttribute('aria-hidden', 'false');
|
||||||
const content = modal.querySelector('.help-content');
|
const content = modal.querySelector('.help-content');
|
||||||
if (content) content.focus();
|
if (content) content.focus();
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window.hideHelp === 'undefined') {
|
if (typeof window.hideHelp === 'undefined') {
|
||||||
window.hideHelp = function() {
|
window.hideHelp = function() {
|
||||||
const modal = document.getElementById('helpModal');
|
const modal = document.getElementById('helpModal');
|
||||||
modal.classList.remove('active');
|
modal.classList.remove('active');
|
||||||
modal.setAttribute('aria-hidden', 'true');
|
modal.setAttribute('aria-hidden', 'true');
|
||||||
document.body.style.overflow = '';
|
document.body.style.overflow = '';
|
||||||
if (lastHelpFocusEl && typeof lastHelpFocusEl.focus === 'function') {
|
if (lastHelpFocusEl && typeof lastHelpFocusEl.focus === 'function') {
|
||||||
lastHelpFocusEl.focus();
|
lastHelpFocusEl.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window.switchHelpTab === 'undefined') {
|
if (typeof window.switchHelpTab === 'undefined') {
|
||||||
window.switchHelpTab = function(tab) {
|
window.switchHelpTab = function(tab) {
|
||||||
document.querySelectorAll('.help-tab').forEach(t => {
|
document.querySelectorAll('.help-tab').forEach(t => {
|
||||||
const isActive = t.dataset.tab === tab;
|
const isActive = t.dataset.tab === tab;
|
||||||
t.classList.toggle('active', isActive);
|
t.classList.toggle('active', isActive);
|
||||||
t.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
t.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
||||||
});
|
});
|
||||||
document.querySelectorAll('.help-section').forEach(s => {
|
document.querySelectorAll('.help-section').forEach(s => {
|
||||||
const isActive = s.id === ('help-' + tab);
|
const isActive = s.id === ('help-' + tab);
|
||||||
s.classList.toggle('active', isActive);
|
s.classList.toggle('active', isActive);
|
||||||
s.hidden = !isActive;
|
s.hidden = !isActive;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard shortcuts for help (only add once)
|
// Keyboard shortcuts for help (only add once)
|
||||||
if (!window._helpKeyboardSetup) {
|
if (!window._helpKeyboardSetup) {
|
||||||
window._helpKeyboardSetup = true;
|
window._helpKeyboardSetup = true;
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
const modal = document.getElementById('helpModal');
|
const modal = document.getElementById('helpModal');
|
||||||
if (modal && modal.classList.contains('active')) hideHelp();
|
if (modal && modal.classList.contains('active')) hideHelp();
|
||||||
}
|
}
|
||||||
// Open help with F1 or ? key (when not typing in an input)
|
// Open help with F1 or ? key (when not typing in an input)
|
||||||
var helpModal = document.getElementById('helpModal');
|
var helpModal = document.getElementById('helpModal');
|
||||||
if (helpModal && (e.key === 'F1' || (e.key === '?' && !e.target.matches('input, textarea, select'))) && !helpModal.classList.contains('active')) {
|
if (helpModal && (e.key === 'F1' || (e.key === '?' && !e.target.matches('input, textarea, select'))) && !helpModal.classList.contains('active')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
showHelp();
|
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>
|
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
<!-- WATERFALL MODE -->
|
||||||
|
<div id="waterfallMode" class="mode-content wf-side">
|
||||||
|
<div class="section wf-side-hero">
|
||||||
|
<div class="wf-side-hero-title-row">
|
||||||
|
<div class="wf-side-hero-title">Spectrum Waterfall</div>
|
||||||
|
<div class="wf-side-chip" id="wfHeroVisualStatus">CONNECTING</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-hero-subtext">
|
||||||
|
Click spectrum/waterfall to tune. Scroll to step-tune. Ctrl/Cmd + scroll to zoom span.
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-hero-stats">
|
||||||
|
<div class="wf-side-stat">
|
||||||
|
<div class="wf-side-stat-label">Tuned</div>
|
||||||
|
<div class="wf-side-stat-value" id="wfHeroFreq">100.0000 MHz</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-stat">
|
||||||
|
<div class="wf-side-stat-label">Mode</div>
|
||||||
|
<div class="wf-side-stat-value" id="wfHeroMode">WFM</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-stat">
|
||||||
|
<div class="wf-side-stat-label">Scan</div>
|
||||||
|
<div class="wf-side-stat-value" id="wfHeroScan">Idle</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-stat">
|
||||||
|
<div class="wf-side-stat-label">Hits</div>
|
||||||
|
<div class="wf-side-stat-value" id="wfHeroHits">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-hero-actions">
|
||||||
|
<button class="run-btn" id="wfStartBtn" onclick="Waterfall.start()">Start Waterfall</button>
|
||||||
|
<button class="stop-btn" id="wfStopBtn" onclick="Waterfall.stop()" style="display:none;">Stop Waterfall</button>
|
||||||
|
</div>
|
||||||
|
<div id="wfStatus" class="wf-side-status-line"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Device</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>SDR Device</label>
|
||||||
|
<select id="wfDevice" onchange="Waterfall && Waterfall.onDeviceChange && Waterfall.onDeviceChange()">
|
||||||
|
<option value="">Loading devices...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="wfDeviceInfo" class="wf-side-box" style="display:none;">
|
||||||
|
<div class="wf-side-kv">
|
||||||
|
<span class="wf-side-kv-label">Type</span>
|
||||||
|
<span id="wfDeviceType" class="wf-side-kv-value">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-kv">
|
||||||
|
<span class="wf-side-kv-label">Range</span>
|
||||||
|
<span id="wfDeviceRange" class="wf-side-kv-value">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-kv">
|
||||||
|
<span class="wf-side-kv-label">Capture SR</span>
|
||||||
|
<span id="wfDeviceBw" class="wf-side-kv-value">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Tuning</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Center Frequency (MHz)</label>
|
||||||
|
<input type="number" id="wfCenterFreq" value="100.0000" step="0.001" min="0.001" max="6000">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Span (MHz)</label>
|
||||||
|
<input type="number" id="wfSpanMhz" value="2.4" step="0.1" min="0.05" max="30">
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-grid-2">
|
||||||
|
<button class="preset-btn" onclick="Waterfall.applyPreset('fm')">FM Broadcast</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.applyPreset('air')">Airband</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.applyPreset('marine')">Marine</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.applyPreset('ham2m')">2m Ham</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Quick Tune & Bookmarks</h3>
|
||||||
|
<div class="wf-side-grid-2">
|
||||||
|
<button class="preset-btn" onclick="Waterfall.quickTune(121.5, 'am')">121.5 Guard</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.quickTune(156.8, 'fm')">156.8 CH16</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.quickTune(145.5, 'fm')">145.5 2m</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.quickTune(98.1, 'wfm')">98.1 FM</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.quickTune(462.5625, 'fm')">462.56 FRS</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.quickTune(446.0, 'fm')">446.0 PMR</button>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-divider"></div>
|
||||||
|
<div class="wf-bookmark-row">
|
||||||
|
<input type="number" id="wfBookmarkFreqInput" step="0.0001" min="0.001" max="6000" placeholder="Frequency MHz">
|
||||||
|
<select id="wfBookmarkMode">
|
||||||
|
<option value="auto" selected>Auto</option>
|
||||||
|
<option value="wfm">WFM</option>
|
||||||
|
<option value="fm">NFM</option>
|
||||||
|
<option value="am">AM</option>
|
||||||
|
<option value="usb">USB</option>
|
||||||
|
<option value="lsb">LSB</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-grid-2">
|
||||||
|
<button class="preset-btn" onclick="Waterfall.useTuneForBookmark()">Use Tuned</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.addBookmarkFromInput()">Save Bookmark</button>
|
||||||
|
</div>
|
||||||
|
<div id="wfBookmarkList" class="wf-bookmark-list">
|
||||||
|
<div class="wf-empty">No bookmarks saved</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-inline-label">Recent Hits</div>
|
||||||
|
<div id="wfRecentSignals" class="wf-recent-list">
|
||||||
|
<div class="wf-empty">No recent signal hits</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Handoff</h3>
|
||||||
|
<div class="wf-side-help">
|
||||||
|
Send current tuned frequency to another decoder/workflow.
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-grid-2">
|
||||||
|
<button class="preset-btn" onclick="Waterfall.handoff('pager')">To Pager</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.handoff('subghz')">To SubGHz</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.handoff('subghz433')">433 Profile</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.handoff('signalid')">Signal ID</button>
|
||||||
|
</div>
|
||||||
|
<div id="wfHandoffStatus" class="wf-side-status-line">Ready</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Signal Identification</h3>
|
||||||
|
<div class="wf-side-help">
|
||||||
|
Identify current frequency using local catalog and SigID Wiki matches.
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Frequency (MHz)</label>
|
||||||
|
<input type="number" id="wfSigIdFreq" value="100.0000" step="0.0001" min="0.001" max="6000">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Mode Hint</label>
|
||||||
|
<select id="wfSigIdMode">
|
||||||
|
<option value="auto" selected>Auto (Current Mode)</option>
|
||||||
|
<option value="wfm">WFM</option>
|
||||||
|
<option value="fm">NFM</option>
|
||||||
|
<option value="am">AM</option>
|
||||||
|
<option value="usb">USB</option>
|
||||||
|
<option value="lsb">LSB</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-grid-2">
|
||||||
|
<button class="preset-btn" onclick="Waterfall.useTuneForSignalId()">Use Tuned</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.identifySignal()">Identify</button>
|
||||||
|
</div>
|
||||||
|
<div id="wfSigIdStatus" class="wf-side-status-line">Ready</div>
|
||||||
|
<div id="wfSigIdResult" class="wf-side-box" style="display:none;"></div>
|
||||||
|
<div id="wfSigIdExternal" class="wf-side-box wf-side-box-muted" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Scan</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Range Start (MHz)</label>
|
||||||
|
<input type="number" id="wfScanStart" value="98.8000" step="0.001" min="0.001" max="6000">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Range End (MHz)</label>
|
||||||
|
<input type="number" id="wfScanEnd" value="101.2000" step="0.001" min="0.001" max="6000">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Step (kHz)</label>
|
||||||
|
<select id="wfScanStepKhz">
|
||||||
|
<option value="5">5</option>
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="12.5">12.5</option>
|
||||||
|
<option value="25">25</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100" selected>100</option>
|
||||||
|
<option value="200">200</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Dwell (ms)</label>
|
||||||
|
<input type="number" id="wfScanDwellMs" value="450" min="60" max="10000" step="10">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Signal Threshold <span id="wfScanThresholdValue" class="wf-inline-value">170</span></label>
|
||||||
|
<input type="range" id="wfScanThreshold" min="0" max="255" value="170">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Hold On Hit (ms)</label>
|
||||||
|
<input type="number" id="wfScanHoldMs" value="2500" min="0" max="30000" step="100">
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-group wf-scan-checkboxes">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="wfScanStopOnSignal" checked>
|
||||||
|
Pause scan when signal is above threshold
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-grid-2 wf-side-grid-gap-top">
|
||||||
|
<button class="preset-btn" onclick="Waterfall.setScanRangeFromView()">Use Current Span</button>
|
||||||
|
<button class="preset-btn" id="wfScanStartBtn" onclick="Waterfall.startScan()">Start Scan</button>
|
||||||
|
<button class="preset-btn" id="wfScanStopBtn" onclick="Waterfall.stopScan()" disabled>Stop Scan</button>
|
||||||
|
</div>
|
||||||
|
<div id="wfScanState" class="wf-side-status-line">Scan idle</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Scan Activity</h3>
|
||||||
|
<div class="wf-scan-metric-grid">
|
||||||
|
<div class="wf-scan-metric-card">
|
||||||
|
<div class="wf-scan-metric-label">Signals</div>
|
||||||
|
<div class="wf-scan-metric-value" id="wfScanSignalsCount">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-scan-metric-card">
|
||||||
|
<div class="wf-scan-metric-label">Scanned</div>
|
||||||
|
<div class="wf-scan-metric-value" id="wfScanStepsCount">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-scan-metric-card">
|
||||||
|
<div class="wf-scan-metric-label">Cycles</div>
|
||||||
|
<div class="wf-scan-metric-value" id="wfScanCyclesCount">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-grid-2 wf-side-grid-gap-top">
|
||||||
|
<button class="preset-btn" onclick="Waterfall.exportScanLog()">Export Log</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.clearScanHistory()">Clear History</button>
|
||||||
|
</div>
|
||||||
|
<div class="wf-hit-table-wrap">
|
||||||
|
<table class="wf-hit-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Frequency</th>
|
||||||
|
<th>Level</th>
|
||||||
|
<th>Mode</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="wfSignalHitsBody">
|
||||||
|
<tr><td colspan="5" class="wf-empty">No signals detected</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="wfSignalHitCount" class="wf-side-inline-label">0 signals found</div>
|
||||||
|
<div id="wfActivityLog" class="wf-activity-log">
|
||||||
|
<div class="wf-empty">Ready</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Capture</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Gain <span class="wf-inline-value">(dB or AUTO)</span></label>
|
||||||
|
<input type="text" id="wfGain" value="AUTO" placeholder="AUTO or numeric">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>FFT Size</label>
|
||||||
|
<select id="wfFftSize">
|
||||||
|
<option value="256">256</option>
|
||||||
|
<option value="512">512</option>
|
||||||
|
<option value="1024" selected>1024</option>
|
||||||
|
<option value="2048">2048</option>
|
||||||
|
<option value="4096">4096</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Frame Rate</label>
|
||||||
|
<select id="wfFps">
|
||||||
|
<option value="10">10 fps</option>
|
||||||
|
<option value="20" selected>20 fps</option>
|
||||||
|
<option value="30">30 fps</option>
|
||||||
|
<option value="40">40 fps</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>FFT Averaging</label>
|
||||||
|
<select id="wfAvgCount">
|
||||||
|
<option value="1">1 (none)</option>
|
||||||
|
<option value="2">2</option>
|
||||||
|
<option value="4" selected>4</option>
|
||||||
|
<option value="8">8</option>
|
||||||
|
<option value="16">16</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>PPM Correction</label>
|
||||||
|
<input type="number" id="wfPpm" value="0" step="1" min="-200" max="200" placeholder="0">
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-group wf-scan-checkboxes">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="wfBiasT">
|
||||||
|
Bias-T (antenna power)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Display</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Color Palette</label>
|
||||||
|
<select id="wfPalette" onchange="Waterfall.setPalette(this.value)">
|
||||||
|
<option value="turbo" selected>Turbo</option>
|
||||||
|
<option value="plasma">Plasma</option>
|
||||||
|
<option value="inferno">Inferno</option>
|
||||||
|
<option value="viridis">Viridis</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Noise Floor (dB)</label>
|
||||||
|
<input type="number" id="wfDbMin" value="-100" step="5" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Ceiling (dB)</label>
|
||||||
|
<input type="number" id="wfDbMax" value="-20" step="5" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-group wf-scan-checkboxes">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="wfPeakHold" onchange="Waterfall.togglePeakHold(this.checked)">
|
||||||
|
Peak Hold
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="wfBandAnnotations" checked onchange="Waterfall.toggleAnnotations(this.checked)">
|
||||||
|
Band Labels
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="wfAutoRange" checked onchange="Waterfall.toggleAutoRange(this.checked)">
|
||||||
|
Auto Range
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -65,8 +65,8 @@
|
|||||||
{{ mode_item('pager', 'Pager', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg>') }}
|
{{ mode_item('pager', 'Pager', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg>') }}
|
||||||
{{ mode_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
{{ mode_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
||||||
{{ mode_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
{{ mode_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
||||||
{{ mode_item('listening', 'Listening Post', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
|
|
||||||
{{ mode_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
|
{{ mode_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
|
||||||
|
{{ mode_item('waterfall', 'Waterfall', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M2 18h20" opacity="0.4"/><path d="M2 21h20" opacity="0.2"/></svg>') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -133,7 +133,6 @@
|
|||||||
|
|
||||||
<div class="mode-nav-dropdown-menu">
|
<div class="mode-nav-dropdown-menu">
|
||||||
{{ mode_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
{{ mode_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
||||||
{{ mode_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
|
|
||||||
{{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
{{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||||
{{ mode_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
{{ mode_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -177,6 +176,15 @@
|
|||||||
<button type="button" class="nav-tool-btn" onclick="showSettings()" title="Settings" aria-label="Open settings">
|
<button type="button" class="nav-tool-btn" onclick="showSettings()" title="Settings" aria-label="Open settings">
|
||||||
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="nav-tool-btn" id="voiceMuteBtn" onclick="window.VoiceAlerts && VoiceAlerts.toggleMute()" title="Toggle voice alerts" aria-label="Toggle voice alerts">
|
||||||
|
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg></span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="nav-tool-btn" onclick="window.CheatSheets && CheatSheets.showForCurrentMode()" title="Mode cheat sheet (Alt+C)" aria-label="Mode cheat sheet">
|
||||||
|
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="nav-tool-btn" onclick="window.KeyboardShortcuts && KeyboardShortcuts.showHelp()" title="Keyboard shortcuts (Alt+K)" aria-label="Keyboard shortcuts">
|
||||||
|
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M8 12h.01M12 12h.01M16 12h.01M7 16h10"/></svg></span>
|
||||||
|
</button>
|
||||||
<button type="button" class="nav-tool-btn" onclick="showHelp()" title="Help & Documentation" aria-label="Open help">?</button>
|
<button type="button" class="nav-tool-btn" onclick="showHelp()" title="Help & Documentation" aria-label="Open help">?</button>
|
||||||
<button type="button" class="nav-tool-btn" onclick="logout(event)" title="Logout" aria-label="Logout">
|
<button type="button" class="nav-tool-btn" onclick="logout(event)" title="Logout" aria-label="Logout">
|
||||||
<span class="power-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></span>
|
<span class="power-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></span>
|
||||||
@@ -191,7 +199,6 @@
|
|||||||
{{ mobile_item('pager', 'Pager', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg>') }}
|
{{ mobile_item('pager', 'Pager', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg>') }}
|
||||||
{{ mobile_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
{{ mobile_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
||||||
{{ mobile_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
{{ mobile_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
||||||
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
|
|
||||||
{{ mobile_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
|
{{ mobile_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
|
||||||
{# Tracking #}
|
{# Tracking #}
|
||||||
{{ mobile_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }}
|
{{ mobile_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }}
|
||||||
@@ -215,13 +222,70 @@
|
|||||||
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
|
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
|
||||||
{# Intel #}
|
{# Intel #}
|
||||||
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
||||||
{{ mobile_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
|
|
||||||
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||||
{{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
{{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||||
|
{# New modes #}
|
||||||
|
{{ mobile_item('waterfall', 'Waterfall', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h4l3-8 3 16 3-8h4"/></svg>') }}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{# JavaScript stub for pages that don't have switchMode defined #}
|
{# JavaScript stub for pages that don't have switchMode defined #}
|
||||||
<script>
|
<script>
|
||||||
|
(function () {
|
||||||
|
const NAV_PERF_KEY = 'intercept_nav_perf_v1';
|
||||||
|
const MAX_NAV_AGE_MS = 30000;
|
||||||
|
|
||||||
|
function parseNavPerf(raw) {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.InterceptNavPerf) {
|
||||||
|
window.InterceptNavPerf = {
|
||||||
|
markStart(meta = {}) {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
startedAtEpochMs: Date.now(),
|
||||||
|
sourcePath: window.location.pathname + window.location.search,
|
||||||
|
sourceMode: document.body?.getAttribute('data-mode') || null,
|
||||||
|
...meta,
|
||||||
|
};
|
||||||
|
sessionStorage.setItem(NAV_PERF_KEY, JSON.stringify(payload));
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore storage errors in private/incognito mode.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const payload = parseNavPerf(sessionStorage.getItem(NAV_PERF_KEY));
|
||||||
|
if (!payload || !payload.targetPath) return;
|
||||||
|
|
||||||
|
const ageMs = Date.now() - (payload.startedAtEpochMs || 0);
|
||||||
|
if (ageMs < 0 || ageMs > MAX_NAV_AGE_MS) {
|
||||||
|
try { sessionStorage.removeItem(NAV_PERF_KEY); } catch (_) { }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.location.pathname !== payload.targetPath) return;
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
`[Perf] Nav ${payload.sourcePath || '(unknown)'} -> ${payload.targetPath} in ${Math.round(ageMs)}ms`,
|
||||||
|
{
|
||||||
|
trigger: payload.trigger || 'unknown',
|
||||||
|
sourceMode: payload.sourceMode || null,
|
||||||
|
activeScans: payload.activeScans || null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
try { sessionStorage.removeItem(NAV_PERF_KEY); } catch (_) { }
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
// Ensure navigation functions exist (for dashboard pages that don't have the full JS)
|
// Ensure navigation functions exist (for dashboard pages that don't have the full JS)
|
||||||
if (typeof switchMode === 'undefined') {
|
if (typeof switchMode === 'undefined') {
|
||||||
window.switchMode = function(mode) {
|
window.switchMode = function(mode) {
|
||||||
|
|||||||
@@ -284,6 +284,93 @@
|
|||||||
|
|
||||||
<!-- Alerts Section -->
|
<!-- Alerts Section -->
|
||||||
<div id="settings-alerts" class="settings-section">
|
<div id="settings-alerts" class="settings-section">
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Voice Alerts</div>
|
||||||
|
<p style="color: var(--text-dim); margin-bottom: 10px; font-size: 12px;">
|
||||||
|
Configure which events trigger spoken alerts and adjust voice settings.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Pager Messages</span>
|
||||||
|
<span class="settings-label-desc">Speak decoded pager messages</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="voiceCfgPager" checked onchange="saveVoiceAlertConfig()">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">TSCM Alerts</span>
|
||||||
|
<span class="settings-label-desc">Speak counter-surveillance detections</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="voiceCfgTscm" checked onchange="saveVoiceAlertConfig()">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Tracker Detection</span>
|
||||||
|
<span class="settings-label-desc">Speak when AirTag, Tile, or SmartTag found</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="voiceCfgTracker" checked onchange="saveVoiceAlertConfig()">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Emergency Squawks</span>
|
||||||
|
<span class="settings-label-desc">Speak aircraft emergency transponder codes</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="voiceCfgSquawk" checked onchange="saveVoiceAlertConfig()">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Voice</span>
|
||||||
|
<span class="settings-label-desc">Speech synthesis voice</span>
|
||||||
|
</div>
|
||||||
|
<select id="voiceCfgVoice" class="settings-select" style="width: 200px;" onchange="saveVoiceAlertConfig()">
|
||||||
|
<option value="">Default</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Rate</span>
|
||||||
|
<span class="settings-label-desc">Speech speed (0.5 – 2.0)</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; width:200px;">
|
||||||
|
<input type="range" id="voiceCfgRate" min="0.5" max="2.0" step="0.1" value="1.1" style="flex:1;" oninput="document.getElementById('voiceCfgRateVal').textContent=this.value; saveVoiceAlertConfig();">
|
||||||
|
<span id="voiceCfgRateVal" style="font-family:var(--font-mono); font-size:11px; color:var(--text-dim); min-width:28px; text-align:right;">1.1</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Pitch</span>
|
||||||
|
<span class="settings-label-desc">Voice pitch (0.5 – 2.0)</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; width:200px;">
|
||||||
|
<input type="range" id="voiceCfgPitch" min="0.5" max="2.0" step="0.1" value="0.9" style="flex:1;" oninput="document.getElementById('voiceCfgPitchVal').textContent=this.value; saveVoiceAlertConfig();">
|
||||||
|
<span id="voiceCfgPitchVal" style="font-family:var(--font-mono); font-size:11px; color:var(--text-dim); min-width:28px; text-align:right;">0.9</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 8px;">
|
||||||
|
<button class="check-assets-btn" onclick="testVoiceAlert()">Test Voice</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<div class="settings-group-title">Alert Feed <span id="alertsFeedCount" style="color: var(--text-dim); font-weight: 500;"></span></div>
|
<div class="settings-group-title">Alert Feed <span id="alertsFeedCount" style="color: var(--text-dim); font-weight: 500;"></span></div>
|
||||||
<div id="alertsFeedList" class="settings-feed">
|
<div id="alertsFeedList" class="settings-feed">
|
||||||
@@ -316,7 +403,6 @@
|
|||||||
<option value="acars">ACARS</option>
|
<option value="acars">ACARS</option>
|
||||||
<option value="vdl2">VDL2</option>
|
<option value="vdl2">VDL2</option>
|
||||||
<option value="aprs">APRS</option>
|
<option value="aprs">APRS</option>
|
||||||
<option value="dsc">DSC</option>
|
|
||||||
<option value="meshtastic">Meshtastic</option>
|
<option value="meshtastic">Meshtastic</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -392,14 +478,12 @@
|
|||||||
<option value="bluetooth">Bluetooth</option>
|
<option value="bluetooth">Bluetooth</option>
|
||||||
<option value="adsb">ADS-B</option>
|
<option value="adsb">ADS-B</option>
|
||||||
<option value="ais">AIS</option>
|
<option value="ais">AIS</option>
|
||||||
<option value="dsc">DSC</option>
|
|
||||||
<option value="acars">ACARS</option>
|
<option value="acars">ACARS</option>
|
||||||
<option value="aprs">APRS</option>
|
<option value="aprs">APRS</option>
|
||||||
<option value="rtlamr">RTLAMR</option>
|
<option value="rtlamr">RTLAMR</option>
|
||||||
<option value="tscm">TSCM</option>
|
<option value="tscm">TSCM</option>
|
||||||
<option value="sstv">SSTV</option>
|
<option value="sstv">SSTV</option>
|
||||||
<option value="sstv_general">SSTV General</option>
|
<option value="sstv_general">SSTV General</option>
|
||||||
<option value="listening_scanner">Listening Post</option>
|
|
||||||
<option value="waterfall">Waterfall</option>
|
<option value="waterfall">Waterfall</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
"""Tests for analytics endpoints, export, and squawk detection."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='session')
|
|
||||||
def app():
|
|
||||||
"""Create application for testing."""
|
|
||||||
import app as app_module
|
|
||||||
import utils.database as db_mod
|
|
||||||
from routes import register_blueprints
|
|
||||||
|
|
||||||
app_module.app.config['TESTING'] = True
|
|
||||||
|
|
||||||
# Use temp directory for test database
|
|
||||||
tmp_dir = Path(tempfile.mkdtemp())
|
|
||||||
db_mod.DB_DIR = tmp_dir
|
|
||||||
db_mod.DB_PATH = tmp_dir / 'test_intercept.db'
|
|
||||||
# Reset thread-local connection so it picks up new path
|
|
||||||
if hasattr(db_mod._local, 'connection') and db_mod._local.connection:
|
|
||||||
db_mod._local.connection.close()
|
|
||||||
db_mod._local.connection = None
|
|
||||||
|
|
||||||
db_mod.init_db()
|
|
||||||
|
|
||||||
if 'pager' not in app_module.app.blueprints:
|
|
||||||
register_blueprints(app_module.app)
|
|
||||||
|
|
||||||
return app_module.app
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client(app):
|
|
||||||
client = app.test_client()
|
|
||||||
# Set session login to bypass require_login before_request hook
|
|
||||||
with client.session_transaction() as sess:
|
|
||||||
sess['logged_in'] = True
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
class TestAnalyticsSummary:
|
|
||||||
"""Tests for /analytics/summary endpoint."""
|
|
||||||
|
|
||||||
def test_summary_returns_json(self, client):
|
|
||||||
response = client.get('/analytics/summary')
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['status'] == 'success'
|
|
||||||
assert 'counts' in data
|
|
||||||
assert 'health' in data
|
|
||||||
assert 'squawks' in data
|
|
||||||
|
|
||||||
def test_summary_counts_structure(self, client):
|
|
||||||
response = client.get('/analytics/summary')
|
|
||||||
data = json.loads(response.data)
|
|
||||||
counts = data['counts']
|
|
||||||
assert 'adsb' in counts
|
|
||||||
assert 'ais' in counts
|
|
||||||
assert 'wifi' in counts
|
|
||||||
assert 'bluetooth' in counts
|
|
||||||
assert 'dsc' in counts
|
|
||||||
# All should be integers
|
|
||||||
for val in counts.values():
|
|
||||||
assert isinstance(val, int)
|
|
||||||
|
|
||||||
def test_summary_health_structure(self, client):
|
|
||||||
response = client.get('/analytics/summary')
|
|
||||||
data = json.loads(response.data)
|
|
||||||
health = data['health']
|
|
||||||
# Should have process statuses
|
|
||||||
assert 'pager' in health
|
|
||||||
assert 'sensor' in health
|
|
||||||
assert 'adsb' in health
|
|
||||||
# Each should have a running flag
|
|
||||||
for mode_info in health.values():
|
|
||||||
if isinstance(mode_info, dict) and 'running' in mode_info:
|
|
||||||
assert isinstance(mode_info['running'], bool)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAnalyticsExport:
|
|
||||||
"""Tests for /analytics/export/<mode> endpoint."""
|
|
||||||
|
|
||||||
def test_export_adsb_json(self, client):
|
|
||||||
response = client.get('/analytics/export/adsb?format=json')
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['status'] == 'success'
|
|
||||||
assert data['mode'] == 'adsb'
|
|
||||||
assert 'data' in data
|
|
||||||
assert isinstance(data['data'], list)
|
|
||||||
|
|
||||||
def test_export_adsb_csv(self, client):
|
|
||||||
response = client.get('/analytics/export/adsb?format=csv')
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.content_type.startswith('text/csv')
|
|
||||||
assert 'Content-Disposition' in response.headers
|
|
||||||
|
|
||||||
def test_export_invalid_mode(self, client):
|
|
||||||
response = client.get('/analytics/export/invalid_mode')
|
|
||||||
assert response.status_code == 400
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['status'] == 'error'
|
|
||||||
|
|
||||||
def test_export_wifi_json(self, client):
|
|
||||||
response = client.get('/analytics/export/wifi?format=json')
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['status'] == 'success'
|
|
||||||
assert data['mode'] == 'wifi'
|
|
||||||
|
|
||||||
|
|
||||||
class TestAnalyticsSquawks:
|
|
||||||
"""Tests for squawk detection."""
|
|
||||||
|
|
||||||
def test_squawks_endpoint(self, client):
|
|
||||||
response = client.get('/analytics/squawks')
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['status'] == 'success'
|
|
||||||
assert isinstance(data['squawks'], list)
|
|
||||||
|
|
||||||
def test_get_emergency_squawks_detects_7700(self):
|
|
||||||
from utils.analytics import get_emergency_squawks
|
|
||||||
|
|
||||||
# Mock the adsb_aircraft DataStore
|
|
||||||
mock_store = MagicMock()
|
|
||||||
mock_store.items.return_value = [
|
|
||||||
('ABC123', {'squawk': '7700', 'callsign': 'TEST01', 'altitude': 35000}),
|
|
||||||
('DEF456', {'squawk': '1200', 'callsign': 'TEST02'}),
|
|
||||||
]
|
|
||||||
|
|
||||||
with patch('utils.analytics.app_module') as mock_app:
|
|
||||||
mock_app.adsb_aircraft = mock_store
|
|
||||||
squawks = get_emergency_squawks()
|
|
||||||
|
|
||||||
assert len(squawks) == 1
|
|
||||||
assert squawks[0]['squawk'] == '7700'
|
|
||||||
assert squawks[0]['meaning'] == 'General Emergency'
|
|
||||||
assert squawks[0]['icao'] == 'ABC123'
|
|
||||||
|
|
||||||
|
|
||||||
class TestGeofenceCRUD:
|
|
||||||
"""Tests for geofence CRUD endpoints."""
|
|
||||||
|
|
||||||
def test_list_geofences(self, client):
|
|
||||||
response = client.get('/analytics/geofences')
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['status'] == 'success'
|
|
||||||
assert isinstance(data['zones'], list)
|
|
||||||
|
|
||||||
def test_create_geofence(self, client):
|
|
||||||
response = client.post('/analytics/geofences',
|
|
||||||
data=json.dumps({
|
|
||||||
'name': 'Test Zone',
|
|
||||||
'lat': 51.5074,
|
|
||||||
'lon': -0.1278,
|
|
||||||
'radius_m': 500,
|
|
||||||
}),
|
|
||||||
content_type='application/json')
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['status'] == 'success'
|
|
||||||
assert 'zone_id' in data
|
|
||||||
|
|
||||||
def test_create_geofence_missing_fields(self, client):
|
|
||||||
response = client.post('/analytics/geofences',
|
|
||||||
data=json.dumps({'name': 'No coords'}),
|
|
||||||
content_type='application/json')
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
def test_create_geofence_invalid_coords(self, client):
|
|
||||||
response = client.post('/analytics/geofences',
|
|
||||||
data=json.dumps({
|
|
||||||
'name': 'Bad',
|
|
||||||
'lat': 100,
|
|
||||||
'lon': 0,
|
|
||||||
'radius_m': 100,
|
|
||||||
}),
|
|
||||||
content_type='application/json')
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
def test_delete_geofence_not_found(self, client):
|
|
||||||
response = client.delete('/analytics/geofences/99999')
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
class TestAnalyticsActivity:
|
|
||||||
"""Tests for /analytics/activity endpoint."""
|
|
||||||
|
|
||||||
def test_activity_returns_sparklines(self, client):
|
|
||||||
response = client.get('/analytics/activity')
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['status'] == 'success'
|
|
||||||
assert 'sparklines' in data
|
|
||||||
assert isinstance(data['sparklines'], dict)
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Tests for pager scope waveform payload generation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import queue
|
||||||
|
import struct
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from routes.pager import _encode_scope_waveform, audio_relay_thread
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_scope_waveform_respects_window_and_range():
|
||||||
|
samples = (-32768, -16384, 0, 16384, 32767)
|
||||||
|
waveform = _encode_scope_waveform(samples, window_size=4)
|
||||||
|
|
||||||
|
assert len(waveform) == 4
|
||||||
|
assert waveform[0] == -64
|
||||||
|
assert waveform[1] == 0
|
||||||
|
assert waveform[2] == 64
|
||||||
|
assert waveform[3] == 127
|
||||||
|
assert max(waveform) <= 127
|
||||||
|
assert min(waveform) >= -127
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_relay_thread_emits_scope_waveform(monkeypatch):
|
||||||
|
base_samples = (0, 32767, -32768, 16384) * 512
|
||||||
|
pcm = struct.pack(f"<{len(base_samples)}h", *base_samples)
|
||||||
|
|
||||||
|
rtl_stdout = io.BytesIO(pcm)
|
||||||
|
multimon_stdin = io.BytesIO()
|
||||||
|
output_queue: queue.Queue = queue.Queue()
|
||||||
|
stop_event = threading.Event()
|
||||||
|
|
||||||
|
ticks = iter([0.0, 0.2, 0.2, 0.2])
|
||||||
|
monkeypatch.setattr("routes.pager.time.monotonic", lambda: next(ticks, 0.2))
|
||||||
|
|
||||||
|
audio_relay_thread(rtl_stdout, multimon_stdin, output_queue, stop_event)
|
||||||
|
|
||||||
|
scope_event = output_queue.get_nowait()
|
||||||
|
assert scope_event["type"] == "scope"
|
||||||
|
assert scope_event["rms"] > 0
|
||||||
|
assert scope_event["peak"] > 0
|
||||||
|
assert "waveform" in scope_event
|
||||||
|
assert len(scope_event["waveform"]) > 0
|
||||||
|
assert max(scope_event["waveform"]) <= 127
|
||||||
|
assert min(scope_event["waveform"]) >= -127
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Tests for RTL-SDR detection parsing."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from utils.sdr.base import SDRType
|
||||||
|
from utils.sdr.detection import detect_rtlsdr_devices
|
||||||
|
|
||||||
|
|
||||||
|
@patch('utils.sdr.detection._check_tool', return_value=True)
|
||||||
|
@patch('utils.sdr.detection.subprocess.run')
|
||||||
|
def test_detect_rtlsdr_devices_filters_empty_serial_entries(mock_run, _mock_check_tool):
|
||||||
|
"""Ignore malformed rtl_test rows that have an empty SN field."""
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.stdout = ""
|
||||||
|
mock_result.stderr = (
|
||||||
|
"Found 3 device(s):\n"
|
||||||
|
" 0: ??C?, , SN:\n"
|
||||||
|
" 1: ??C?, , SN:\n"
|
||||||
|
" 2: RTLSDRBlog, Blog V4, SN: 1\n"
|
||||||
|
)
|
||||||
|
mock_run.return_value = mock_result
|
||||||
|
|
||||||
|
devices = detect_rtlsdr_devices()
|
||||||
|
|
||||||
|
assert len(devices) == 1
|
||||||
|
assert devices[0].sdr_type == SDRType.RTL_SDR
|
||||||
|
assert devices[0].index == 2
|
||||||
|
assert devices[0].name == "RTLSDRBlog, Blog V4"
|
||||||
|
assert devices[0].serial == "1"
|
||||||
|
|
||||||
|
|
||||||
|
@patch('utils.sdr.detection._check_tool', return_value=True)
|
||||||
|
@patch('utils.sdr.detection.subprocess.run')
|
||||||
|
def test_detect_rtlsdr_devices_uses_replace_decode_mode(mock_run, _mock_check_tool):
|
||||||
|
"""Run rtl_test with tolerant decoding for malformed output bytes."""
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.stdout = ""
|
||||||
|
mock_result.stderr = "Found 0 device(s):"
|
||||||
|
mock_run.return_value = mock_result
|
||||||
|
|
||||||
|
detect_rtlsdr_devices()
|
||||||
|
|
||||||
|
_, kwargs = mock_run.call_args
|
||||||
|
assert kwargs["text"] is True
|
||||||
|
assert kwargs["encoding"] == "utf-8"
|
||||||
|
assert kwargs["errors"] == "replace"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Tests for synthesized 433 MHz scope waveform payload."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from routes.sensor import _build_scope_waveform
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_scope_waveform_has_expected_shape_and_bounds():
|
||||||
|
waveform = _build_scope_waveform(rssi=-8.5, snr=11.2, noise=-26.0, points=96)
|
||||||
|
|
||||||
|
assert len(waveform) == 96
|
||||||
|
assert max(waveform) <= 127
|
||||||
|
assert min(waveform) >= -127
|
||||||
|
assert any(sample != 0 for sample in waveform)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_scope_waveform_changes_with_signal_profile():
|
||||||
|
low_snr = _build_scope_waveform(rssi=-14.0, snr=2.0, noise=-12.0, points=64)
|
||||||
|
high_snr = _build_scope_waveform(rssi=-14.0, snr=20.0, noise=-12.0, points=64)
|
||||||
|
|
||||||
|
assert low_snr != high_snr
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Tests for the SigID Wiki lookup API endpoint."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import routes.signalid as signalid_module
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_client(client):
|
||||||
|
"""Client with logged-in session."""
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess['logged_in'] = True
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def test_sigidwiki_lookup_missing_frequency(auth_client):
|
||||||
|
"""frequency_mhz is required."""
|
||||||
|
resp = auth_client.post('/signalid/sigidwiki', json={})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
|
||||||
|
|
||||||
|
def test_sigidwiki_lookup_invalid_frequency(auth_client):
|
||||||
|
"""frequency_mhz must be numeric and positive."""
|
||||||
|
resp = auth_client.post('/signalid/sigidwiki', json={'frequency_mhz': 'abc'})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
resp = auth_client.post('/signalid/sigidwiki', json={'frequency_mhz': -1})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_sigidwiki_lookup_success(auth_client):
|
||||||
|
"""Endpoint returns normalized SigID lookup structure."""
|
||||||
|
signalid_module._cache.clear()
|
||||||
|
fake_lookup = {
|
||||||
|
'matches': [
|
||||||
|
{
|
||||||
|
'title': 'POCSAG',
|
||||||
|
'url': 'https://www.sigidwiki.com/wiki/POCSAG',
|
||||||
|
'frequencies_mhz': [929.6625],
|
||||||
|
'modes': ['NFM'],
|
||||||
|
'modulations': ['FSK'],
|
||||||
|
'distance_hz': 0,
|
||||||
|
'source': 'SigID Wiki',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'search_used': False,
|
||||||
|
'exact_queries': ['[[Category:Signal]][[Frequencies::929.6625 MHz]]|?Frequencies|?Mode|?Modulation|limit=10'],
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch('routes.signalid._lookup_sigidwiki_matches', return_value=fake_lookup) as lookup_mock:
|
||||||
|
resp = auth_client.post('/signalid/sigidwiki', json={
|
||||||
|
'frequency_mhz': 929.6625,
|
||||||
|
'modulation': 'fm',
|
||||||
|
'limit': 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert lookup_mock.call_count == 1
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data['status'] == 'ok'
|
||||||
|
assert data['source'] == 'sigidwiki'
|
||||||
|
assert data['cached'] is False
|
||||||
|
assert data['match_count'] == 1
|
||||||
|
assert data['matches'][0]['title'] == 'POCSAG'
|
||||||
|
|
||||||
|
|
||||||
|
def test_sigidwiki_lookup_cached_response(auth_client):
|
||||||
|
"""Second identical lookup should be served from cache."""
|
||||||
|
signalid_module._cache.clear()
|
||||||
|
fake_lookup = {
|
||||||
|
'matches': [{'title': 'Test Signal', 'url': 'https://www.sigidwiki.com/wiki/Test_Signal'}],
|
||||||
|
'search_used': True,
|
||||||
|
'exact_queries': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {'frequency_mhz': 433.92, 'modulation': 'nfm', 'limit': 5}
|
||||||
|
with patch('routes.signalid._lookup_sigidwiki_matches', return_value=fake_lookup) as lookup_mock:
|
||||||
|
first = auth_client.post('/signalid/sigidwiki', json=payload)
|
||||||
|
second = auth_client.post('/signalid/sigidwiki', json=payload)
|
||||||
|
|
||||||
|
assert lookup_mock.call_count == 1
|
||||||
|
assert first.status_code == 200
|
||||||
|
assert second.status_code == 200
|
||||||
|
assert first.get_json()['cached'] is False
|
||||||
|
assert second.get_json()['cached'] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_sigidwiki_lookup_backend_failure(auth_client):
|
||||||
|
"""Unexpected lookup failures should return 502."""
|
||||||
|
signalid_module._cache.clear()
|
||||||
|
with patch('routes.signalid._lookup_sigidwiki_matches', side_effect=RuntimeError('boom')):
|
||||||
|
resp = auth_client.post('/signalid/sigidwiki', json={'frequency_mhz': 433.92})
|
||||||
|
assert resp.status_code == 502
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Tests for SSTV scope waveform encoding."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from utils.sstv.sstv_decoder import _encode_scope_waveform
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_scope_waveform_respects_window_and_bounds():
|
||||||
|
samples = np.array([-32768, -16384, 0, 16384, 32767], dtype=np.int16)
|
||||||
|
waveform = _encode_scope_waveform(samples, window_size=4)
|
||||||
|
|
||||||
|
assert len(waveform) == 4
|
||||||
|
assert waveform[0] == -64
|
||||||
|
assert waveform[1] == 0
|
||||||
|
assert waveform[2] == 64
|
||||||
|
assert waveform[3] == 127
|
||||||
|
assert max(waveform) <= 127
|
||||||
|
assert min(waveform) >= -127
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_scope_waveform_empty_input():
|
||||||
|
waveform = _encode_scope_waveform(np.array([], dtype=np.int16))
|
||||||
|
assert waveform == []
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Tests for waterfall WebSocket configuration helpers."""
|
||||||
|
|
||||||
|
from routes.waterfall_websocket import (
|
||||||
|
_parse_center_freq_mhz,
|
||||||
|
_parse_span_mhz,
|
||||||
|
_pick_sample_rate,
|
||||||
|
)
|
||||||
|
from utils.sdr import SDRType
|
||||||
|
from utils.sdr.base import SDRCapabilities
|
||||||
|
|
||||||
|
|
||||||
|
def _caps(sample_rates):
|
||||||
|
return SDRCapabilities(
|
||||||
|
sdr_type=SDRType.RTL_SDR,
|
||||||
|
freq_min_mhz=24.0,
|
||||||
|
freq_max_mhz=1766.0,
|
||||||
|
gain_min=0.0,
|
||||||
|
gain_max=49.6,
|
||||||
|
sample_rates=sample_rates,
|
||||||
|
supports_bias_t=True,
|
||||||
|
supports_ppm=True,
|
||||||
|
tx_capable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_center_prefers_center_freq_mhz():
|
||||||
|
assert _parse_center_freq_mhz({'center_freq_mhz': 162.55, 'center_freq': 144000000}) == 162.55
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_center_supports_center_freq_hz():
|
||||||
|
assert _parse_center_freq_mhz({'center_freq_hz': 915000000}) == 915.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_center_supports_legacy_hz_payload():
|
||||||
|
assert _parse_center_freq_mhz({'center_freq': 109000000}) == 109.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_center_supports_legacy_mhz_payload():
|
||||||
|
assert _parse_center_freq_mhz({'center_freq': 433.92}) == 433.92
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_span_from_hz_and_mhz():
|
||||||
|
assert _parse_span_mhz({'span_hz': 2400000}) == 2.4
|
||||||
|
assert _parse_span_mhz({'span_mhz': 10.0}) == 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_sample_rate_chooses_nearest_declared_rate():
|
||||||
|
caps = _caps([250000, 1024000, 1800000, 2048000, 2400000])
|
||||||
|
assert _pick_sample_rate(700000, caps, SDRType.RTL_SDR) == 1024000
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_sample_rate_falls_back_to_max_bandwidth():
|
||||||
|
caps = _caps([])
|
||||||
|
assert _pick_sample_rate(10_000_000, caps, SDRType.RTL_SDR) == 2_400_000
|
||||||
+21
-20
@@ -97,7 +97,7 @@ class AgentClient:
|
|||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
raise AgentHTTPError(f"Request failed: {e}")
|
raise AgentHTTPError(f"Request failed: {e}")
|
||||||
|
|
||||||
def _post(self, path: str, data: dict | None = None) -> dict:
|
def _post(self, path: str, data: dict | None = None, timeout: float | None = None) -> dict:
|
||||||
"""
|
"""
|
||||||
Perform POST request to agent.
|
Perform POST request to agent.
|
||||||
|
|
||||||
@@ -112,20 +112,21 @@ class AgentClient:
|
|||||||
AgentHTTPError: On HTTP errors
|
AgentHTTPError: On HTTP errors
|
||||||
AgentConnectionError: If agent is unreachable
|
AgentConnectionError: If agent is unreachable
|
||||||
"""
|
"""
|
||||||
url = f"{self.base_url}{path}"
|
url = f"{self.base_url}{path}"
|
||||||
try:
|
request_timeout = self.timeout if timeout is None else timeout
|
||||||
response = requests.post(
|
try:
|
||||||
url,
|
response = requests.post(
|
||||||
json=data or {},
|
url,
|
||||||
headers=self._headers(),
|
json=data or {},
|
||||||
timeout=self.timeout
|
headers=self._headers(),
|
||||||
)
|
timeout=request_timeout
|
||||||
response.raise_for_status()
|
)
|
||||||
return response.json() if response.content else {}
|
response.raise_for_status()
|
||||||
except requests.ConnectionError as e:
|
return response.json() if response.content else {}
|
||||||
raise AgentConnectionError(f"Cannot connect to agent at {self.base_url}: {e}")
|
except requests.ConnectionError as e:
|
||||||
except requests.Timeout:
|
raise AgentConnectionError(f"Cannot connect to agent at {self.base_url}: {e}")
|
||||||
raise AgentConnectionError(f"Request to agent timed out after {self.timeout}s")
|
except requests.Timeout:
|
||||||
|
raise AgentConnectionError(f"Request to agent timed out after {request_timeout}s")
|
||||||
except requests.HTTPError as e:
|
except requests.HTTPError as e:
|
||||||
# Try to extract error message from response body
|
# Try to extract error message from response body
|
||||||
error_msg = f"Agent returned error: {e.response.status_code}"
|
error_msg = f"Agent returned error: {e.response.status_code}"
|
||||||
@@ -141,9 +142,9 @@ class AgentClient:
|
|||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
raise AgentHTTPError(f"Request failed: {e}")
|
raise AgentHTTPError(f"Request failed: {e}")
|
||||||
|
|
||||||
def post(self, path: str, data: dict | None = None) -> dict:
|
def post(self, path: str, data: dict | None = None, timeout: float | None = None) -> dict:
|
||||||
"""Public POST method for arbitrary endpoints."""
|
"""Public POST method for arbitrary endpoints."""
|
||||||
return self._post(path, data)
|
return self._post(path, data, timeout=timeout)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Capability & Status
|
# Capability & Status
|
||||||
@@ -214,7 +215,7 @@ class AgentClient:
|
|||||||
"""
|
"""
|
||||||
return self._post(f'/{mode}/start', params or {})
|
return self._post(f'/{mode}/start', params or {})
|
||||||
|
|
||||||
def stop_mode(self, mode: str) -> dict:
|
def stop_mode(self, mode: str, timeout: float = 8.0) -> dict:
|
||||||
"""
|
"""
|
||||||
Stop a running mode on the agent.
|
Stop a running mode on the agent.
|
||||||
|
|
||||||
@@ -224,7 +225,7 @@ class AgentClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Stop result with 'status' field
|
Stop result with 'status' field
|
||||||
"""
|
"""
|
||||||
return self._post(f'/{mode}/stop')
|
return self._post(f'/{mode}/stop', timeout=timeout)
|
||||||
|
|
||||||
def get_mode_status(self, mode: str) -> dict:
|
def get_mode_status(self, mode: str) -> dict:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ def _load_meta() -> dict[str, Any] | None:
|
|||||||
if os.path.exists(DB_META_FILE):
|
if os.path.exists(DB_META_FILE):
|
||||||
with open(DB_META_FILE, 'r') as f:
|
with open(DB_META_FILE, 'r') as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(f"Corrupt aircraft db meta file, removing: {e}")
|
||||||
|
try:
|
||||||
|
os.remove(DB_META_FILE)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error loading aircraft db meta: {e}")
|
logger.warning(f"Error loading aircraft db meta: {e}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,230 +0,0 @@
|
|||||||
"""Cross-mode analytics: activity tracking, summaries, and emergency squawk detection."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import time
|
|
||||||
from collections import deque
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import app as app_module
|
|
||||||
|
|
||||||
|
|
||||||
class ModeActivityTracker:
|
|
||||||
"""Track device counts per mode in time-bucketed ring buffer for sparklines."""
|
|
||||||
|
|
||||||
def __init__(self, max_buckets: int = 60, bucket_interval: float = 5.0):
|
|
||||||
self._max_buckets = max_buckets
|
|
||||||
self._bucket_interval = bucket_interval
|
|
||||||
self._history: dict[str, deque] = {}
|
|
||||||
self._last_record_time = 0.0
|
|
||||||
|
|
||||||
def record(self) -> None:
|
|
||||||
"""Snapshot current counts for all modes."""
|
|
||||||
now = time.time()
|
|
||||||
if now - self._last_record_time < self._bucket_interval:
|
|
||||||
return
|
|
||||||
self._last_record_time = now
|
|
||||||
|
|
||||||
counts = _get_mode_counts()
|
|
||||||
for mode, count in counts.items():
|
|
||||||
if mode not in self._history:
|
|
||||||
self._history[mode] = deque(maxlen=self._max_buckets)
|
|
||||||
self._history[mode].append(count)
|
|
||||||
|
|
||||||
def get_sparkline(self, mode: str) -> list[int]:
|
|
||||||
"""Return sparkline array for a mode."""
|
|
||||||
self.record()
|
|
||||||
return list(self._history.get(mode, []))
|
|
||||||
|
|
||||||
def get_all_sparklines(self) -> dict[str, list[int]]:
|
|
||||||
"""Return sparkline arrays for all tracked modes."""
|
|
||||||
self.record()
|
|
||||||
return {mode: list(values) for mode, values in self._history.items()}
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton
|
|
||||||
_tracker: ModeActivityTracker | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_activity_tracker() -> ModeActivityTracker:
|
|
||||||
global _tracker
|
|
||||||
if _tracker is None:
|
|
||||||
_tracker = ModeActivityTracker()
|
|
||||||
return _tracker
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_len(attr_name: str) -> int:
|
|
||||||
"""Safely get len() of an app_module attribute."""
|
|
||||||
try:
|
|
||||||
return len(getattr(app_module, attr_name))
|
|
||||||
except Exception:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_route_attr(module_path: str, attr_name: str, default: int = 0) -> int:
|
|
||||||
"""Safely read a module-level counter from a route file."""
|
|
||||||
try:
|
|
||||||
import importlib
|
|
||||||
mod = importlib.import_module(module_path)
|
|
||||||
return int(getattr(mod, attr_name, default))
|
|
||||||
except Exception:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def _get_mode_counts() -> dict[str, int]:
|
|
||||||
"""Read current entity counts from all available data sources."""
|
|
||||||
counts: dict[str, int] = {}
|
|
||||||
|
|
||||||
# ADS-B aircraft (DataStore)
|
|
||||||
counts['adsb'] = _safe_len('adsb_aircraft')
|
|
||||||
|
|
||||||
# AIS vessels (DataStore)
|
|
||||||
counts['ais'] = _safe_len('ais_vessels')
|
|
||||||
|
|
||||||
# WiFi: prefer v2 scanner, fall back to legacy DataStore
|
|
||||||
wifi_count = 0
|
|
||||||
try:
|
|
||||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
|
||||||
if wifi_scanner is not None:
|
|
||||||
wifi_count = len(wifi_scanner.access_points)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if wifi_count == 0:
|
|
||||||
wifi_count = _safe_len('wifi_networks')
|
|
||||||
counts['wifi'] = wifi_count
|
|
||||||
|
|
||||||
# Bluetooth: prefer v2 scanner, fall back to legacy DataStore
|
|
||||||
bt_count = 0
|
|
||||||
try:
|
|
||||||
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
|
||||||
if bt_scanner is not None:
|
|
||||||
bt_count = len(bt_scanner.get_devices())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if bt_count == 0:
|
|
||||||
bt_count = _safe_len('bt_devices')
|
|
||||||
counts['bluetooth'] = bt_count
|
|
||||||
|
|
||||||
# DSC messages (DataStore)
|
|
||||||
counts['dsc'] = _safe_len('dsc_messages')
|
|
||||||
|
|
||||||
# ACARS message count (route-level counter)
|
|
||||||
counts['acars'] = _safe_route_attr('routes.acars', 'acars_message_count')
|
|
||||||
|
|
||||||
# VDL2 message count (route-level counter)
|
|
||||||
counts['vdl2'] = _safe_route_attr('routes.vdl2', 'vdl2_message_count')
|
|
||||||
|
|
||||||
# APRS stations (route-level dict)
|
|
||||||
try:
|
|
||||||
import routes.aprs as aprs_mod
|
|
||||||
counts['aprs'] = len(getattr(aprs_mod, 'aprs_stations', {}))
|
|
||||||
except Exception:
|
|
||||||
counts['aprs'] = 0
|
|
||||||
|
|
||||||
# Meshtastic recent messages (route-level list)
|
|
||||||
try:
|
|
||||||
import routes.meshtastic as mesh_route
|
|
||||||
counts['meshtastic'] = len(getattr(mesh_route, '_recent_messages', []))
|
|
||||||
except Exception:
|
|
||||||
counts['meshtastic'] = 0
|
|
||||||
|
|
||||||
return counts
|
|
||||||
|
|
||||||
|
|
||||||
def get_cross_mode_summary() -> dict[str, Any]:
|
|
||||||
"""Return counts dict for all available data sources."""
|
|
||||||
counts = _get_mode_counts()
|
|
||||||
wifi_clients_count = 0
|
|
||||||
try:
|
|
||||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
|
||||||
if wifi_scanner is not None:
|
|
||||||
wifi_clients_count = len(wifi_scanner.clients)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if wifi_clients_count == 0:
|
|
||||||
wifi_clients_count = _safe_len('wifi_clients')
|
|
||||||
counts['wifi_clients'] = wifi_clients_count
|
|
||||||
return counts
|
|
||||||
|
|
||||||
|
|
||||||
def get_mode_health() -> dict[str, dict]:
|
|
||||||
"""Check process refs and SDR status for each mode."""
|
|
||||||
health: dict[str, dict] = {}
|
|
||||||
|
|
||||||
process_map = {
|
|
||||||
'pager': 'current_process',
|
|
||||||
'sensor': 'sensor_process',
|
|
||||||
'adsb': 'adsb_process',
|
|
||||||
'ais': 'ais_process',
|
|
||||||
'acars': 'acars_process',
|
|
||||||
'vdl2': 'vdl2_process',
|
|
||||||
'aprs': 'aprs_process',
|
|
||||||
'wifi': 'wifi_process',
|
|
||||||
'bluetooth': 'bt_process',
|
|
||||||
'dsc': 'dsc_process',
|
|
||||||
'rtlamr': 'rtlamr_process',
|
|
||||||
}
|
|
||||||
|
|
||||||
for mode, attr in process_map.items():
|
|
||||||
proc = getattr(app_module, attr, None)
|
|
||||||
running = proc is not None and (proc.poll() is None if proc else False)
|
|
||||||
health[mode] = {'running': running}
|
|
||||||
|
|
||||||
# Override WiFi/BT health with v2 scanner status if available
|
|
||||||
try:
|
|
||||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
|
||||||
if wifi_scanner is not None and wifi_scanner.is_scanning:
|
|
||||||
health['wifi'] = {'running': True}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
|
||||||
if bt_scanner is not None and bt_scanner.is_scanning:
|
|
||||||
health['bluetooth'] = {'running': True}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Meshtastic: check client connection status
|
|
||||||
try:
|
|
||||||
from utils.meshtastic import get_meshtastic_client
|
|
||||||
client = get_meshtastic_client()
|
|
||||||
health['meshtastic'] = {'running': client._interface is not None}
|
|
||||||
except Exception:
|
|
||||||
health['meshtastic'] = {'running': False}
|
|
||||||
|
|
||||||
try:
|
|
||||||
sdr_status = app_module.get_sdr_device_status()
|
|
||||||
health['sdr_devices'] = {str(k): v for k, v in sdr_status.items()}
|
|
||||||
except Exception:
|
|
||||||
health['sdr_devices'] = {}
|
|
||||||
|
|
||||||
return health
|
|
||||||
|
|
||||||
|
|
||||||
EMERGENCY_SQUAWKS = {
|
|
||||||
'7700': 'General Emergency',
|
|
||||||
'7600': 'Comms Failure',
|
|
||||||
'7500': 'Hijack',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_emergency_squawks() -> list[dict]:
|
|
||||||
"""Iterate adsb_aircraft DataStore for emergency squawk codes."""
|
|
||||||
emergencies: list[dict] = []
|
|
||||||
try:
|
|
||||||
for icao, aircraft in app_module.adsb_aircraft.items():
|
|
||||||
sq = str(aircraft.get('squawk', '')).strip()
|
|
||||||
if sq in EMERGENCY_SQUAWKS:
|
|
||||||
emergencies.append({
|
|
||||||
'icao': icao,
|
|
||||||
'callsign': aircraft.get('callsign', ''),
|
|
||||||
'squawk': sq,
|
|
||||||
'meaning': EMERGENCY_SQUAWKS[sq],
|
|
||||||
'altitude': aircraft.get('altitude'),
|
|
||||||
'lat': aircraft.get('lat'),
|
|
||||||
'lon': aircraft.get('lon'),
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return emergencies
|
|
||||||
+275
-257
@@ -7,68 +7,68 @@ distance estimation, and proximity alerts for search and rescue operations.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from utils.bluetooth.models import BTDeviceAggregate
|
from utils.bluetooth.models import BTDeviceAggregate
|
||||||
from utils.bluetooth.scanner import BluetoothScanner, get_bluetooth_scanner
|
from utils.bluetooth.scanner import BluetoothScanner, get_bluetooth_scanner
|
||||||
from utils.gps import get_current_position
|
from utils.gps import get_current_position
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.bt_locate')
|
logger = logging.getLogger('intercept.bt_locate')
|
||||||
|
|
||||||
# Maximum trail points to retain
|
# Maximum trail points to retain
|
||||||
MAX_TRAIL_POINTS = 500
|
MAX_TRAIL_POINTS = 500
|
||||||
|
|
||||||
# EMA smoothing factor for RSSI
|
# EMA smoothing factor for RSSI
|
||||||
EMA_ALPHA = 0.3
|
EMA_ALPHA = 0.3
|
||||||
|
|
||||||
# Polling/restart tuning for scanner resilience without high CPU churn.
|
# Polling/restart tuning for scanner resilience without high CPU churn.
|
||||||
POLL_INTERVAL_SECONDS = 1.5
|
POLL_INTERVAL_SECONDS = 1.5
|
||||||
SCAN_RESTART_BACKOFF_SECONDS = 8.0
|
SCAN_RESTART_BACKOFF_SECONDS = 8.0
|
||||||
NO_MATCH_LOG_EVERY_POLLS = 10
|
NO_MATCH_LOG_EVERY_POLLS = 10
|
||||||
|
|
||||||
|
|
||||||
def _normalize_mac(address: str | None) -> str | None:
|
def _normalize_mac(address: str | None) -> str | None:
|
||||||
"""Normalize MAC string to colon-separated uppercase form when possible."""
|
"""Normalize MAC string to colon-separated uppercase form when possible."""
|
||||||
if not address:
|
if not address:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
text = str(address).strip().upper().replace('-', ':')
|
text = str(address).strip().upper().replace('-', ':')
|
||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Handle raw 12-hex form: AABBCCDDEEFF
|
# Handle raw 12-hex form: AABBCCDDEEFF
|
||||||
raw = ''.join(ch for ch in text if ch in '0123456789ABCDEF')
|
raw = ''.join(ch for ch in text if ch in '0123456789ABCDEF')
|
||||||
if ':' not in text and len(raw) == 12:
|
if ':' not in text and len(raw) == 12:
|
||||||
text = ':'.join(raw[i:i + 2] for i in range(0, 12, 2))
|
text = ':'.join(raw[i:i + 2] for i in range(0, 12, 2))
|
||||||
|
|
||||||
parts = text.split(':')
|
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):
|
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 ':'.join(parts)
|
||||||
|
|
||||||
# Return cleaned original when not a strict MAC (caller may still use exact matching)
|
# Return cleaned original when not a strict MAC (caller may still use exact matching)
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def _address_looks_like_rpa(address: str | None) -> bool:
|
def _address_looks_like_rpa(address: str | None) -> bool:
|
||||||
"""
|
"""
|
||||||
Return True when an address looks like a Resolvable Private Address.
|
Return True when an address looks like a Resolvable Private Address.
|
||||||
|
|
||||||
RPA check: most-significant two bits of the first octet are `01`.
|
RPA check: most-significant two bits of the first octet are `01`.
|
||||||
"""
|
"""
|
||||||
normalized = _normalize_mac(address)
|
normalized = _normalize_mac(address)
|
||||||
if not normalized:
|
if not normalized:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
first_octet = int(normalized.split(':', 1)[0], 16)
|
first_octet = int(normalized.split(':', 1)[0], 16)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return False
|
return False
|
||||||
return (first_octet >> 6) == 1
|
return (first_octet >> 6) == 1
|
||||||
|
|
||||||
|
|
||||||
class Environment(Enum):
|
class Environment(Enum):
|
||||||
@@ -125,110 +125,110 @@ def resolve_rpa(irk: bytes, address: str) -> bool:
|
|||||||
return computed_hash == expected_hash
|
return computed_hash == expected_hash
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LocateTarget:
|
class LocateTarget:
|
||||||
"""Target device specification for locate session."""
|
"""Target device specification for locate session."""
|
||||||
mac_address: str | None = None
|
mac_address: str | None = None
|
||||||
name_pattern: str | None = None
|
name_pattern: str | None = None
|
||||||
irk_hex: str | None = None
|
irk_hex: str | None = None
|
||||||
device_id: str | None = None
|
device_id: str | None = None
|
||||||
device_key: str | None = None
|
device_key: str | None = None
|
||||||
fingerprint_id: str | None = None
|
fingerprint_id: str | None = None
|
||||||
# Hand-off metadata from Bluetooth mode
|
# Hand-off metadata from Bluetooth mode
|
||||||
known_name: str | None = None
|
known_name: str | None = None
|
||||||
known_manufacturer: str | None = None
|
known_manufacturer: str | None = None
|
||||||
last_known_rssi: int | None = None
|
last_known_rssi: int | None = None
|
||||||
_cached_irk_hex: str | None = field(default=None, init=False, repr=False)
|
_cached_irk_hex: str | None = field(default=None, init=False, repr=False)
|
||||||
_cached_irk_bytes: bytes | 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:
|
def _get_irk_bytes(self) -> bytes | None:
|
||||||
"""Parse/cache target IRK bytes once for repeated match checks."""
|
"""Parse/cache target IRK bytes once for repeated match checks."""
|
||||||
if not self.irk_hex:
|
if not self.irk_hex:
|
||||||
return None
|
return None
|
||||||
if self._cached_irk_hex == self.irk_hex:
|
if self._cached_irk_hex == self.irk_hex:
|
||||||
return self._cached_irk_bytes
|
return self._cached_irk_bytes
|
||||||
self._cached_irk_hex = self.irk_hex
|
self._cached_irk_hex = self.irk_hex
|
||||||
self._cached_irk_bytes = None
|
self._cached_irk_bytes = None
|
||||||
try:
|
try:
|
||||||
parsed = bytes.fromhex(self.irk_hex)
|
parsed = bytes.fromhex(self.irk_hex)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return None
|
return None
|
||||||
if len(parsed) != 16:
|
if len(parsed) != 16:
|
||||||
return None
|
return None
|
||||||
self._cached_irk_bytes = parsed
|
self._cached_irk_bytes = parsed
|
||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
def matches(self, device: BTDeviceAggregate, irk_bytes: bytes | None = None) -> bool:
|
def matches(self, device: BTDeviceAggregate, irk_bytes: bytes | None = None) -> bool:
|
||||||
"""Check if a device matches this target."""
|
"""Check if a device matches this target."""
|
||||||
# Match by stable device key (survives MAC randomization for many devices)
|
# Match by stable device key (survives MAC randomization for many devices)
|
||||||
if self.device_key and getattr(device, 'device_key', None) == self.device_key:
|
if self.device_key and getattr(device, 'device_key', None) == self.device_key:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Match by device_id (exact)
|
# Match by device_id (exact)
|
||||||
if self.device_id and device.device_id == self.device_id:
|
if self.device_id and device.device_id == self.device_id:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Match by device_id address portion (without :address_type suffix)
|
# Match by device_id address portion (without :address_type suffix)
|
||||||
if self.device_id and ':' in self.device_id:
|
if self.device_id and ':' in self.device_id:
|
||||||
target_addr_part = self.device_id.rsplit(':', 1)[0].upper()
|
target_addr_part = self.device_id.rsplit(':', 1)[0].upper()
|
||||||
dev_addr = (device.address or '').upper()
|
dev_addr = (device.address or '').upper()
|
||||||
if target_addr_part and dev_addr == target_addr_part:
|
if target_addr_part and dev_addr == target_addr_part:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Match by MAC/address (case-insensitive, normalize separators)
|
# Match by MAC/address (case-insensitive, normalize separators)
|
||||||
if self.mac_address:
|
if self.mac_address:
|
||||||
dev_addr = _normalize_mac(device.address)
|
dev_addr = _normalize_mac(device.address)
|
||||||
target_addr = _normalize_mac(self.mac_address)
|
target_addr = _normalize_mac(self.mac_address)
|
||||||
if dev_addr and target_addr and dev_addr == target_addr:
|
if dev_addr and target_addr and dev_addr == target_addr:
|
||||||
return True
|
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 name pattern
|
# Match by payload fingerprint.
|
||||||
if self.name_pattern and device.name and self.name_pattern.lower() in device.name.lower():
|
# For explicit hand-off sessions, allow exact fingerprint matches even if
|
||||||
return True
|
# stability is still warming up.
|
||||||
|
if self.fingerprint_id:
|
||||||
# Match by known_name from handoff (exact or loose normalized match)
|
dev_fp = getattr(device, 'payload_fingerprint_id', None)
|
||||||
if self.known_name and device.name:
|
dev_fp_stability = getattr(device, 'payload_fingerprint_stability', 0.0) or 0.0
|
||||||
target_name = self.known_name.strip().lower()
|
if dev_fp and dev_fp == self.fingerprint_id:
|
||||||
device_name = device.name.strip().lower()
|
if dev_fp_stability >= 0.35:
|
||||||
if target_name and (
|
return True
|
||||||
target_name == device_name
|
if any([self.device_id, self.device_key, self.mac_address, self.known_name]):
|
||||||
or target_name in device_name
|
return True
|
||||||
or device_name in target_name
|
|
||||||
):
|
# Match by RPA resolution
|
||||||
return True
|
if self.irk_hex and device.address and _address_looks_like_rpa(device.address):
|
||||||
|
irk = irk_bytes or self._get_irk_bytes()
|
||||||
return False
|
if irk and resolve_rpa(irk, device.address):
|
||||||
|
return True
|
||||||
def to_dict(self) -> dict:
|
|
||||||
return {
|
# Match by name pattern
|
||||||
'mac_address': self.mac_address,
|
if self.name_pattern and device.name and self.name_pattern.lower() in device.name.lower():
|
||||||
'name_pattern': self.name_pattern,
|
return True
|
||||||
'irk_hex': self.irk_hex,
|
|
||||||
'device_id': self.device_id,
|
# Match by known_name from handoff (exact or loose normalized match)
|
||||||
'device_key': self.device_key,
|
if self.known_name and device.name:
|
||||||
'fingerprint_id': self.fingerprint_id,
|
target_name = self.known_name.strip().lower()
|
||||||
'known_name': self.known_name,
|
device_name = device.name.strip().lower()
|
||||||
'known_manufacturer': self.known_manufacturer,
|
if target_name and (
|
||||||
'last_known_rssi': self.last_known_rssi,
|
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:
|
class DistanceEstimator:
|
||||||
@@ -300,7 +300,7 @@ class LocateSession:
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
self.fallback_lat = fallback_lat
|
self.fallback_lat = fallback_lat
|
||||||
self.fallback_lon = fallback_lon
|
self.fallback_lon = fallback_lon
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
# Distance estimator
|
# Distance estimator
|
||||||
n = custom_exponent if environment == Environment.CUSTOM and custom_exponent else environment.value
|
n = custom_exponent if environment == Environment.CUSTOM and custom_exponent else environment.value
|
||||||
@@ -324,9 +324,9 @@ class LocateSession:
|
|||||||
# Debug counters
|
# Debug counters
|
||||||
self.callback_call_count = 0
|
self.callback_call_count = 0
|
||||||
self.poll_count = 0
|
self.poll_count = 0
|
||||||
self._last_seen_device: str | None = None
|
self._last_seen_device: str | None = None
|
||||||
self._last_scan_restart_attempt = 0.0
|
self._last_scan_restart_attempt = 0.0
|
||||||
self._target_irk = target._get_irk_bytes()
|
self._target_irk = target._get_irk_bytes()
|
||||||
|
|
||||||
# Scanner reference
|
# Scanner reference
|
||||||
self._scanner: BluetoothScanner | None = None
|
self._scanner: BluetoothScanner | None = None
|
||||||
@@ -335,34 +335,34 @@ class LocateSession:
|
|||||||
# Track last RSSI per device to detect changes
|
# Track last RSSI per device to detect changes
|
||||||
self._last_cb_rssi: dict[str, int] = {} # Dedup for rapid callbacks only
|
self._last_cb_rssi: dict[str, int] = {} # Dedup for rapid callbacks only
|
||||||
|
|
||||||
def start(self) -> bool:
|
def start(self) -> bool:
|
||||||
"""Start the locate session.
|
"""Start the locate session.
|
||||||
|
|
||||||
Subscribes to scanner callbacks AND runs a polling thread that
|
Subscribes to scanner callbacks AND runs a polling thread that
|
||||||
checks the aggregator directly (handles bleak scan timeout).
|
checks the aggregator directly (handles bleak scan timeout).
|
||||||
"""
|
"""
|
||||||
self._scanner = get_bluetooth_scanner()
|
self._scanner = get_bluetooth_scanner()
|
||||||
self._scanner.add_device_callback(self._on_device)
|
self._scanner.add_device_callback(self._on_device)
|
||||||
self._scanner_started_by_us = False
|
self._scanner_started_by_us = False
|
||||||
|
|
||||||
# Ensure BLE scanning is active
|
# Ensure BLE scanning is active
|
||||||
if not self._scanner.is_scanning:
|
if not self._scanner.is_scanning:
|
||||||
logger.info("BT scanner not running, starting scan for locate session")
|
logger.info("BT scanner not running, starting scan for locate session")
|
||||||
self._scanner_started_by_us = True
|
self._scanner_started_by_us = True
|
||||||
self._last_scan_restart_attempt = time.monotonic()
|
self._last_scan_restart_attempt = time.monotonic()
|
||||||
if not self._scanner.start_scan(mode='auto'):
|
if not self._scanner.start_scan(mode='auto'):
|
||||||
# Surface startup failure to caller and avoid leaving stale callbacks.
|
# Surface startup failure to caller and avoid leaving stale callbacks.
|
||||||
status = self._scanner.get_status()
|
status = self._scanner.get_status()
|
||||||
reason = status.error or "unknown error"
|
reason = status.error or "unknown error"
|
||||||
logger.warning(f"Failed to start BT scanner for locate session: {reason}")
|
logger.warning(f"Failed to start BT scanner for locate session: {reason}")
|
||||||
self._scanner.remove_device_callback(self._on_device)
|
self._scanner.remove_device_callback(self._on_device)
|
||||||
self._scanner = None
|
self._scanner = None
|
||||||
self._scanner_started_by_us = False
|
self._scanner_started_by_us = False
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self.active = True
|
self.active = True
|
||||||
self.started_at = datetime.now()
|
self.started_at = datetime.now()
|
||||||
self._stop_event.clear()
|
self._stop_event.clear()
|
||||||
|
|
||||||
# Start polling thread as reliable fallback
|
# Start polling thread as reliable fallback
|
||||||
self._poll_thread = threading.Thread(
|
self._poll_thread = threading.Thread(
|
||||||
@@ -388,40 +388,40 @@ class LocateSession:
|
|||||||
|
|
||||||
def _poll_loop(self) -> None:
|
def _poll_loop(self) -> None:
|
||||||
"""Poll scanner aggregator for target device updates."""
|
"""Poll scanner aggregator for target device updates."""
|
||||||
while not self._stop_event.is_set():
|
while not self._stop_event.is_set():
|
||||||
self._stop_event.wait(timeout=POLL_INTERVAL_SECONDS)
|
self._stop_event.wait(timeout=POLL_INTERVAL_SECONDS)
|
||||||
if self._stop_event.is_set():
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
self._check_aggregator()
|
self._check_aggregator()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Locate poll error: {e}")
|
logger.error(f"Locate poll error: {e}")
|
||||||
|
|
||||||
def _check_aggregator(self) -> None:
|
def _check_aggregator(self) -> None:
|
||||||
"""Check the scanner's aggregator for the target device."""
|
"""Check the scanner's aggregator for the target device."""
|
||||||
if not self._scanner:
|
if not self._scanner:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.poll_count += 1
|
self.poll_count += 1
|
||||||
|
|
||||||
# Restart scan if it expired (bleak 10s timeout)
|
# Restart scan if it expired (bleak 10s timeout)
|
||||||
if not self._scanner.is_scanning:
|
if not self._scanner.is_scanning:
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if (now - self._last_scan_restart_attempt) >= SCAN_RESTART_BACKOFF_SECONDS:
|
if (now - self._last_scan_restart_attempt) >= SCAN_RESTART_BACKOFF_SECONDS:
|
||||||
self._last_scan_restart_attempt = now
|
self._last_scan_restart_attempt = now
|
||||||
logger.info("Scanner stopped, restarting for locate session")
|
logger.info("Scanner stopped, restarting for locate session")
|
||||||
self._scanner.start_scan(mode='auto')
|
self._scanner.start_scan(mode='auto')
|
||||||
|
|
||||||
# Check devices seen within a recent window. Using a short window
|
# Check devices seen within a recent window. Using a short window
|
||||||
# (rather than the aggregator's full 120s) so that once a device
|
# (rather than the aggregator's full 120s) so that once a device
|
||||||
# goes silent its stale RSSI stops producing detections. The window
|
# goes silent its stale RSSI stops producing detections. The window
|
||||||
# must survive bleak's 10s scan cycle + restart gap (~3s).
|
# must survive bleak's 10s scan cycle + restart gap (~3s).
|
||||||
devices = self._scanner.get_devices(max_age_seconds=15)
|
devices = self._scanner.get_devices(max_age_seconds=15)
|
||||||
found_target = False
|
found_target = False
|
||||||
for device in devices:
|
for device in devices:
|
||||||
if not self.target.matches(device, irk_bytes=self._target_irk):
|
if not self.target.matches(device, irk_bytes=self._target_irk):
|
||||||
continue
|
continue
|
||||||
found_target = True
|
found_target = True
|
||||||
rssi = device.rssi_current
|
rssi = device.rssi_current
|
||||||
if rssi is None:
|
if rssi is None:
|
||||||
continue
|
continue
|
||||||
@@ -429,14 +429,14 @@ class LocateSession:
|
|||||||
break # One match per poll cycle is sufficient
|
break # One match per poll cycle is sufficient
|
||||||
|
|
||||||
# Log periodically for debugging
|
# Log periodically for debugging
|
||||||
if (
|
if (
|
||||||
self.poll_count <= 5
|
self.poll_count <= 5
|
||||||
or self.poll_count % 20 == 0
|
or self.poll_count % 20 == 0
|
||||||
or (not found_target and self.poll_count % NO_MATCH_LOG_EVERY_POLLS == 0)
|
or (not found_target and self.poll_count % NO_MATCH_LOG_EVERY_POLLS == 0)
|
||||||
):
|
):
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Poll #{self.poll_count}: {len(devices)} devices, "
|
f"Poll #{self.poll_count}: {len(devices)} devices, "
|
||||||
f"target_found={found_target}, "
|
f"target_found={found_target}, "
|
||||||
f"detections={self.detection_count}, "
|
f"detections={self.detection_count}, "
|
||||||
f"scanning={self._scanner.is_scanning}"
|
f"scanning={self._scanner.is_scanning}"
|
||||||
)
|
)
|
||||||
@@ -449,8 +449,8 @@ class LocateSession:
|
|||||||
self.callback_call_count += 1
|
self.callback_call_count += 1
|
||||||
self._last_seen_device = f"{device.device_id}|{device.name}"
|
self._last_seen_device = f"{device.device_id}|{device.name}"
|
||||||
|
|
||||||
if not self.target.matches(device, irk_bytes=self._target_irk):
|
if not self.target.matches(device, irk_bytes=self._target_irk):
|
||||||
return
|
return
|
||||||
|
|
||||||
rssi = device.rssi_current
|
rssi = device.rssi_current
|
||||||
if rssi is None:
|
if rssi is None:
|
||||||
@@ -478,9 +478,9 @@ class LocateSession:
|
|||||||
band = DistanceEstimator.proximity_band(distance)
|
band = DistanceEstimator.proximity_band(distance)
|
||||||
|
|
||||||
# Check RPA resolution
|
# Check RPA resolution
|
||||||
rpa_resolved = False
|
rpa_resolved = False
|
||||||
if self._target_irk and device.address and _address_looks_like_rpa(device.address):
|
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 = resolve_rpa(self._target_irk, device.address)
|
||||||
|
|
||||||
# GPS tag — prefer live GPS, fall back to user-set coordinates
|
# GPS tag — prefer live GPS, fall back to user-set coordinates
|
||||||
gps_pos = get_current_position()
|
gps_pos = get_current_position()
|
||||||
@@ -542,15 +542,15 @@ class LocateSession:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return [p.to_dict() for p in self.trail if p.lat is not None]
|
return [p.to_dict() for p in self.trail if p.lat is not None]
|
||||||
|
|
||||||
def get_status(self, include_debug: bool = False) -> dict:
|
def get_status(self, include_debug: bool = False) -> dict:
|
||||||
"""Get session status."""
|
"""Get session status."""
|
||||||
gps_pos = get_current_position()
|
gps_pos = get_current_position()
|
||||||
|
|
||||||
# Collect scanner/aggregator data OUTSIDE self._lock to avoid ABBA
|
# Collect scanner/aggregator data OUTSIDE self._lock to avoid ABBA
|
||||||
# deadlock: get_status would hold self._lock then wait on
|
# deadlock: get_status would hold self._lock then wait on
|
||||||
# aggregator._lock, while _poll_loop holds aggregator._lock then
|
# aggregator._lock, while _poll_loop holds aggregator._lock then
|
||||||
# waits on self._lock in _record_detection.
|
# 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_running = self._scanner.is_scanning if self._scanner else False
|
||||||
scanner_device_count = self._scanner.device_count if self._scanner else 0
|
scanner_device_count = self._scanner.device_count if self._scanner else 0
|
||||||
callback_registered = (
|
callback_registered = (
|
||||||
@@ -586,8 +586,8 @@ class LocateSession:
|
|||||||
'latest_rssi_ema': round(self.trail[-1].rssi_ema, 1) if self.trail else None,
|
'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_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,
|
'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:
|
def set_environment(self, environment: Environment, custom_exponent: float | None = None) -> None:
|
||||||
"""Update the environment and recalculate distance estimator."""
|
"""Update the environment and recalculate distance estimator."""
|
||||||
@@ -602,16 +602,16 @@ class LocateSession:
|
|||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
devices = self._scanner.get_devices(max_age_seconds=30)
|
devices = self._scanner.get_devices(max_age_seconds=30)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
'id': d.device_id,
|
'id': d.device_id,
|
||||||
'addr': d.address,
|
'addr': d.address,
|
||||||
'name': d.name,
|
'name': d.name,
|
||||||
'rssi': d.rssi_current,
|
'rssi': d.rssi_current,
|
||||||
'match': self.target.matches(d, irk_bytes=self._target_irk),
|
'match': self.target.matches(d, irk_bytes=self._target_irk),
|
||||||
}
|
}
|
||||||
for d in devices[:8]
|
for d in devices[:8]
|
||||||
]
|
]
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -627,37 +627,55 @@ _session: LocateSession | None = None
|
|||||||
_session_lock = threading.Lock()
|
_session_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def start_locate_session(
|
def start_locate_session(
|
||||||
target: LocateTarget,
|
target: LocateTarget,
|
||||||
environment: Environment = Environment.OUTDOOR,
|
environment: Environment = Environment.OUTDOOR,
|
||||||
custom_exponent: float | None = None,
|
custom_exponent: float | None = None,
|
||||||
fallback_lat: float | None = None,
|
fallback_lat: float | None = None,
|
||||||
fallback_lon: float | None = None,
|
fallback_lon: float | None = None,
|
||||||
) -> LocateSession:
|
) -> LocateSession:
|
||||||
"""Start a new locate session, stopping any existing one."""
|
"""Start a new locate session, stopping any existing one."""
|
||||||
global _session
|
global _session
|
||||||
|
|
||||||
with _session_lock:
|
# Grab and evict any existing session without holding the lock during stop()
|
||||||
if _session and _session.active:
|
# (stop() joins a thread which can block for up to 3 s).
|
||||||
_session.stop()
|
old_session = None
|
||||||
|
with _session_lock:
|
||||||
_session = LocateSession(
|
if _session and _session.active:
|
||||||
target, environment, custom_exponent, fallback_lat, fallback_lon
|
old_session = _session
|
||||||
)
|
_session = None
|
||||||
if not _session.start():
|
|
||||||
_session = None
|
if old_session:
|
||||||
raise RuntimeError("Bluetooth scanner failed to start")
|
old_session.stop()
|
||||||
return _session
|
|
||||||
|
new_session = LocateSession(
|
||||||
|
target, environment, custom_exponent, fallback_lat, fallback_lon
|
||||||
|
)
|
||||||
|
with _session_lock:
|
||||||
|
_session = new_session
|
||||||
|
|
||||||
|
if not new_session.start():
|
||||||
|
with _session_lock:
|
||||||
|
if _session is new_session:
|
||||||
|
_session = None
|
||||||
|
raise RuntimeError("Bluetooth scanner failed to start")
|
||||||
|
|
||||||
|
return new_session
|
||||||
|
|
||||||
|
|
||||||
def stop_locate_session() -> None:
|
def stop_locate_session() -> None:
|
||||||
"""Stop the active locate session."""
|
"""Stop the active locate session."""
|
||||||
global _session
|
global _session
|
||||||
|
|
||||||
|
# Release the lock before stop() so concurrent status/SSE requests
|
||||||
|
# aren't blocked for up to 3 s while the poll thread is joined.
|
||||||
|
session_to_stop = None
|
||||||
with _session_lock:
|
with _session_lock:
|
||||||
if _session:
|
session_to_stop = _session
|
||||||
_session.stop()
|
_session = None
|
||||||
_session = None
|
|
||||||
|
if session_to_stop:
|
||||||
|
session_to_stop.stop()
|
||||||
|
|
||||||
|
|
||||||
def get_locate_session() -> LocateSession | None:
|
def get_locate_session() -> LocateSession | None:
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ def safe_terminate(process: subprocess.Popen | None, timeout: float = 2.0) -> bo
|
|||||||
return True
|
return True
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
process.kill()
|
process.kill()
|
||||||
|
try:
|
||||||
|
process.wait(timeout=3)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
pass
|
||||||
unregister_process(process)
|
unregister_process(process)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+23
-20
@@ -112,18 +112,21 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
|||||||
lib_paths = ['/usr/local/lib', '/opt/homebrew/lib']
|
lib_paths = ['/usr/local/lib', '/opt/homebrew/lib']
|
||||||
current_ld = env.get('DYLD_LIBRARY_PATH', '')
|
current_ld = env.get('DYLD_LIBRARY_PATH', '')
|
||||||
env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + [current_ld] if current_ld else lib_paths)
|
env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + [current_ld] if current_ld else lib_paths)
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['rtl_test', '-t'],
|
['rtl_test', '-t'],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=5,
|
encoding='utf-8',
|
||||||
env=env
|
errors='replace',
|
||||||
)
|
timeout=5,
|
||||||
output = result.stderr + result.stdout
|
env=env
|
||||||
|
)
|
||||||
# Parse device info from rtl_test output
|
output = result.stderr + result.stdout
|
||||||
# Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001"
|
|
||||||
device_pattern = r'(\d+):\s+(.+?)(?:,\s*SN:\s*(\S+))?$'
|
# 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
|
from .rtlsdr import RTLSDRCommandBuilder
|
||||||
|
|
||||||
@@ -131,14 +134,14 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
|||||||
line = line.strip()
|
line = line.strip()
|
||||||
match = re.match(device_pattern, line)
|
match = re.match(device_pattern, line)
|
||||||
if match:
|
if match:
|
||||||
devices.append(SDRDevice(
|
devices.append(SDRDevice(
|
||||||
sdr_type=SDRType.RTL_SDR,
|
sdr_type=SDRType.RTL_SDR,
|
||||||
index=int(match.group(1)),
|
index=int(match.group(1)),
|
||||||
name=match.group(2).strip().rstrip(','),
|
name=match.group(2).strip().rstrip(','),
|
||||||
serial=match.group(3) or 'N/A',
|
serial=match.group(3),
|
||||||
driver='rtlsdr',
|
driver='rtlsdr',
|
||||||
capabilities=RTLSDRCommandBuilder.CAPABILITIES
|
capabilities=RTLSDRCommandBuilder.CAPABILITIES
|
||||||
))
|
))
|
||||||
|
|
||||||
# Fallback: if we found devices but couldn't parse details
|
# Fallback: if we found devices but couldn't parse details
|
||||||
if not devices:
|
if not devices:
|
||||||
|
|||||||
@@ -122,6 +122,17 @@ class DecodeProgress:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_scope_waveform(raw_samples: np.ndarray, window_size: int = 256) -> list[int]:
|
||||||
|
"""Compress recent int16 PCM samples to signed 8-bit values for SSE."""
|
||||||
|
if raw_samples.size == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
window = raw_samples[-window_size:] if raw_samples.size > window_size else raw_samples
|
||||||
|
packed = np.rint(window.astype(np.float64) / 256.0).astype(np.int16)
|
||||||
|
packed = np.clip(packed, -127, 127)
|
||||||
|
return packed.tolist()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# DopplerTracker
|
# DopplerTracker
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -423,6 +434,7 @@ class SSTVDecoder:
|
|||||||
# Scope: compute RMS/peak from raw int16 samples every chunk
|
# Scope: compute RMS/peak from raw int16 samples every chunk
|
||||||
rms_val = int(np.sqrt(np.mean(raw_samples.astype(np.float64) ** 2)))
|
rms_val = int(np.sqrt(np.mean(raw_samples.astype(np.float64) ** 2)))
|
||||||
peak_val = int(np.max(np.abs(raw_samples)))
|
peak_val = int(np.max(np.abs(raw_samples)))
|
||||||
|
waveform = _encode_scope_waveform(raw_samples)
|
||||||
|
|
||||||
if image_decoder is not None:
|
if image_decoder is not None:
|
||||||
# Currently decoding an image
|
# Currently decoding an image
|
||||||
@@ -451,7 +463,7 @@ class SSTVDecoder:
|
|||||||
message=f'Decoding {current_mode_name}: {pct}%',
|
message=f'Decoding {current_mode_name}: {pct}%',
|
||||||
partial_image=partial_url,
|
partial_image=partial_url,
|
||||||
))
|
))
|
||||||
self._emit_scope(rms_val, peak_val, 'decoding')
|
self._emit_scope(rms_val, peak_val, 'decoding', waveform)
|
||||||
|
|
||||||
if complete:
|
if complete:
|
||||||
# Save image
|
# Save image
|
||||||
@@ -529,7 +541,7 @@ class SSTVDecoder:
|
|||||||
vis_state=vis_detector.state.value,
|
vis_state=vis_detector.state.value,
|
||||||
))
|
))
|
||||||
|
|
||||||
self._emit_scope(rms_val, peak_val, scope_tone)
|
self._emit_scope(rms_val, peak_val, scope_tone, waveform)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in decode thread: {e}")
|
logger.error(f"Error in decode thread: {e}")
|
||||||
@@ -762,11 +774,20 @@ class SSTVDecoder:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in progress callback: {e}")
|
logger.error(f"Error in progress callback: {e}")
|
||||||
|
|
||||||
def _emit_scope(self, rms: int, peak: int, tone: str | None = None) -> None:
|
def _emit_scope(
|
||||||
|
self,
|
||||||
|
rms: int,
|
||||||
|
peak: int,
|
||||||
|
tone: str | None = None,
|
||||||
|
waveform: list[int] | None = None,
|
||||||
|
) -> None:
|
||||||
"""Emit scope signal levels to callback."""
|
"""Emit scope signal levels to callback."""
|
||||||
if self._callback:
|
if self._callback:
|
||||||
try:
|
try:
|
||||||
self._callback({'type': 'sstv_scope', 'rms': rms, 'peak': peak, 'tone': tone})
|
payload = {'type': 'sstv_scope', 'rms': rms, 'peak': peak, 'tone': tone}
|
||||||
|
if waveform:
|
||||||
|
payload['waveform'] = waveform
|
||||||
|
self._callback(payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
+106
-56
@@ -726,46 +726,76 @@ class UnifiedWiFiScanner:
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def stop_deep_scan(self) -> bool:
|
def stop_deep_scan(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Stop the deep scan.
|
Stop the deep scan.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if scan was stopped.
|
True if scan was stopped.
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
cleanup_process: Optional[subprocess.Popen] = None
|
||||||
if not self._status.is_scanning:
|
cleanup_thread: Optional[threading.Thread] = None
|
||||||
return True
|
cleanup_detector = None
|
||||||
|
|
||||||
# Stop deauth detector first
|
with self._lock:
|
||||||
self._stop_deauth_detector()
|
if not self._status.is_scanning:
|
||||||
|
return True
|
||||||
self._deep_scan_stop_event.set()
|
|
||||||
|
self._deep_scan_stop_event.set()
|
||||||
if self._deep_scan_process:
|
cleanup_process = self._deep_scan_process
|
||||||
try:
|
cleanup_thread = self._deep_scan_thread
|
||||||
self._deep_scan_process.terminate()
|
cleanup_detector = self._deauth_detector
|
||||||
self._deep_scan_process.wait(timeout=5)
|
self._deauth_detector = None
|
||||||
except Exception as e:
|
self._deep_scan_process = None
|
||||||
logger.warning(f"Error terminating airodump-ng: {e}")
|
self._deep_scan_thread = None
|
||||||
try:
|
|
||||||
self._deep_scan_process.kill()
|
self._status.is_scanning = False
|
||||||
except Exception:
|
self._status.error = None
|
||||||
pass
|
|
||||||
self._deep_scan_process = None
|
self._queue_event({
|
||||||
|
'type': 'scan_stopped',
|
||||||
if self._deep_scan_thread:
|
'mode': SCAN_MODE_DEEP,
|
||||||
self._deep_scan_thread.join(timeout=5)
|
})
|
||||||
self._deep_scan_thread = None
|
|
||||||
|
cleanup_start = time.perf_counter()
|
||||||
self._status.is_scanning = False
|
|
||||||
|
def _finalize_stop(
|
||||||
self._queue_event({
|
process: Optional[subprocess.Popen],
|
||||||
'type': 'scan_stopped',
|
scan_thread: Optional[threading.Thread],
|
||||||
'mode': SCAN_MODE_DEEP,
|
detector,
|
||||||
})
|
) -> None:
|
||||||
|
if detector:
|
||||||
return True
|
try:
|
||||||
|
detector.stop()
|
||||||
|
logger.info("Deauth detector stopped")
|
||||||
|
self._queue_event({'type': 'deauth_detector_stopped'})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Error stopping deauth detector: {exc}")
|
||||||
|
|
||||||
|
if process and process.poll() is None:
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
process.wait(timeout=1.5)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if scan_thread and scan_thread.is_alive():
|
||||||
|
scan_thread.join(timeout=1.5)
|
||||||
|
|
||||||
|
elapsed_ms = (time.perf_counter() - cleanup_start) * 1000.0
|
||||||
|
logger.info(f"Deep scan stop finalized in {elapsed_ms:.1f}ms")
|
||||||
|
|
||||||
|
threading.Thread(
|
||||||
|
target=_finalize_stop,
|
||||||
|
args=(cleanup_process, cleanup_thread, cleanup_detector),
|
||||||
|
daemon=True,
|
||||||
|
name='wifi-deep-stop',
|
||||||
|
).start()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
def _run_deep_scan(
|
def _run_deep_scan(
|
||||||
self,
|
self,
|
||||||
@@ -799,14 +829,32 @@ class UnifiedWiFiScanner:
|
|||||||
|
|
||||||
logger.info(f"Starting airodump-ng: {' '.join(cmd)}")
|
logger.info(f"Starting airodump-ng: {' '.join(cmd)}")
|
||||||
|
|
||||||
try:
|
process: Optional[subprocess.Popen] = None
|
||||||
self._deep_scan_process = subprocess.Popen(
|
try:
|
||||||
cmd,
|
process = subprocess.Popen(
|
||||||
stdout=subprocess.DEVNULL,
|
cmd,
|
||||||
stderr=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
)
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
csv_file = f"{output_prefix}-01.csv"
|
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
|
# Poll CSV file for updates
|
||||||
while not self._deep_scan_stop_event.is_set():
|
while not self._deep_scan_stop_event.is_set():
|
||||||
@@ -830,14 +878,16 @@ class UnifiedWiFiScanner:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Error parsing airodump CSV: {e}")
|
logger.debug(f"Error parsing airodump CSV: {e}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Deep scan error: {e}")
|
logger.exception(f"Deep scan error: {e}")
|
||||||
self._queue_event({
|
self._queue_event({
|
||||||
'type': 'scan_error',
|
'type': 'scan_error',
|
||||||
'error': str(e),
|
'error': str(e),
|
||||||
})
|
})
|
||||||
finally:
|
finally:
|
||||||
self._deep_scan_process = None
|
with self._lock:
|
||||||
|
if process is not None and self._deep_scan_process is process:
|
||||||
|
self._deep_scan_process = None
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Observation Processing
|
# Observation Processing
|
||||||
|
|||||||
Reference in New Issue
Block a user