Compare commits
110 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e67b77714 | |||
| b1993847b5 | |||
| cde79f4619 | |||
| cc271819ad | |||
| 8cd64ce3ca | |||
| 9705e58691 | |||
| 3acdab816a | |||
| c31ed14041 | |||
| 7241dbed35 | |||
| 94b358f686 | |||
| 8e19f7e688 | |||
| 7ea06caaa2 | |||
| 5f480caa3f | |||
| 5d4b61b4c3 | |||
| a8e2b9d98d | |||
| 4b225db9da | |||
| aba4ccd040 | |||
| f8a6d0ae70 | |||
| 00681840c8 | |||
| 00be3e940a | |||
| fb2a12773a | |||
| 167f10c7f7 | |||
| e386016349 | |||
| aec925753e | |||
| c3bf30b49c | |||
| c0221ba53d | |||
| af5b17e841 | |||
| b628a5f751 | |||
| 9ec316fbe2 | |||
| a407c7708d | |||
| 1466fc2d30 | |||
| 963bcdf9fa | |||
| cfe03317c9 | |||
| 37ba12daaa | |||
| 5c47e9f10a | |||
| 694786d4e0 | |||
| 06a00ca6b5 | |||
| bbc25ddaa0 | |||
| 02a94281c3 | |||
| cbe5faab3b | |||
| cacfbf5713 | |||
| 2faed68af4 | |||
| bec0881018 | |||
| da2a700bcc | |||
| cd3ed9a03b | |||
| f7fad076c2 | |||
| a397271553 | |||
| 83a54ccb20 | |||
| 2e9bab75b1 | |||
| 0dc40bbea3 | |||
| 17f6947648 | |||
| 481651c88d | |||
| ad4903d4ac | |||
| 3a962ca207 | |||
| f29ae3d5a8 | |||
| 37d24a539d | |||
| 622f23c091 | |||
| b70db887b1 | |||
| 7f13af3fcd | |||
| 9afff0f4b2 | |||
| 5a7a6ce522 | |||
| 36b6539044 | |||
| 6c6cd8a280 | |||
| 4df112e712 | |||
| 3d8b8bbfdc | |||
| 076339024f | |||
| e82f0f36d2 | |||
| f4ade209f9 | |||
| b0652595fa | |||
| 332172881e | |||
| e05ac97749 | |||
| 615a83c23f | |||
| d017375f64 | |||
| 0b5235f619 | |||
| 16239c1d31 | |||
| cae7a0586f | |||
| 23f28a8102 | |||
| 34ecec3800 | |||
| d40bd37406 | |||
| 4ed41434e2 | |||
| 6a0b54fa0e | |||
| b83ecfcc19 | |||
| 671bf38083 | |||
| 0f5a414a09 | |||
| 831426948f | |||
| df2c0a0d25 | |||
| d427f69dcd | |||
| cab04e6e2c | |||
| 8969fefe2e | |||
| 5e9fcc5c49 | |||
| 53b23fc2f7 | |||
| eeb3a29ecf | |||
| 4cdfa98a4e | |||
| 9fcec6cbb8 | |||
| a527ac191a | |||
| 8cd3aafd10 | |||
| 5c76a423af | |||
| c80bf99b91 | |||
| 6e5cb0a23e | |||
| ffb98425f1 | |||
| 533e92c711 | |||
| 9f32b05719 | |||
| 2a05aaa4d8 | |||
| 6529febcfa | |||
| bd87d4b4c6 | |||
| 5a0589dd69 | |||
| 5605ae0359 | |||
| 2b3f351ff0 | |||
| 126b9ba2ee | |||
| c0498ebe68 |
@@ -2,6 +2,89 @@
|
||||
|
||||
All notable changes to iNTERCEPT will be documented in this file.
|
||||
|
||||
## [2.22.2] - 2026-02-23
|
||||
|
||||
### Fixed
|
||||
- Waterfall control panel rendered as unstyled text for up to 20 seconds on first visit — CSS is now loaded eagerly with the rest of the page assets
|
||||
- WebSDR globe failed to render on first page load — initialization now waits for a layout frame before mounting the WebGL renderer, ensuring the container has non-zero dimensions
|
||||
|
||||
---
|
||||
|
||||
## [2.22.1] - 2026-02-23
|
||||
|
||||
### Fixed
|
||||
- PWA install prompt not appearing — manifest now includes required PNG icons (192×192, 512×512)
|
||||
- Apple touch icon updated to PNG for iOS Safari compatibility
|
||||
- Service worker cache bumped to bust stale cached assets
|
||||
|
||||
---
|
||||
|
||||
## [2.22.0] - 2026-02-23
|
||||
|
||||
### Added
|
||||
- **Waterfall Receiver Overhaul** - WebSocket-based I/Q streaming with server-side FFT, click-to-tune, zoom controls, and auto-scaling
|
||||
- **Voice Alerts** - Configurable text-to-speech event notifications across modes
|
||||
- **Signal Fingerprinting** - RF device identification and pattern analysis mode
|
||||
- **SignalID** - Automatic signal classification via SigIDWiki API integration
|
||||
- **PWA Support** - Installable web app with service worker caching and manifest
|
||||
- **Real-time Signal Scope** - Live signal visualization for pager, sensor, and SSTV modes
|
||||
- **ADS-B MSG2 Surface Parsing** - Ground vehicle movement tracking from MSG2 frames
|
||||
- **Cheat Sheets** - Quick reference overlays for keyboard shortcuts and mode controls
|
||||
- App icon (SVG) for PWA and browser tab
|
||||
|
||||
### Changed
|
||||
- **WebSDR overhaul** - Improved receiver management, audio streaming, and UI
|
||||
- **Mode stop responsiveness** - Faster timeout handling and improved WiFi/Bluetooth scanner shutdown
|
||||
- **Mode transitions** - Smoother navigation with performance instrumentation
|
||||
- **BT Locate** - Refactored JS engine with improved trail management and signal smoothing
|
||||
- **Listening Post** - Refactored with cross-module frequency routing
|
||||
- **SSTV decoder** - State machine improvements and partial image streaming
|
||||
- Analytics mode removed; per-mode analytics panels integrated into existing dashboards
|
||||
|
||||
### Fixed
|
||||
- ADS-B SSE multi-client fanout stability and update flush timing
|
||||
- WiFi scanner robustness and monitor mode teardown reliability
|
||||
- Agent client reliability improvements for remote sensor nodes
|
||||
- SSTV VIS detector state reporting in signal monitor diagnostics
|
||||
|
||||
### Documentation
|
||||
- Complete documentation audit across README, FEATURES, USAGE, help modal, and GitHub Pages
|
||||
- Fixed license badge (MIT → Apache 2.0) to match actual LICENSE file
|
||||
- Fixed tool name `rtl_amr` → `rtlamr` throughout all docs
|
||||
- Fixed incorrect entry point examples (`python app.py` → `sudo -E venv/bin/python intercept.py`)
|
||||
- Removed duplicate AIS Vessel Tracking section from FEATURES.md
|
||||
- Updated SSTV requirements: pure Python decoder, no external `slowrx` needed
|
||||
- Added ACARS and VDL2 mode descriptions to in-app help modal
|
||||
- GitHub Pages site: corrected Docker command, license, and tool name references
|
||||
|
||||
---
|
||||
|
||||
## [2.21.1] - 2026-02-20
|
||||
|
||||
### Fixed
|
||||
- BT Locate map first-load rendering race that could cause blank/late map initialization
|
||||
- BT Locate mode switch timing so Leaflet invalidation runs after panel visibility settles
|
||||
- BT Locate trail restore startup latency by batching historical GPS point rendering
|
||||
|
||||
---
|
||||
|
||||
## [2.21.0] - 2026-02-20
|
||||
|
||||
### Added
|
||||
- Analytics panels for operational insights and temporal pattern analysis
|
||||
|
||||
### Changed
|
||||
- Global map theme refresh with improved contrast and cross-dashboard consistency
|
||||
- Cross-app UX refinements for accessibility, mode consistency, and render performance
|
||||
- BT Locate enhancements including improved continuity, smoothing, and confidence reporting
|
||||
|
||||
### Fixed
|
||||
- Weather satellite auto-scheduler and Mercator tracking reliability issues
|
||||
- Bluetooth/WiFi runtime health issues affecting scanner continuity
|
||||
- ADS-B SSE multi-client fanout stability and remote VDL2 streaming reliability
|
||||
|
||||
---
|
||||
|
||||
## [2.15.0] - 2026-02-09
|
||||
|
||||
### Added
|
||||
|
||||
@@ -57,7 +57,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
soapysdr-module-airspy \
|
||||
airspy \
|
||||
limesuite \
|
||||
hackrf \
|
||||
# Utilities
|
||||
curl \
|
||||
procps \
|
||||
@@ -94,7 +93,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpulse-dev \
|
||||
libfftw3-dev \
|
||||
liblapack-dev \
|
||||
libcodec2-dev \
|
||||
libglib2.0-dev \
|
||||
libxml2-dev \
|
||||
# Build dump1090
|
||||
&& cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
|
||||
@@ -137,10 +137,29 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
|
||||
&& cd acarsdec \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. -Drtl=ON \
|
||||
&& cmake .. -Drtl=ON -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
|
||||
&& make \
|
||||
&& cp acarsdec /usr/bin/acarsdec \
|
||||
&& rm -rf /tmp/acarsdec \
|
||||
# Build libacars (required by dumpvdl2)
|
||||
&& cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/szpajder/libacars.git \
|
||||
&& cd libacars \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. \
|
||||
&& make \
|
||||
&& make install \
|
||||
&& ldconfig \
|
||||
&& rm -rf /tmp/libacars \
|
||||
# Build dumpvdl2 (VDL2 aircraft datalink decoder)
|
||||
&& cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/szpajder/dumpvdl2.git \
|
||||
&& cd dumpvdl2 \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. \
|
||||
&& make \
|
||||
&& cp src/dumpvdl2 /usr/bin/dumpvdl2 \
|
||||
&& rm -rf /tmp/dumpvdl2 \
|
||||
# Build slowrx (SSTV decoder) — pinned to known-good commit
|
||||
&& cd /tmp \
|
||||
&& git clone https://github.com/windytan/slowrx.git \
|
||||
@@ -170,6 +189,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
fi \
|
||||
&& cd /tmp \
|
||||
&& rm -rf /tmp/SatDump \
|
||||
# Build hackrf CLI tools from source — avoids libhackrf0 version conflict
|
||||
# between the 'hackrf' apt package and soapysdr-module-hackrf's newer libhackrf0
|
||||
&& cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/greatscottgadgets/hackrf.git \
|
||||
&& cd hackrf/host \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. \
|
||||
&& make \
|
||||
&& make install \
|
||||
&& ldconfig \
|
||||
&& rm -rf /tmp/hackrf \
|
||||
# Build rtlamr (utility meter decoder - requires Go)
|
||||
&& cd /tmp \
|
||||
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
|
||||
@@ -178,27 +208,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& go install github.com/bemasher/rtlamr@latest \
|
||||
&& cp /tmp/gopath/bin/rtlamr /usr/bin/rtlamr \
|
||||
&& rm -rf /usr/local/go /tmp/gopath \
|
||||
# Build mbelib (required by DSD)
|
||||
&& cd /tmp \
|
||||
&& git clone https://github.com/lwvmobile/mbelib.git \
|
||||
&& cd mbelib \
|
||||
&& (git checkout ambe_tones || true) \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. \
|
||||
&& make -j$(nproc) \
|
||||
&& make install \
|
||||
&& ldconfig \
|
||||
&& rm -rf /tmp/mbelib \
|
||||
# Build DSD-FME (Digital Speech Decoder for DMR/P25)
|
||||
&& cd /tmp \
|
||||
&& git clone --depth 1 https://github.com/lwvmobile/dsd-fme.git \
|
||||
&& cd dsd-fme \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. \
|
||||
&& make -j$(nproc) \
|
||||
&& make install \
|
||||
&& ldconfig \
|
||||
&& rm -rf /tmp/dsd-fme \
|
||||
# Cleanup build tools to reduce image size
|
||||
# libgtk-3-dev is explicitly removed; runtime GTK libs remain for slowrx
|
||||
&& apt-get remove -y \
|
||||
@@ -226,7 +235,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpulse-dev \
|
||||
libfftw3-dev \
|
||||
liblapack-dev \
|
||||
libcodec2-dev \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="Python 3.9+">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green.svg" alt="MIT License">
|
||||
<img src="https://img.shields.io/badge/license-Apache--2.0-green.svg" alt="Apache 2.0 License">
|
||||
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg" alt="Platform">
|
||||
</p>
|
||||
|
||||
@@ -32,6 +32,7 @@ Support the developer of this open-source project
|
||||
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
|
||||
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
|
||||
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
|
||||
- **VDL2** - VHF Data Link Mode 2 aircraft datalink decoding via dumpvdl2
|
||||
- **Listening Post** - Wideband frequency scanner with real-time audio monitoring
|
||||
- **Weather Satellites** - NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler
|
||||
- **WebSDR** - Remote HF/shortwave listening via KiwiSDR network
|
||||
@@ -39,7 +40,7 @@ Support the developer of this open-source project
|
||||
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF)
|
||||
- **APRS** - Amateur packet radio position reports and telemetry via direwolf
|
||||
- **Satellite Tracking** - Pass prediction with polar plot and ground track map
|
||||
- **Utility Meters** - Electric, gas, and water meter reading via rtl_amr
|
||||
- **Utility Meters** - Electric, gas, and water meter reading via rtlamr
|
||||
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
||||
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
||||
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
||||
@@ -47,6 +48,7 @@ Support the developer of this open-source project
|
||||
- **GPS** - Real-time GPS position tracking with live map, speed, altitude, and satellite info
|
||||
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
|
||||
- **Meshtastic** - LoRa mesh network integration
|
||||
- **Space Weather** - Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL (no SDR required)
|
||||
- **Spy Stations** - Number stations and diplomatic HF network database
|
||||
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
||||
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
||||
@@ -55,8 +57,6 @@ Support the developer of this open-source project
|
||||
|
||||
## Installation / Debian / Ubuntu / MacOS
|
||||
|
||||
```
|
||||
|
||||
**1. Clone and run:**
|
||||
```bash
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
@@ -148,7 +148,7 @@ Set these as environment variables for either local installs or Docker:
|
||||
```bash
|
||||
INTERCEPT_ADSB_AUTO_START=true \
|
||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||
python app.py
|
||||
sudo -E venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
**Docker example (.env)**
|
||||
@@ -170,7 +170,7 @@ Then open **/adsb/history** for the reporting dashboard.
|
||||
|
||||
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
|
||||
|
||||
The credentials can be change in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py
|
||||
The credentials can be changed in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py
|
||||
|
||||
---
|
||||
|
||||
@@ -243,7 +243,8 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
||||
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
|
||||
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
||||
[direwolf](https://github.com/wb2osz/direwolf) |
|
||||
[rtl_amr](https://github.com/bemasher/rtlamr) |
|
||||
[rtlamr](https://github.com/bemasher/rtlamr) |
|
||||
[dumpvdl2](https://github.com/szpajder/dumpvdl2) |
|
||||
[aircrack-ng](https://www.aircrack-ng.org/) |
|
||||
[Leaflet.js](https://leafletjs.com/) |
|
||||
[SatDump](https://github.com/SatDump/SatDump) |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2026-02-01_ba81b697",
|
||||
"downloaded": "2026-02-04T17:06:54.806043Z"
|
||||
"version": "2026-02-15_ae16bb62",
|
||||
"downloaded": "2026-02-20T00:29:06.228007Z"
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import subprocess
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session
|
||||
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session, send_from_directory
|
||||
from werkzeug.security import check_password_hash
|
||||
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
||||
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
||||
@@ -100,11 +100,24 @@ def add_security_headers(response):
|
||||
def inject_offline_settings():
|
||||
"""Inject offline settings into all templates."""
|
||||
from utils.database import get_setting
|
||||
|
||||
# Privacy-first defaults: keep dashboard assets/fonts local to avoid
|
||||
# third-party tracker/storage defenses in strict browsers.
|
||||
assets_source = str(get_setting('offline.assets_source', 'local') or 'local').lower()
|
||||
fonts_source = str(get_setting('offline.fonts_source', 'local') or 'local').lower()
|
||||
if assets_source not in ('local', 'cdn'):
|
||||
assets_source = 'local'
|
||||
if fonts_source not in ('local', 'cdn'):
|
||||
fonts_source = 'local'
|
||||
# Force local delivery for core dashboard pages.
|
||||
assets_source = 'local'
|
||||
fonts_source = 'local'
|
||||
|
||||
return {
|
||||
'offline_settings': {
|
||||
'enabled': get_setting('offline.enabled', False),
|
||||
'assets_source': get_setting('offline.assets_source', 'cdn'),
|
||||
'fonts_source': get_setting('offline.fonts_source', 'cdn'),
|
||||
'assets_source': assets_source,
|
||||
'fonts_source': fonts_source,
|
||||
'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'),
|
||||
'tile_server_url': get_setting('offline.tile_server_url', '')
|
||||
}
|
||||
@@ -150,6 +163,11 @@ acars_process = None
|
||||
acars_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
acars_lock = threading.Lock()
|
||||
|
||||
# VDL2 aircraft datalink
|
||||
vdl2_process = None
|
||||
vdl2_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
vdl2_lock = threading.Lock()
|
||||
|
||||
# APRS amateur radio tracking
|
||||
aprs_process = None
|
||||
aprs_rtl_process = None
|
||||
@@ -172,12 +190,6 @@ dsc_rtl_process = None
|
||||
dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
dsc_lock = threading.Lock()
|
||||
|
||||
# DMR / Digital Voice
|
||||
dmr_process = None
|
||||
dmr_rtl_process = None
|
||||
dmr_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
dmr_lock = threading.Lock()
|
||||
|
||||
# TSCM (Technical Surveillance Countermeasures)
|
||||
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
tscm_lock = threading.Lock()
|
||||
@@ -384,6 +396,18 @@ def favicon() -> Response:
|
||||
return send_file('favicon.svg', mimetype='image/svg+xml')
|
||||
|
||||
|
||||
@app.route('/sw.js')
|
||||
def service_worker() -> Response:
|
||||
resp = send_from_directory('static', 'sw.js', mimetype='application/javascript')
|
||||
resp.headers['Service-Worker-Allowed'] = '/'
|
||||
return resp
|
||||
|
||||
|
||||
@app.route('/manifest.json')
|
||||
def pwa_manifest() -> Response:
|
||||
return send_from_directory('static', 'manifest.json', mimetype='application/manifest+json')
|
||||
|
||||
|
||||
@app.route('/devices')
|
||||
def get_devices() -> Response:
|
||||
"""Get all detected SDR devices with hardware type info."""
|
||||
@@ -656,20 +680,66 @@ def _get_subghz_active() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _get_dmr_active() -> bool:
|
||||
"""Check if Digital Voice decoder has an active process."""
|
||||
def _get_bluetooth_health() -> tuple[bool, int]:
|
||||
"""Return Bluetooth active state and best-effort device count."""
|
||||
legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False)
|
||||
scanner_running = False
|
||||
scanner_count = 0
|
||||
|
||||
try:
|
||||
from routes import dmr as dmr_module
|
||||
proc = dmr_module.dmr_dsd_process
|
||||
return bool(dmr_module.dmr_running and proc and proc.poll() is None)
|
||||
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
||||
if bt_scanner is not None:
|
||||
scanner_running = bool(bt_scanner.is_scanning)
|
||||
scanner_count = int(bt_scanner.device_count)
|
||||
except Exception:
|
||||
return False
|
||||
scanner_running = False
|
||||
scanner_count = 0
|
||||
|
||||
locate_running = False
|
||||
try:
|
||||
from utils.bt_locate import get_locate_session
|
||||
session = get_locate_session()
|
||||
if session and getattr(session, 'active', False):
|
||||
scanner = getattr(session, '_scanner', None)
|
||||
locate_running = bool(scanner and scanner.is_scanning)
|
||||
except Exception:
|
||||
locate_running = False
|
||||
|
||||
return (legacy_running or scanner_running or locate_running), max(len(bt_devices), scanner_count)
|
||||
|
||||
|
||||
def _get_wifi_health() -> tuple[bool, int, int]:
|
||||
"""Return WiFi active state and best-effort network/client counts."""
|
||||
legacy_running = wifi_process is not None and (wifi_process.poll() is None if wifi_process else False)
|
||||
scanner_running = False
|
||||
scanner_networks = 0
|
||||
scanner_clients = 0
|
||||
|
||||
try:
|
||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
||||
if wifi_scanner is not None:
|
||||
status = wifi_scanner.get_status()
|
||||
scanner_running = bool(status.is_scanning)
|
||||
scanner_networks = int(status.networks_found or 0)
|
||||
scanner_clients = int(status.clients_found or 0)
|
||||
except Exception:
|
||||
scanner_running = False
|
||||
scanner_networks = 0
|
||||
scanner_clients = 0
|
||||
|
||||
return (
|
||||
legacy_running or scanner_running,
|
||||
max(len(wifi_networks), scanner_networks),
|
||||
max(len(wifi_clients), scanner_clients),
|
||||
)
|
||||
|
||||
|
||||
@app.route('/health')
|
||||
def health_check() -> Response:
|
||||
"""Health check endpoint for monitoring."""
|
||||
import time
|
||||
bt_active, bt_device_count = _get_bluetooth_health()
|
||||
wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health()
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'version': VERSION,
|
||||
@@ -680,19 +750,19 @@ def health_check() -> Response:
|
||||
'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False),
|
||||
'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False),
|
||||
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
|
||||
'vdl2': vdl2_process is not None and (vdl2_process.poll() is None if vdl2_process else False),
|
||||
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
|
||||
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
|
||||
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
|
||||
'wifi': wifi_active,
|
||||
'bluetooth': bt_active,
|
||||
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
||||
'dmr': _get_dmr_active(),
|
||||
'subghz': _get_subghz_active(),
|
||||
},
|
||||
'data': {
|
||||
'aircraft_count': len(adsb_aircraft),
|
||||
'vessel_count': len(ais_vessels),
|
||||
'wifi_networks_count': len(wifi_networks),
|
||||
'wifi_clients_count': len(wifi_clients),
|
||||
'bt_devices_count': len(bt_devices),
|
||||
'wifi_networks_count': wifi_network_count,
|
||||
'wifi_clients_count': wifi_client_count,
|
||||
'bt_devices_count': bt_device_count,
|
||||
'dsc_messages_count': len(dsc_messages),
|
||||
}
|
||||
})
|
||||
@@ -702,8 +772,8 @@ def health_check() -> Response:
|
||||
def kill_all() -> Response:
|
||||
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||
global vdl2_process
|
||||
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
|
||||
global dmr_process, dmr_rtl_process
|
||||
|
||||
# Import adsb and ais modules to reset their state
|
||||
from routes import adsb as adsb_module
|
||||
@@ -714,8 +784,8 @@ def kill_all() -> Response:
|
||||
processes_to_kill = [
|
||||
'rtl_fm', 'multimon-ng', 'rtl_433',
|
||||
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
||||
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
|
||||
'hcitool', 'bluetoothctl', 'satdump', 'dsd',
|
||||
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
|
||||
'hcitool', 'bluetoothctl', 'satdump',
|
||||
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
||||
'hackrf_transfer', 'hackrf_sweep'
|
||||
]
|
||||
@@ -751,6 +821,10 @@ def kill_all() -> Response:
|
||||
with acars_lock:
|
||||
acars_process = None
|
||||
|
||||
# Reset VDL2 state
|
||||
with vdl2_lock:
|
||||
vdl2_process = None
|
||||
|
||||
# Reset APRS state
|
||||
with aprs_lock:
|
||||
aprs_process = None
|
||||
@@ -761,11 +835,6 @@ def kill_all() -> Response:
|
||||
dsc_process = None
|
||||
dsc_rtl_process = None
|
||||
|
||||
# Reset DMR state
|
||||
with dmr_lock:
|
||||
dmr_process = None
|
||||
dmr_rtl_process = None
|
||||
|
||||
# Reset Bluetooth state (legacy)
|
||||
with bt_lock:
|
||||
if bt_process:
|
||||
|
||||
@@ -7,10 +7,81 @@ import os
|
||||
import sys
|
||||
|
||||
# Application version
|
||||
VERSION = "2.18.0"
|
||||
VERSION = "2.22.2"
|
||||
|
||||
# Changelog - latest release notes (shown on welcome screen)
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "2.22.2",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"Waterfall control panel no longer shows as unstyled text on first visit",
|
||||
"WebSDR globe renders correctly on first page load without requiring a refresh",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.22.1",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"Waterfall receiver overhaul: WebSocket I/Q streaming with server-side FFT, click-to-tune, and zoom controls",
|
||||
"Voice alerts for configurable event notifications across modes",
|
||||
"Signal fingerprinting mode for RF device identification and pattern analysis",
|
||||
"SignalID integration via SigIDWiki API for automatic signal classification",
|
||||
"PWA support: installable web app with service worker and manifest",
|
||||
"Mode stop responsiveness improvements with faster timeout handling",
|
||||
"Navigation performance instrumentation and smoother mode transitions",
|
||||
"Pager, sensor, and SSTV real-time signal scope visualization",
|
||||
"ADS-B MSG2 surface movement parsing for ground vehicle tracking",
|
||||
"WebSDR major overhaul with improved receiver management and audio streaming",
|
||||
"Documentation audit: fixed license, tool names, entry points, and SSTV decoder references",
|
||||
"Help modal updated with ACARS and VDL2 mode descriptions",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.21.1",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"BT Locate map first-load fix with render stabilization retries during initial mode open",
|
||||
"BT Locate trail restore optimization for faster startup when historical GPS points exist",
|
||||
"BT Locate mode-switch map invalidation timing fix to prevent delayed/blank map render",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.21.0",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"Global map theme refresh with improved contrast and cross-dashboard consistency",
|
||||
"Cross-app UX updates for accessibility, mode consistency, and render performance",
|
||||
"Weather satellite reliability fixes for auto-scheduler and Mercator pass tracking",
|
||||
"Bluetooth/WiFi runtime health fixes with BT Locate continuity and confidence improvements",
|
||||
"ADS-B/VDL2 streaming reliability upgrades for multi-client SSE fanout and remote decoding",
|
||||
"Analytics enhancements with operational insights and temporal pattern panels",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.20.0",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"Space Weather mode: real-time solar and geomagnetic monitoring from NOAA SWPC, NASA SDO, and HamQSL",
|
||||
"Kp index, solar wind, X-ray flux charts with Chart.js visualization",
|
||||
"HF band conditions, D-RAP absorption maps, aurora forecast, and solar imagery",
|
||||
"NOAA Space Weather Scales (G/S/R), flare probability, and active solar regions",
|
||||
"No SDR hardware required — all data from public APIs with server-side caching",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.19.0",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"VDL2 mode with modal message viewer, consolidated into ADS-B dashboard",
|
||||
"ADS-B: trails enabled by default, radar modes removed, CSV export added",
|
||||
"Bundled Roboto Condensed font for offline mode with SVG icon overhaul",
|
||||
"Help modal updated with all modes and correct SVG icons",
|
||||
"Setup script overhauled for reliability and macOS compatibility",
|
||||
"GPS fix for preserving satellites across DOP-only SKY messages",
|
||||
"Fix gpsd deadlock causing GPS connect to hang",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.18.0",
|
||||
"date": "February 2026",
|
||||
@@ -54,7 +125,6 @@ CHANGELOG = [
|
||||
"Pure Python SSTV decoder replacing broken slowrx dependency",
|
||||
"Real-time signal scope for pager, sensor, and SSTV modes",
|
||||
"USB-level device probe to prevent cryptic rtl_fm crashes",
|
||||
"DMR dsd-fme protocol fixes, tuning controls, and state sync",
|
||||
"SDR device lock-up fix from unreleased device registry on crash",
|
||||
]
|
||||
},
|
||||
@@ -62,8 +132,6 @@ CHANGELOG = [
|
||||
"version": "2.14.0",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"DMR/P25/NXDN/D-STAR digital voice decoder with dsd-fme",
|
||||
"DMR visual synthesizer with event-driven spring-physics bars",
|
||||
"HF SSTV general mode with predefined shortwave frequencies",
|
||||
"WebSDR integration for remote HF/shortwave listening",
|
||||
"Listening Post signal scanner and audio pipeline improvements",
|
||||
|
||||
@@ -24,17 +24,6 @@ Complete feature list for all modules.
|
||||
- **Wideband spectrum analysis** with real-time visualization
|
||||
- **I/Q capture** - record raw samples for offline analysis
|
||||
|
||||
## AIS Vessel Tracking
|
||||
|
||||
- **Real-time vessel tracking** via AIS-catcher on 161.975/162.025 MHz
|
||||
- **Full-screen dashboard** - dedicated popout with interactive map
|
||||
- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
|
||||
- **Vessel details popup** - name, MMSI, callsign, destination, ETA
|
||||
- **Navigation data** - speed, course, heading, rate of turn
|
||||
- **Ship type classification** - cargo, tanker, passenger, fishing, etc.
|
||||
- **Vessel dimensions** - length, width, draught
|
||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||
|
||||
## Spy Stations (Number Stations)
|
||||
|
||||
- **Comprehensive database** of active number stations and diplomatic networks
|
||||
@@ -99,6 +88,18 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
|
||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||
- **Message filtering** - filter by message type, flight, or registration
|
||||
|
||||
## VDL2 (VHF Data Link Mode 2)
|
||||
|
||||
- **Real-time VDL2 decoding** via dumpvdl2 on standard VDL2 frequencies
|
||||
- **ACARS-over-AVLC** message capture with full frame parsing
|
||||
- **Signal analysis** - frequency, signal level, noise level, SNR, burst length
|
||||
- **AVLC frame details** - source/destination addresses, frame type, command/response
|
||||
- **Raw JSON inspection** - expandable raw message data for each frame
|
||||
- **Multi-frequency monitoring** - simultaneous reception on multiple VDL2 channels
|
||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||
- **CSV/JSON export** - export captured messages for offline analysis
|
||||
- **Integrated with ADS-B dashboard** - VDL2 messages linked to aircraft tracking
|
||||
|
||||
## Listening Post
|
||||
|
||||
- **Wideband frequency scanning** via rtl_power sweep with SNR filtering
|
||||
@@ -122,11 +123,23 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
|
||||
- **Receiver discovery** with automatic caching
|
||||
- **Frequency tuning** with band presets
|
||||
|
||||
## ISS SSTV
|
||||
|
||||
- **ISS SSTV image reception** on 145.800 MHz FM during special event transmissions
|
||||
- **Real-time ISS tracking** with world map and pass predictions
|
||||
- **Doppler correction** - optional lat/lon input for real-time frequency shift compensation
|
||||
- **Next pass countdown** - time remaining until ISS is overhead
|
||||
- **Image gallery** with timestamped decoded imagery
|
||||
- **TLE updates** - fetch latest ISS orbital elements
|
||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||
|
||||
## HF SSTV
|
||||
|
||||
- **Terrestrial SSTV decoding** across HF (80m-10m), VHF (6m, 2m), and UHF (70cm) bands
|
||||
- **Predefined frequency lookup** for active SSTV calling frequencies
|
||||
- **Predefined frequency lookup** for 13 active SSTV calling frequencies
|
||||
- **Auto-modulation selection** - frequency table maps to correct mode (USB, LSB, FM)
|
||||
- **Image gallery** with decoded transmissions
|
||||
- **Common modes supported** - PD120, PD180, Martin1, Scottie1, Robot36
|
||||
|
||||
## APRS
|
||||
|
||||
@@ -141,6 +154,22 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
|
||||
- **Real-time JSON output** with meter ID, consumption, and signal data
|
||||
- **Multiple meter protocol support** via rtl_tcp integration
|
||||
|
||||
## Space Weather
|
||||
|
||||
- **Real-time solar indices** - Solar Flux Index (SFI), Kp index, A-index, sunspot number
|
||||
- **NOAA Space Weather Scales** - Geomagnetic storms (G), solar radiation (S), radio blackouts (R)
|
||||
- **HF band conditions** - Day/night propagation from HamQSL for 80m through 10m bands
|
||||
- **Solar wind monitoring** - Speed, density, and IMF Bz from DSCOVR satellite
|
||||
- **X-ray flux chart** - GOES X-ray data with flare class scale (A/B/C/M/X)
|
||||
- **Flare probability** - 1-day and 3-day C/M/X-class flare forecasts
|
||||
- **Solar imagery** - NASA SDO 193A, 304A, and magnetogram images
|
||||
- **D-RAP absorption maps** - HF radio absorption at 5-30 MHz frequency bands
|
||||
- **Aurora forecast** - OVATION aurora oval visualization
|
||||
- **SWPC alerts** - Real-time space weather alerts and warnings
|
||||
- **Active solar regions** - Current sunspot region data with location and area
|
||||
- **Auto-refresh** - 5-minute polling with manual refresh option
|
||||
- **No SDR required** - Data fetched from NOAA SWPC, NASA SDO, and HamQSL public APIs
|
||||
|
||||
## Satellite Tracking
|
||||
|
||||
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
||||
|
||||
@@ -206,14 +206,22 @@ Extended base for full-screen dashboards (maps, visualizations).
|
||||
| `listening` | Listening post |
|
||||
| `spystations` | Spy stations |
|
||||
| `meshtastic` | Mesh networking |
|
||||
|
||||
### Navigation Groups
|
||||
| `weathersat` | Weather satellites |
|
||||
| `sstv_general` | HF SSTV |
|
||||
| `gps` | GPS tracking |
|
||||
| `websdr` | WebSDR |
|
||||
| `subghz` | Sub-GHz analyzer |
|
||||
| `bt_locate` | BT Locate |
|
||||
| `analytics` | Analytics dashboard |
|
||||
| `spaceweather` | Space weather |
|
||||
### Navigation Groups
|
||||
|
||||
The navigation is organized into groups:
|
||||
- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic
|
||||
- **Wireless**: WiFi, Bluetooth
|
||||
- **Security**: TSCM
|
||||
- **Space**: Satellite, ISS SSTV
|
||||
- **Signals**: Pager, 433MHz, Meters, Listening Post, SubGHz
|
||||
- **Tracking**: Aircraft, Vessels, APRS, GPS
|
||||
- **Space**: Satellite, ISS SSTV, Weather Sat, HF SSTV, Space Weather
|
||||
- **Wireless**: WiFi, Bluetooth, BT Locate, Meshtastic
|
||||
- **Intel**: TSCM, Analytics, Spy Stations, WebSDR
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -69,6 +69,22 @@ INTERCEPT automatically detects known trackers:
|
||||
|
||||
Common ISM band protocols including garage doors, key fobs, weather stations, and IoT devices in the 300-928 MHz range.
|
||||
|
||||
## VDL2 (Aircraft Datalink)
|
||||
|
||||
1. **Select Hardware** - Choose your SDR type
|
||||
2. **Select Device** - Choose your SDR device
|
||||
3. **Set Frequencies** - Default VDL2 frequencies are pre-configured (136.975, 136.725, 136.775 MHz etc.)
|
||||
4. **Start Decoding** - Click "Start" to begin VDL2 reception via dumpvdl2
|
||||
5. **View Messages** - AVLC frames appear with source/destination, signal levels, and decoded content
|
||||
6. **Inspect Details** - Click a message to view full AVLC frame details and raw JSON
|
||||
7. **Export** - Use CSV or JSON export buttons to save captured messages
|
||||
|
||||
### Tips
|
||||
|
||||
- VDL2 is most active near airports and along flight corridors
|
||||
- Multiple frequencies can be monitored simultaneously for better coverage
|
||||
- VDL2 data is also accessible from the ADS-B dashboard
|
||||
|
||||
## Listening Post
|
||||
|
||||
1. **Select Hardware** - Choose your SDR type
|
||||
@@ -110,6 +126,23 @@ The system highlights aircraft transmitting emergency squawks:
|
||||
- **7600** - Radio failure
|
||||
- **7700** - General emergency
|
||||
|
||||
## ACARS Messaging
|
||||
|
||||
1. **Select Hardware** - Choose your SDR type
|
||||
2. **Select Device** - Choose your SDR device
|
||||
3. **Select Region** - Choose North America, Europe, or Asia-Pacific to auto-populate frequencies
|
||||
4. **Select Frequencies** - Check one or more ACARS frequencies (131.550 MHz primary worldwide, 130.025 MHz secondary USA/Canada, etc.)
|
||||
5. **Adjust Gain** - Set gain (0 for auto, or 0-50 dB)
|
||||
6. **Start Decoding** - Click "Start" to begin ACARS reception via acarsdec
|
||||
7. **View Messages** - Aircraft messages appear in real-time with flight ID, registration, and content
|
||||
|
||||
### Tips
|
||||
|
||||
- A vertical polarization antenna works best for ACARS
|
||||
- Quarter-wave dipole: 57 cm per element at 130 MHz
|
||||
- Stock SDR antenna may work at close range near airports
|
||||
- Outdoor placement with clear sky view significantly improves reception
|
||||
|
||||
## ADS-B History (Optional)
|
||||
|
||||
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
|
||||
@@ -139,7 +172,7 @@ Set the following environment variables (Docker recommended):
|
||||
```bash
|
||||
INTERCEPT_ADSB_AUTO_START=true \
|
||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||
python app.py
|
||||
sudo -E venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
**Docker example (.env)**
|
||||
@@ -206,6 +239,25 @@ Enable the auto-scheduler to automatically capture passes:
|
||||
- Starts SatDump at the correct time and frequency
|
||||
- Decoded images are saved with timestamps
|
||||
|
||||
## Space Weather
|
||||
|
||||
1. **Switch to Space Weather mode** - Select "Space Weather" from the Space navigation group
|
||||
2. **View Dashboard** - Solar indices, NOAA scales, band conditions, and charts load automatically
|
||||
3. **Solar Imagery** - Toggle between SDO 193A, 304A, and Magnetogram views
|
||||
4. **D-RAP Maps** - Select frequency (5-30 MHz) to view HF radio absorption maps
|
||||
5. **Aurora Forecast** - View the OVATION aurora oval for the northern hemisphere
|
||||
6. **Alerts** - Review current SWPC space weather alerts and warnings
|
||||
7. **Active Regions** - View solar active region data (number, location, area)
|
||||
8. **Refresh** - Data auto-refreshes every 5 minutes, or click "Refresh Now"
|
||||
|
||||
### Tips
|
||||
|
||||
- No SDR hardware required — all data comes from public APIs (NOAA SWPC, NASA SDO, HamQSL)
|
||||
- Check HF band conditions before operating on shortwave frequencies
|
||||
- Kp >= 5 indicates geomagnetic storm conditions that may affect HF propagation
|
||||
- D-RAP maps show where HF absorption is highest — useful for path planning
|
||||
- Solar imagery updates approximately every 15 minutes from NASA SDO
|
||||
|
||||
## AIS Vessel Tracking
|
||||
|
||||
1. **Select Hardware** - Choose your SDR type
|
||||
@@ -221,6 +273,61 @@ Digital Selective Calling monitoring runs alongside AIS:
|
||||
- Distress positions plotted with pulsing alert markers
|
||||
- Audio alerts for critical messages
|
||||
|
||||
## WebSDR
|
||||
|
||||
1. **Set Frequency** - Enter a frequency in kHz (e.g., 6500 for 6.5 MHz)
|
||||
2. **Select Mode** - Choose demodulation mode (USB, LSB, AM, CW)
|
||||
3. **Find Receivers** - Click "Find Receivers" to discover available KiwiSDR nodes worldwide
|
||||
4. **Select Receiver** - Click a receiver from the list to connect
|
||||
5. **Listen** - Audio streams in real-time via WebSocket
|
||||
6. **Adjust Volume** - Use the volume slider and monitor the S-meter
|
||||
7. **Spy Station Presets** - Use the quick-tune buttons to jump to known number station frequencies
|
||||
|
||||
### Tips
|
||||
|
||||
- Requires an internet connection to access the KiwiSDR network
|
||||
- Receiver list is cached for 1 hour to reduce API load
|
||||
- Receivers are sorted by distance from your location
|
||||
- Integrated spy station presets allow quick tuning to SIGINT targets
|
||||
|
||||
## ISS SSTV
|
||||
|
||||
1. **Select Hardware** - Choose your SDR type
|
||||
2. **Select Device** - Choose your SDR device
|
||||
3. **Set Frequency** - Default is 145.800 MHz (ISS downlink)
|
||||
4. **Set Location** - Enter lat/lon for Doppler correction and pass prediction
|
||||
5. **Update TLE** - Click "Update TLE" to fetch latest ISS orbital elements
|
||||
6. **Wait for Pass** - The next pass countdown shows when ISS will be overhead
|
||||
7. **Start Decoding** - Click "Start" to begin SSTV reception
|
||||
8. **View Images** - Decoded SSTV images appear in the gallery with timestamps
|
||||
|
||||
### Tips
|
||||
|
||||
- A V-dipole or better antenna is required (stock antenna will not work)
|
||||
- V-dipole construction: 51 cm per element at 145.8 MHz, 120-degree angle between elements
|
||||
- ISS SSTV events occur during special anniversaries and missions — check ARISS for schedules
|
||||
- Best passes have elevation > 30 degrees above horizon
|
||||
- Doppler shift tracking dramatically improves reception quality
|
||||
- Common SSTV modes: PD120, PD180, Martin1, Scottie1
|
||||
- Outdoor antenna placement with clear sky view is essential
|
||||
|
||||
## HF SSTV
|
||||
|
||||
1. **Select Hardware** - Choose your SDR type
|
||||
2. **Select Device** - Choose your SDR device
|
||||
3. **Select Frequency** - Choose from 13 preset frequencies or enter a custom one
|
||||
4. **Modulation** - Auto-selected based on frequency (USB for HF, FM for VHF/UHF)
|
||||
5. **Start Decoding** - Click "Start" to begin SSTV reception
|
||||
6. **View Images** - Decoded amateur radio images appear in the gallery
|
||||
|
||||
### Tips
|
||||
|
||||
- HF frequencies (3-30 MHz) require an upconverter with RTL-SDR
|
||||
- VHF/UHF frequencies (145 MHz, 433 MHz) work directly with RTL-SDR
|
||||
- Most popular frequency: 14.230 MHz USB (20m band) with regular activity
|
||||
- Weekend activity peaks on most HF bands
|
||||
- Amateur license is not required to receive (listen-only)
|
||||
|
||||
## APRS
|
||||
|
||||
1. **Select Hardware** - Choose your SDR type
|
||||
@@ -283,6 +390,46 @@ Digital Selective Calling monitoring runs alongside AIS:
|
||||
- GPS fix may take 30-60 seconds after cold start
|
||||
- Accuracy improves with more satellites in view
|
||||
|
||||
## TSCM (Counter-Surveillance)
|
||||
|
||||
1. **Select Sweep Type** - Choose from Quick Scan (2 min), Standard (5 min), Full Sweep (15 min), or presets for Wireless Cameras, Body-Worn Devices, or GPS Trackers
|
||||
2. **Select Scan Sources** - Toggle WiFi, Bluetooth, and/or RF/SDR scanning and select the appropriate interfaces
|
||||
3. **Select Baseline** - Optionally choose a previously recorded baseline to compare against
|
||||
4. **Start Sweep** - Click "Start Sweep" to begin scanning
|
||||
5. **Review Results** - Detected devices are classified and scored by threat level
|
||||
6. **Record Baseline** - In a known clean environment, record a baseline for future comparison
|
||||
7. **Export Report** - Generate PDF report, JSON annex, or CSV data
|
||||
|
||||
### Threat Levels
|
||||
|
||||
- **Informational (0-2)** - Known or expected devices
|
||||
- **Needs Review (3-5)** - Unusual devices requiring assessment
|
||||
- **High Interest (6+)** - Multiple indicators warrant investigation
|
||||
|
||||
### Tips
|
||||
|
||||
- Record a baseline in a known clean environment before conducting sweeps
|
||||
- Use the meeting window feature to flag new RF signatures during sensitive periods
|
||||
- Full functionality requires WiFi adapter, Bluetooth adapter, and SDR hardware
|
||||
- Threat detection uses a database of 47K+ known tracker fingerprints
|
||||
|
||||
## Spy Stations
|
||||
|
||||
1. **Browse Database** - View the full list of documented number stations and diplomatic networks
|
||||
2. **Filter by Type** - Toggle between Number Stations and Diplomatic Networks
|
||||
3. **Filter by Country** - Select specific countries (Russia, Cuba, Israel, Poland, etc.)
|
||||
4. **Filter by Mode** - Filter by demodulation mode (USB, AM, CW, OFDM)
|
||||
5. **View Details** - Click "Details" on a station card for full information
|
||||
6. **Tune In** - Click "Tune In" to route the station frequency to the Listening Post or WebSDR
|
||||
|
||||
### Tips
|
||||
|
||||
- Data sourced from priyom.org (non-profit monitoring community)
|
||||
- Most activity is on HF bands (3-30 MHz) — propagation varies by time of day
|
||||
- Notable stations: UVB-76 "The Buzzer" (4625 kHz), E06 English Man, HM01 Cuban Numbers
|
||||
- Legal to monitor in most countries (check local regulations)
|
||||
- No decryption or content decoding is included — this is a reference database
|
||||
|
||||
## Meshtastic
|
||||
|
||||
1. **Connect Device** - Plug in a Meshtastic device via USB or connect via TCP
|
||||
@@ -291,6 +438,22 @@ Digital Selective Calling monitoring runs alongside AIS:
|
||||
4. **View Nodes** - Connected nodes displayed with signal metrics (RSSI, SNR)
|
||||
5. **Send Messages** - Type messages to broadcast on the mesh
|
||||
|
||||
## Offline Mode
|
||||
|
||||
1. **Open Settings** - Click the gear icon in the navigation bar
|
||||
2. **Offline Tab** - Toggle "Offline Mode" to enable local assets
|
||||
3. **Configure Sources** - Switch assets and fonts from CDN to local
|
||||
4. **Set Tile Provider** - Choose a map tile provider or enter a custom tile server URL
|
||||
5. **Check Assets** - Click "Check Assets" to verify all local files are present
|
||||
|
||||
### Tips
|
||||
|
||||
- Download required assets: Leaflet JS/CSS, Chart.js, Inter and JetBrains Mono fonts
|
||||
- Assets are stored in the `static/vendor/` directory
|
||||
- For maps, you need a local tile server (e.g., self-hosted OpenStreetMap tiles)
|
||||
- Missing assets fail gracefully with console warnings
|
||||
- Useful for air-gapped environments, field deployments, or reducing latency
|
||||
|
||||
## Remote Agents (Distributed SIGINT)
|
||||
|
||||
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
|
||||
|
||||
|
After Width: | Height: | Size: 3.4 MiB |
|
Before Width: | Height: | Size: 694 KiB After Width: | Height: | Size: 790 KiB |
|
After Width: | Height: | Size: 514 KiB |
|
After Width: | Height: | Size: 853 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 570 KiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 876 KiB |
|
After Width: | Height: | Size: 455 KiB |
|
After Width: | Height: | Size: 886 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
@@ -11,6 +11,7 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="bg-canvas"></canvas>
|
||||
<nav class="navbar">
|
||||
<div class="nav-container">
|
||||
<a href="#" class="nav-logo">iNTERCEPT</a>
|
||||
@@ -35,7 +36,7 @@
|
||||
</div>
|
||||
<div class="hero-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">20+</span>
|
||||
<span class="stat-value">25+</span>
|
||||
<span class="stat-label">Modes</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
@@ -58,151 +59,149 @@
|
||||
<h2>Capabilities</h2>
|
||||
<p class="section-subtitle">Everything you need for signal intelligence in one interface</p>
|
||||
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📟</div>
|
||||
<h3>Pager Decoding</h3>
|
||||
<p>Decode POCSAG and FLEX pager messages using rtl_fm and multimon-ng. Monitor emergency services and legacy paging systems.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">✈️</div>
|
||||
<h3>Aircraft Tracking</h3>
|
||||
<p>Real-time ADS-B tracking with interactive maps, aircraft photos, emergency squawk detection, and range visualization.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📡</div>
|
||||
<h3>433MHz Sensors</h3>
|
||||
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📻</div>
|
||||
<h3>Sub-GHz Analyzer</h3>
|
||||
<p>HackRF-based signal capture and protocol decoding for 300-928 MHz ISM bands with spectrum analysis and replay.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📻</div>
|
||||
<h3>Listening Post</h3>
|
||||
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🛰️</div>
|
||||
<h3>Satellite Tracking</h3>
|
||||
<p>Track satellites with TLE data, sky plots, ground track visualization, and pass predictions for your location.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📶</div>
|
||||
<h3>WiFi Scanning</h3>
|
||||
<p>Monitor mode reconnaissance via aircrack-ng. Network discovery, client tracking, and handshake capture.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔵</div>
|
||||
<h3>Bluetooth Scanning</h3>
|
||||
<p>Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📍</div>
|
||||
<h3>BT Locate</h3>
|
||||
<p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🛰️</div>
|
||||
<h3>GPS Tracking</h3>
|
||||
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🛡️</div>
|
||||
<h3>TSCM</h3>
|
||||
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">⚡</div>
|
||||
<h3>Meter Reading</h3>
|
||||
<p>Intercept smart utility meters via rtl_amr. Monitor electricity, gas, and water meter transmissions.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🚢</div>
|
||||
<h3>Vessel Tracking</h3>
|
||||
<p>Real-time AIS ship tracking via AIS-catcher. Monitor maritime traffic with vessel details, course, speed, and destination.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔢</div>
|
||||
<h3>Spy Stations</h3>
|
||||
<p>Number stations and diplomatic HF network database. Frequencies, schedules, and background info from priyom.org.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🌐</div>
|
||||
<h3>Remote Agents</h3>
|
||||
<p>Distributed signal intelligence with remote sensor nodes. Deploy agents across multiple locations and aggregate data to a central controller.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📴</div>
|
||||
<h3>Offline Mode</h3>
|
||||
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📡</div>
|
||||
<h3>Meshtastic</h3>
|
||||
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🌧️</div>
|
||||
<h3>Weather Satellites</h3>
|
||||
<p>NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler, polar plot, and ground track map.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🖼️</div>
|
||||
<h3>ISS SSTV</h3>
|
||||
<p>Receive Slow Scan Television from the ISS. Real-time tracking globe, pass predictions, and image decoding.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🖼️</div>
|
||||
<h3>HF SSTV</h3>
|
||||
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">✈️</div>
|
||||
<h3>ACARS</h3>
|
||||
<p>Aircraft datalink messages via acarsdec. Decode operational, weather, and position reports from commercial flights.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📍</div>
|
||||
<h3>APRS</h3>
|
||||
<p>Amateur packet radio position reports and telemetry via direwolf. Track amateur radio operators on an interactive map.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🌐</div>
|
||||
<h3>WebSDR</h3>
|
||||
<p>Remote HF/shortwave listening via the KiwiSDR network. Access receivers worldwide with real-time audio streaming.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">⚡</div>
|
||||
<h3>Utility Meters</h3>
|
||||
<p>Smart meter monitoring via rtl_amr. Receive electric, gas, and water meter broadcasts in real time.</p>
|
||||
</div>
|
||||
<div class="carousel-filters">
|
||||
<button class="filter-btn active" data-filter="all">All</button>
|
||||
<button class="filter-btn" data-filter="signals">Signals</button>
|
||||
<button class="filter-btn" data-filter="tracking">Tracking</button>
|
||||
<button class="filter-btn" data-filter="space">Space</button>
|
||||
<button class="filter-btn" data-filter="wireless">Wireless</button>
|
||||
<button class="filter-btn" data-filter="intel">Intel</button>
|
||||
<button class="filter-btn" data-filter="platform">Platform</button>
|
||||
</div>
|
||||
|
||||
<div class="carousel-wrapper">
|
||||
<button class="carousel-arrow carousel-arrow-left" aria-label="Scroll left">‹</button>
|
||||
<div class="carousel-track">
|
||||
<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"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="6" y1="9" x2="6" y2="15"/><line x1="10" y1="9" x2="10" y2="15"/><line x1="14" y1="11" x2="18" y2="11"/><line x1="14" y1="13" x2="18" y2="13"/></svg></div>
|
||||
<h3>Pager Decoding</h3>
|
||||
<p>Decode POCSAG and FLEX pager messages using rtl_fm and multimon-ng. Monitor emergency services and legacy paging systems.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="signals">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="M12 18v4"/><circle cx="12" cy="12" r="4"/><path d="M4.93 4.93l2.83 2.83"/><path d="M16.24 16.24l2.83 2.83"/><path d="M2 12h4"/><path d="M18 12h4"/><path d="M4.93 19.07l2.83-2.83"/><path d="M16.24 7.76l2.83-2.83"/></svg></div>
|
||||
<h3>433MHz Sensors</h3>
|
||||
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="signals">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-9 6 18 3-9h4"/></svg></div>
|
||||
<h3>Sub-GHz Analyzer</h3>
|
||||
<p>HackRF-based signal capture and protocol decoding for 300-928 MHz ISM bands with spectrum analysis and replay.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="signals">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 18.5a6.5 6.5 0 1 1 0-13"/><path d="M17 12h5"/><path d="M12 7V2"/><circle cx="12" cy="12" r="2"/><path d="M8.5 8.5L5 5"/></svg></div>
|
||||
<h3>Listening Post</h3>
|
||||
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="intel">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
|
||||
<h3>WebSDR</h3>
|
||||
<p>Remote HF/shortwave listening via the KiwiSDR network. Access receivers worldwide with real-time audio streaming.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="intel">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M8 8h2v2H8z"/><path d="M14 8h2v2h-2z"/><path d="M8 14h2v2H8z"/><path d="M14 14h2v2h-2z"/><path d="M11 8h2v2h-2z"/><path d="M11 11h2v2h-2z"/></svg></div>
|
||||
<h3>Spy Stations</h3>
|
||||
<p>Number stations and diplomatic HF network database. Frequencies, schedules, and background info from priyom.org.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="tracking">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></div>
|
||||
<h3>APRS</h3>
|
||||
<p>Amateur packet radio position reports and telemetry via direwolf. Track amateur radio operators on an interactive map.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="signals">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></div>
|
||||
<h3>Utility Meters</h3>
|
||||
<p>Smart meter monitoring via rtlamr. Receive electric, gas, and water meter broadcasts in real time.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="tracking">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2L16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></svg></div>
|
||||
<h3>Aircraft Tracking</h3>
|
||||
<p>Real-time ADS-B tracking with interactive maps, aircraft photos, emergency squawk detection, and range visualization.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="tracking">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 8h10"/><path d="M7 12h6"/><path d="M7 16h8"/></svg></div>
|
||||
<h3>ACARS</h3>
|
||||
<p>Aircraft datalink messages via acarsdec. Decode operational, weather, and position reports from commercial flights.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="tracking">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/><circle cx="12" cy="12" r="9"/><path d="M3.5 9h17"/><path d="M3.5 15h17"/></svg></div>
|
||||
<h3>VDL2</h3>
|
||||
<p>VHF Data Link Mode 2 aircraft datalink decoding via dumpvdl2. Real-time ACARS-over-AVLC message capture with signal analysis.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="tracking">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20l4-4h3l4-7 2 4h2l5-9"/><path d="M22 20H2"/><path d="M6 16v4"/></svg></div>
|
||||
<h3>Vessel Tracking</h3>
|
||||
<p>Real-time AIS ship tracking via AIS-catcher. Monitor maritime traffic with vessel details, course, speed, and destination.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="space">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v4"/><path d="M12 19v4"/><path d="M5 5l2 2"/><path d="M17 17l2 2"/><path d="M1 12h4"/><path d="M19 12h4"/><path d="M5 19l2-2"/><path d="M17 7l2-2"/><ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(45 12 12)"/></svg></div>
|
||||
<h3>Satellite Tracking</h3>
|
||||
<p>Track satellites with TLE data, sky plots, ground track visualization, and pass predictions for your location.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="space">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/><circle cx="12" cy="12" r="4"/><path d="M16 12a4 4 0 0 0-4-4"/></svg></div>
|
||||
<h3>Weather Satellites</h3>
|
||||
<p>NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler, polar plot, and ground track map.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="space">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg></div>
|
||||
<h3>ISS SSTV</h3>
|
||||
<p>Receive Slow Scan Television from the ISS. Real-time tracking globe, pass predictions, and image decoding.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="space">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 3v18"/></svg></div>
|
||||
<h3>HF SSTV</h3>
|
||||
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="tracking">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/><path d="M12 8l4 4-4 4"/></svg></div>
|
||||
<h3>GPS Tracking</h3>
|
||||
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="space">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></div>
|
||||
<h3>Space Weather</h3>
|
||||
<p>Real-time solar and geomagnetic monitoring. Kp index, HF band conditions, solar imagery, D-RAP maps, and aurora forecasts from NOAA SWPC.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="wireless">
|
||||
<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="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/></svg></div>
|
||||
<h3>WiFi Scanning</h3>
|
||||
<p>Monitor mode reconnaissance via aircrack-ng. Network discovery, client tracking, and handshake capture.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="wireless">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="6"/><path d="M12 16v5"/><path d="M8 21h8"/><path d="M9.5 7.5L12 10l2.5-2.5"/></svg></div>
|
||||
<h3>Bluetooth Scanning</h3>
|
||||
<p>Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="wireless">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="7" stroke-dasharray="4 2"/><circle cx="12" cy="12" r="11" stroke-dasharray="2 3"/><line x1="12" y1="1" x2="12" y2="3"/></svg></div>
|
||||
<h3>BT Locate</h3>
|
||||
<p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="intel">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s-8-4.5-8-11.8A8 8 0 0 1 12 2a8 8 0 0 1 8 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/><path d="M12 2v3"/><path d="M4.93 4.93l2.12 2.12"/><path d="M20 12h-3"/></svg></div>
|
||||
<h3>TSCM</h3>
|
||||
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="wireless">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></div>
|
||||
<h3>Meshtastic</h3>
|
||||
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="platform">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/><circle cx="9" cy="10" r="1.5"/><circle cx="15" cy="10" r="1.5"/><path d="M5 10h2"/><path d="M17 10h2"/></svg></div>
|
||||
<h3>Remote Agents</h3>
|
||||
<p>Distributed signal intelligence with remote sensor nodes. Deploy agents across multiple locations and aggregate data to a central controller.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="platform">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18.36 6.64A9 9 0 0 1 20.77 15"/><path d="M6.16 6.16a9 9 0 0 0-2.57 8.84"/><path d="M12 2v4"/><path d="M2 12h4"/><line x1="2" y1="2" x2="22" y2="22"/><circle cx="12" cy="12" r="3"/></svg></div>
|
||||
<h3>Offline Mode</h3>
|
||||
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="carousel-arrow carousel-arrow-right" aria-label="Scroll right">›</button>
|
||||
</div>
|
||||
|
||||
<div class="carousel-indicators" id="carousel-indicators"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -252,6 +251,46 @@
|
||||
<img src="images/bt-locate.png" alt="BT Locate SAR Tracker">
|
||||
<span class="screenshot-label">BT Locate — SAR Tracker</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/spy-stations.png" alt="Spy Stations Database">
|
||||
<span class="screenshot-label">Spy Stations</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/gps.png" alt="GPS Receiver">
|
||||
<span class="screenshot-label">GPS Receiver</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/websdr.png" alt="WebSDR Remote Listening">
|
||||
<span class="screenshot-label">WebSDR</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/aprs.png" alt="APRS Tracker">
|
||||
<span class="screenshot-label">APRS Tracker</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/vdl2.png" alt="VDL2 Aircraft Datalink">
|
||||
<span class="screenshot-label">VDL2 Aircraft Datalink</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/weather-satellite.png" alt="Weather Satellite Decoder">
|
||||
<span class="screenshot-label">Weather Satellite</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/space-weather-1.png" alt="Space Weather Dashboard">
|
||||
<span class="screenshot-label">Space Weather</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/space-weather-2.png" alt="Space Weather Solar Imagery">
|
||||
<span class="screenshot-label">Space Weather — Solar & Aurora</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/satellite-tracker.png" alt="Satellite Tracker">
|
||||
<span class="screenshot-label">Satellite Tracker</span>
|
||||
</div>
|
||||
<div class="screenshot-item">
|
||||
<img src="images/iss-sstv.png" alt="ISS SSTV Decoder">
|
||||
<span class="screenshot-label">ISS SSTV</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -282,7 +321,7 @@ sudo -E venv/bin/python intercept.py</code></pre>
|
||||
<div class="code-block">
|
||||
<pre><code>git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
docker compose up -d</code></pre>
|
||||
docker compose --profile basic up -d --build</code></pre>
|
||||
</div>
|
||||
<p class="install-note">Requires privileged mode for USB SDR access</p>
|
||||
</div>
|
||||
@@ -336,6 +375,36 @@ docker compose up -d</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="support">
|
||||
<div class="container">
|
||||
<h2>Support & Contact</h2>
|
||||
<p class="section-subtitle">Help keep iNTERCEPT alive or get in touch</p>
|
||||
|
||||
<div class="support-grid">
|
||||
<a href="https://www.buymeacoffee.com/smittix" target="_blank" class="support-card support-coffee">
|
||||
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 8h1a4 4 0 0 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V8z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/></svg></div>
|
||||
<h3>Buy Me a Coffee</h3>
|
||||
<p>Support development with a one-time donation</p>
|
||||
</a>
|
||||
<a href="#" id="email-card" class="support-card" onclick="return false;">
|
||||
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M22 4L12 13 2 4"/></svg></div>
|
||||
<h3>Email</h3>
|
||||
<p id="email-text">Click to reveal</p>
|
||||
</a>
|
||||
<a href="https://discord.gg/EyeksEJmWE" target="_blank" class="support-card">
|
||||
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><circle cx="12" cy="12" r="10"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></div>
|
||||
<h3>Discord</h3>
|
||||
<p>Join the community for help and discussion</p>
|
||||
</a>
|
||||
<a href="https://github.com/smittix/intercept/issues" target="_blank" class="support-card">
|
||||
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
|
||||
<h3>Report an Issue</h3>
|
||||
<p>Bug reports and feature requests on GitHub</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
@@ -346,12 +415,14 @@ docker compose up -d</code></pre>
|
||||
<div class="footer-links">
|
||||
<a href="https://github.com/smittix/intercept" target="_blank">GitHub</a>
|
||||
<a href="https://discord.gg/EyeksEJmWE" target="_blank">Discord</a>
|
||||
<a href="#" id="footer-email">Email</a>
|
||||
<a href="https://www.buymeacoffee.com/smittix" target="_blank">Donate</a>
|
||||
<a href="https://github.com/smittix/intercept/blob/main/docs/USAGE.md">Documentation</a>
|
||||
<a href="https://github.com/smittix/intercept/blob/main/docs/DISTRIBUTED_AGENTS.md">Remote Agents</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<p>Created by <a href="https://github.com/smittix" target="_blank">smittix</a> · MIT License</p>
|
||||
<p>Created by <a href="https://github.com/smittix" target="_blank">smittix</a> · Apache 2.0 License</p>
|
||||
<p class="disclaimer">For educational and authorized testing purposes only.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -394,6 +465,334 @@ docker compose up -d</code></pre>
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeLightbox();
|
||||
});
|
||||
|
||||
// Carousel functionality
|
||||
(function() {
|
||||
const track = document.querySelector('.carousel-track');
|
||||
const cards = Array.from(track.querySelectorAll('.feature-card'));
|
||||
const leftArrow = document.querySelector('.carousel-arrow-left');
|
||||
const rightArrow = document.querySelector('.carousel-arrow-right');
|
||||
const filterBtns = document.querySelectorAll('.filter-btn');
|
||||
const indicatorContainer = document.getElementById('carousel-indicators');
|
||||
|
||||
const SCROLL_AMOUNT = 300;
|
||||
|
||||
function updateArrows() {
|
||||
leftArrow.disabled = track.scrollLeft <= 0;
|
||||
rightArrow.disabled = track.scrollLeft + track.clientWidth >= track.scrollWidth - 2;
|
||||
}
|
||||
|
||||
function buildIndicators() {
|
||||
const visible = cards.filter(c => !c.classList.contains('hidden'));
|
||||
const totalWidth = visible.length * 300;
|
||||
const pages = Math.max(1, Math.ceil(totalWidth / track.clientWidth));
|
||||
indicatorContainer.innerHTML = '';
|
||||
for (let i = 0; i < pages; i++) {
|
||||
const dot = document.createElement('button');
|
||||
dot.className = 'carousel-dot' + (i === 0 ? ' active' : '');
|
||||
dot.addEventListener('click', () => {
|
||||
track.scrollTo({ left: (track.scrollWidth / pages) * i, behavior: 'smooth' });
|
||||
});
|
||||
indicatorContainer.appendChild(dot);
|
||||
}
|
||||
}
|
||||
|
||||
function updateIndicators() {
|
||||
const dots = indicatorContainer.querySelectorAll('.carousel-dot');
|
||||
if (!dots.length) return;
|
||||
const ratio = track.scrollLeft / Math.max(1, track.scrollWidth - track.clientWidth);
|
||||
const idx = Math.round(ratio * (dots.length - 1));
|
||||
dots.forEach((d, i) => d.classList.toggle('active', i === idx));
|
||||
}
|
||||
|
||||
leftArrow.addEventListener('click', () => {
|
||||
track.scrollBy({ left: -SCROLL_AMOUNT, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
rightArrow.addEventListener('click', () => {
|
||||
track.scrollBy({ left: SCROLL_AMOUNT, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
track.addEventListener('scroll', () => {
|
||||
updateArrows();
|
||||
updateIndicators();
|
||||
});
|
||||
|
||||
filterBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
filterBtns.forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
const filter = btn.dataset.filter;
|
||||
|
||||
cards.forEach(card => {
|
||||
if (filter === 'all' || card.dataset.category === filter) {
|
||||
card.classList.remove('hidden');
|
||||
} else {
|
||||
card.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
track.scrollTo({ left: 0 });
|
||||
buildIndicators();
|
||||
updateArrows();
|
||||
});
|
||||
});
|
||||
|
||||
buildIndicators();
|
||||
updateArrows();
|
||||
window.addEventListener('resize', () => { buildIndicators(); updateArrows(); });
|
||||
})();
|
||||
|
||||
// Obfuscated email - assembled at runtime to defeat scrapers
|
||||
(function() {
|
||||
const p = ['smittix', 'outlook', 'com'];
|
||||
const addr = p[0] + '@' + p[1] + '.' + p[2];
|
||||
const card = document.getElementById('email-card');
|
||||
const text = document.getElementById('email-text');
|
||||
const footerLink = document.getElementById('footer-email');
|
||||
let revealed = false;
|
||||
|
||||
card.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
if (!revealed) {
|
||||
text.textContent = addr;
|
||||
revealed = true;
|
||||
} else {
|
||||
window.location.href = 'mail' + 'to:' + addr;
|
||||
}
|
||||
});
|
||||
|
||||
footerLink.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
window.location.href = 'mail' + 'to:' + addr;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// Animated satellite & signal background
|
||||
(function() {
|
||||
const canvas = document.getElementById('bg-canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
let w, h, dpr;
|
||||
let orbits = [];
|
||||
let pulses = [];
|
||||
let particles = [];
|
||||
let mouse = { x: -1000, y: -1000 };
|
||||
|
||||
function resize() {
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
w = window.innerWidth;
|
||||
h = document.documentElement.scrollHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
canvas.style.width = w + 'px';
|
||||
canvas.style.height = h + 'px';
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
|
||||
// Orbital paths with satellites
|
||||
function createOrbits() {
|
||||
orbits = [];
|
||||
const count = Math.max(4, Math.floor(w / 300));
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cx = Math.random() * w;
|
||||
const cy = Math.random() * h;
|
||||
const rx = 120 + Math.random() * 280;
|
||||
const ry = 40 + Math.random() * 100;
|
||||
const tilt = (Math.random() - 0.5) * 1.2;
|
||||
const speed = (0.0002 + Math.random() * 0.0004) * (Math.random() > 0.5 ? 1 : -1);
|
||||
const sats = [];
|
||||
const satCount = 1 + Math.floor(Math.random() * 2);
|
||||
for (let j = 0; j < satCount; j++) {
|
||||
sats.push({ angle: Math.random() * Math.PI * 2, pulseTimer: 0 });
|
||||
}
|
||||
orbits.push({ cx, cy, rx, ry, tilt, speed, sats });
|
||||
}
|
||||
}
|
||||
|
||||
// Floating signal particles (tiny dots drifting upward)
|
||||
function createParticles() {
|
||||
particles = [];
|
||||
const count = Math.max(30, Math.floor((w * h) / 25000));
|
||||
for (let i = 0; i < count; i++) {
|
||||
particles.push({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
vy: -(0.08 + Math.random() * 0.15),
|
||||
vx: (Math.random() - 0.5) * 0.1,
|
||||
size: 0.5 + Math.random() * 1.2,
|
||||
alpha: 0.1 + Math.random() * 0.25,
|
||||
flicker: Math.random() * Math.PI * 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function spawnPulse(x, y) {
|
||||
pulses.push({ x, y, r: 2, maxR: 50 + Math.random() * 40, alpha: 0.35 });
|
||||
}
|
||||
|
||||
function drawOrbitPath(orbit) {
|
||||
ctx.save();
|
||||
ctx.translate(orbit.cx, orbit.cy);
|
||||
ctx.rotate(orbit.tilt);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 0, orbit.rx, orbit.ry, 0, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = 'rgba(0, 212, 170, 0.04)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawSatellite(orbit, sat, dt) {
|
||||
sat.angle += orbit.speed * dt;
|
||||
const cos = Math.cos(orbit.tilt);
|
||||
const sin = Math.sin(orbit.tilt);
|
||||
const ex = orbit.rx * Math.cos(sat.angle);
|
||||
const ey = orbit.ry * Math.sin(sat.angle);
|
||||
const sx = orbit.cx + ex * cos - ey * sin;
|
||||
const sy = orbit.cy + ex * sin + ey * cos;
|
||||
|
||||
// Satellite dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, 2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(0, 212, 170, 0.7)';
|
||||
ctx.fill();
|
||||
|
||||
// Faint glow
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, 6, 0, Math.PI * 2);
|
||||
const g = ctx.createRadialGradient(sx, sy, 0, sx, sy, 6);
|
||||
g.addColorStop(0, 'rgba(0, 212, 170, 0.15)');
|
||||
g.addColorStop(1, 'rgba(0, 212, 170, 0)');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fill();
|
||||
|
||||
// Periodic signal pulse
|
||||
sat.pulseTimer += dt;
|
||||
if (sat.pulseTimer > 3000 + Math.random() * 500) {
|
||||
sat.pulseTimer = 0;
|
||||
spawnPulse(sx, sy);
|
||||
}
|
||||
}
|
||||
|
||||
function drawPulses(dt) {
|
||||
for (let i = pulses.length - 1; i >= 0; i--) {
|
||||
const p = pulses[i];
|
||||
p.r += dt * 0.025;
|
||||
p.alpha = 0.35 * (1 - p.r / p.maxR);
|
||||
if (p.r >= p.maxR) { pulses.splice(i, 1); continue; }
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = `rgba(0, 212, 170, ${p.alpha})`;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
// Second ring
|
||||
if (p.r > 12) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.r * 0.6, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = `rgba(0, 136, 255, ${p.alpha * 0.5})`;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawParticles(dt, time) {
|
||||
for (const p of particles) {
|
||||
p.y += p.vy * dt * 0.06;
|
||||
p.x += p.vx * dt * 0.06;
|
||||
p.flicker += dt * 0.002;
|
||||
|
||||
if (p.y < -10) { p.y = h + 10; p.x = Math.random() * w; }
|
||||
if (p.x < -10) p.x = w + 10;
|
||||
if (p.x > w + 10) p.x = -10;
|
||||
|
||||
const flick = p.alpha * (0.6 + 0.4 * Math.sin(p.flicker));
|
||||
|
||||
// Mouse interaction - subtle brighten
|
||||
const dx = p.x - mouse.x;
|
||||
const dy = p.y - mouse.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const boost = dist < 150 ? 0.3 * (1 - dist / 150) : 0;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(0, 212, 170, ${Math.min(flick + boost, 0.6)})`;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// Faint grid lines (signal grid)
|
||||
function drawGrid(time) {
|
||||
ctx.strokeStyle = 'rgba(0, 212, 170, 0.015)';
|
||||
ctx.lineWidth = 1;
|
||||
const spacing = 120;
|
||||
const offset = (time * 0.005) % spacing;
|
||||
|
||||
for (let x = -spacing + offset; x < w + spacing; x += spacing) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, h);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = -spacing + offset * 0.7; y < h + spacing; y += spacing) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(w, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
let last = 0;
|
||||
function animate(now) {
|
||||
const dt = last ? Math.min(now - last, 50) : 16;
|
||||
last = now;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
drawGrid(now);
|
||||
|
||||
for (const orbit of orbits) {
|
||||
drawOrbitPath(orbit);
|
||||
for (const sat of orbit.sats) {
|
||||
drawSatellite(orbit, sat, dt);
|
||||
}
|
||||
}
|
||||
|
||||
drawPulses(dt);
|
||||
drawParticles(dt, now);
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
// Track mouse for particle interaction
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
mouse.x = e.clientX;
|
||||
mouse.y = e.clientY + window.scrollY;
|
||||
});
|
||||
|
||||
// Resize handling
|
||||
let resizeTimer;
|
||||
function handleResize() {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
resize();
|
||||
createOrbits();
|
||||
createParticles();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// Keep canvas height synced with document
|
||||
const ro = new ResizeObserver(() => { handleResize(); });
|
||||
ro.observe(document.documentElement);
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
resize();
|
||||
createOrbits();
|
||||
createParticles();
|
||||
requestAnimationFrame(animate);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -17,6 +17,22 @@
|
||||
--gradient-end: #0088ff;
|
||||
}
|
||||
|
||||
/* Animated background canvas */
|
||||
#bg-canvas {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body > *:not(#bg-canvas) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -245,18 +261,74 @@ section h2 {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 24px;
|
||||
/* Category filter tabs */
|
||||
.carousel-filters {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 40px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
padding: 8px 20px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.25s;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Carousel */
|
||||
.carousel-wrapper {
|
||||
position: relative;
|
||||
padding: 0 56px;
|
||||
}
|
||||
|
||||
.carousel-track {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
overflow-x: auto;
|
||||
scroll-behavior: smooth;
|
||||
scroll-snap-type: x mandatory;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
padding: 8px 0 16px;
|
||||
}
|
||||
|
||||
.carousel-track::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
flex: 0 0 280px;
|
||||
scroll-snap-align: start;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 32px 24px;
|
||||
transition: all 0.3s;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.feature-card.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
@@ -266,8 +338,15 @@ section h2 {
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 2rem;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.feature-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
@@ -283,6 +362,81 @@ section h2 {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Carousel arrows */
|
||||
.carousel-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.carousel-arrow:hover {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.carousel-arrow:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.carousel-arrow:disabled:hover {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.carousel-arrow-left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.carousel-arrow-right {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* Carousel indicators */
|
||||
.carousel-indicators {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.carousel-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--border);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.carousel-dot.active {
|
||||
background: var(--accent);
|
||||
width: 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.carousel-dot:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Screenshots */
|
||||
.screenshot-gallery {
|
||||
display: grid;
|
||||
@@ -550,6 +704,72 @@ section h2 {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Support & Contact */
|
||||
.support {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.support-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.support-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.support-card:hover {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.support-card.support-coffee {
|
||||
border-color: rgba(255, 193, 59, 0.3);
|
||||
}
|
||||
|
||||
.support-card.support-coffee:hover {
|
||||
border-color: #ffc13b;
|
||||
box-shadow: 0 8px 30px rgba(255, 193, 59, 0.1);
|
||||
}
|
||||
|
||||
.support-card.support-coffee .support-icon {
|
||||
color: #ffc13b;
|
||||
}
|
||||
|
||||
.support-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: 0 auto 16px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.support-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.support-card h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.support-card p {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
background: var(--bg-secondary);
|
||||
@@ -641,14 +861,22 @@ section h2 {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
.carousel-wrapper {
|
||||
padding: 0 48px;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
flex: 0 0 260px;
|
||||
}
|
||||
|
||||
.screenshot-gallery {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.support-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.install-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -669,14 +897,35 @@ section h2 {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
.carousel-wrapper {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.carousel-arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
flex: 0 0 260px;
|
||||
}
|
||||
|
||||
.carousel-filters {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
font-size: 0.7rem;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
|
||||
.screenshot-gallery {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.support-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "intercept"
|
||||
version = "2.18.0"
|
||||
version = "2.21.1"
|
||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -2,38 +2,40 @@
|
||||
|
||||
def register_blueprints(app):
|
||||
"""Register all route blueprints with the Flask app."""
|
||||
from .pager import pager_bp
|
||||
from .sensor import sensor_bp
|
||||
from .rtlamr import rtlamr_bp
|
||||
from .wifi import wifi_bp
|
||||
from .wifi_v2 import wifi_v2_bp
|
||||
from .bluetooth import bluetooth_bp
|
||||
from .bluetooth_v2 import bluetooth_v2_bp
|
||||
from .acars import acars_bp
|
||||
from .adsb import adsb_bp
|
||||
from .ais import ais_bp
|
||||
from .dsc import dsc_bp
|
||||
from .acars import acars_bp
|
||||
from .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 .dmr import dmr_bp
|
||||
from .websdr import websdr_bp
|
||||
from .alerts import alerts_bp
|
||||
from .recordings import recordings_bp
|
||||
from .subghz import subghz_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 .rtlamr import rtlamr_bp
|
||||
from .satellite import satellite_bp
|
||||
from .sensor import sensor_bp
|
||||
from .settings import settings_bp
|
||||
from .signalid import signalid_bp
|
||||
from .space_weather import space_weather_bp
|
||||
from .spy_stations import spy_stations_bp
|
||||
from .sstv import sstv_bp
|
||||
from .sstv_general import sstv_general_bp
|
||||
from .subghz import subghz_bp
|
||||
from .tscm import init_tscm_state, tscm_bp
|
||||
from .updater import updater_bp
|
||||
from .vdl2 import vdl2_bp
|
||||
from .weather_sat import weather_sat_bp
|
||||
from .websdr import websdr_bp
|
||||
from .wifi import wifi_bp
|
||||
from .wifi_v2 import wifi_v2_bp
|
||||
|
||||
app.register_blueprint(pager_bp)
|
||||
app.register_blueprint(sensor_bp)
|
||||
@@ -46,12 +48,13 @@ def register_blueprints(app):
|
||||
app.register_blueprint(ais_bp)
|
||||
app.register_blueprint(dsc_bp) # VHF DSC maritime distress
|
||||
app.register_blueprint(acars_bp)
|
||||
app.register_blueprint(vdl2_bp)
|
||||
app.register_blueprint(aprs_bp)
|
||||
app.register_blueprint(satellite_bp)
|
||||
app.register_blueprint(gps_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
app.register_blueprint(correlation_bp)
|
||||
app.register_blueprint(listening_post_bp)
|
||||
app.register_blueprint(receiver_bp)
|
||||
app.register_blueprint(meshtastic_bp)
|
||||
app.register_blueprint(tscm_bp)
|
||||
app.register_blueprint(spy_stations_bp)
|
||||
@@ -61,12 +64,13 @@ def register_blueprints(app):
|
||||
app.register_blueprint(sstv_bp) # ISS SSTV decoder
|
||||
app.register_blueprint(weather_sat_bp) # NOAA/Meteor weather satellite decoder
|
||||
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
|
||||
app.register_blueprint(dmr_bp) # DMR / P25 / Digital Voice
|
||||
app.register_blueprint(websdr_bp) # HF/Shortwave WebSDR
|
||||
app.register_blueprint(alerts_bp) # Cross-mode alerts
|
||||
app.register_blueprint(recordings_bp) # Session recordings
|
||||
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
||||
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
||||
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
||||
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
|
||||
import app as app_module
|
||||
|
||||
@@ -21,7 +21,7 @@ import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
@@ -35,11 +35,8 @@ acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
|
||||
|
||||
# Default VHF ACARS frequencies (MHz) - common worldwide
|
||||
DEFAULT_ACARS_FREQUENCIES = [
|
||||
'131.550', # Primary worldwide
|
||||
'130.025', # Secondary USA/Canada
|
||||
'129.125', # USA
|
||||
'131.525', # Europe
|
||||
'131.725', # Europe secondary
|
||||
'131.725', # North America
|
||||
'131.825', # North America
|
||||
]
|
||||
|
||||
# Message counter for statistics
|
||||
@@ -129,6 +126,13 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
||||
|
||||
app_module.acars_queue.put(data)
|
||||
|
||||
# Feed flight correlator
|
||||
try:
|
||||
from utils.flight_correlator import get_flight_correlator
|
||||
get_flight_correlator().add_acars_message(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
@@ -407,31 +411,25 @@ def stop_acars() -> Response:
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@acars_bp.route('/stream')
|
||||
def stream_acars() -> Response:
|
||||
"""SSE stream for ACARS messages."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('acars', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
@acars_bp.route('/stream')
|
||||
def stream_acars() -> Response:
|
||||
"""SSE stream for ACARS messages."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('acars', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.acars_queue,
|
||||
channel_key='acars',
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
|
||||
@acars_bp.route('/frequencies')
|
||||
@@ -440,7 +438,7 @@ def get_frequencies() -> Response:
|
||||
return jsonify({
|
||||
'default': DEFAULT_ACARS_FREQUENCIES,
|
||||
'regions': {
|
||||
'north_america': ['129.125', '130.025', '130.450', '131.550'],
|
||||
'north_america': ['131.725', '131.825'],
|
||||
'europe': ['131.525', '131.725', '131.550'],
|
||||
'asia_pacific': ['131.550', '131.450'],
|
||||
}
|
||||
|
||||
@@ -77,6 +77,11 @@ _sbs_error_logged = False # Suppress repeated connection error logs
|
||||
# Track ICAOs already looked up in aircraft database (avoid repeated lookups)
|
||||
_looked_up_icaos: set[str] = set()
|
||||
|
||||
# Per-client SSE queues for ADS-B stream fanout.
|
||||
_adsb_stream_subscribers: set[queue.Queue] = set()
|
||||
_adsb_stream_subscribers_lock = threading.Lock()
|
||||
_ADSB_STREAM_CLIENT_QUEUE_SIZE = 500
|
||||
|
||||
# Load aircraft database at module init
|
||||
aircraft_db.load_database()
|
||||
|
||||
@@ -203,6 +208,31 @@ def _parse_int_param(value: str | None, default: int, min_value: int | None = No
|
||||
return parsed
|
||||
|
||||
|
||||
def _broadcast_adsb_update(payload: dict[str, Any]) -> None:
|
||||
"""Fan out a payload to all active ADS-B SSE subscribers."""
|
||||
with _adsb_stream_subscribers_lock:
|
||||
subscribers = tuple(_adsb_stream_subscribers)
|
||||
|
||||
for subscriber in subscribers:
|
||||
try:
|
||||
subscriber.put_nowait(payload)
|
||||
except queue.Full:
|
||||
# Drop oldest queued event for that client and try once more.
|
||||
try:
|
||||
subscriber.get_nowait()
|
||||
subscriber.put_nowait(payload)
|
||||
except (queue.Empty, queue.Full):
|
||||
# Client queue remains saturated; skip this payload.
|
||||
continue
|
||||
|
||||
|
||||
def _adsb_stream_queue_depth() -> int:
|
||||
"""Best-effort aggregate queue depth across connected ADS-B SSE clients."""
|
||||
with _adsb_stream_subscribers_lock:
|
||||
subscribers = tuple(_adsb_stream_subscribers)
|
||||
return sum(subscriber.qsize() for subscriber in subscribers)
|
||||
|
||||
|
||||
def _get_active_session() -> dict[str, Any] | None:
|
||||
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
||||
return None
|
||||
@@ -349,10 +379,62 @@ def parse_sbs_stream(service_addr):
|
||||
adsb_bytes_received = 0
|
||||
adsb_lines_received = 0
|
||||
|
||||
def flush_pending_updates(force: bool = False) -> None:
|
||||
nonlocal last_update
|
||||
if not pending_updates:
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
if not force and now - last_update < ADSB_UPDATE_INTERVAL:
|
||||
return
|
||||
|
||||
captured_at = datetime.now(timezone.utc)
|
||||
for update_icao in tuple(pending_updates):
|
||||
if update_icao in app_module.adsb_aircraft:
|
||||
snapshot = app_module.adsb_aircraft[update_icao]
|
||||
_broadcast_adsb_update({
|
||||
'type': 'aircraft',
|
||||
**snapshot
|
||||
})
|
||||
adsb_snapshot_writer.enqueue({
|
||||
'captured_at': captured_at,
|
||||
'icao': update_icao,
|
||||
'callsign': snapshot.get('callsign'),
|
||||
'registration': snapshot.get('registration'),
|
||||
'type_code': snapshot.get('type_code'),
|
||||
'type_desc': snapshot.get('type_desc'),
|
||||
'altitude': snapshot.get('altitude'),
|
||||
'speed': snapshot.get('speed'),
|
||||
'heading': snapshot.get('heading'),
|
||||
'vertical_rate': snapshot.get('vertical_rate'),
|
||||
'lat': snapshot.get('lat'),
|
||||
'lon': snapshot.get('lon'),
|
||||
'squawk': snapshot.get('squawk'),
|
||||
'source_host': service_addr,
|
||||
'snapshot': snapshot,
|
||||
})
|
||||
# Geofence check
|
||||
_gf_lat = snapshot.get('lat')
|
||||
_gf_lon = snapshot.get('lon')
|
||||
if _gf_lat is not None and _gf_lon is not None:
|
||||
try:
|
||||
from utils.geofence import get_geofence_manager
|
||||
for _gf_evt in get_geofence_manager().check_position(
|
||||
update_icao, 'aircraft', _gf_lat, _gf_lon,
|
||||
{'callsign': snapshot.get('callsign'), 'altitude': snapshot.get('altitude')}
|
||||
):
|
||||
process_event('adsb', _gf_evt, 'geofence')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
pending_updates.clear()
|
||||
last_update = now
|
||||
|
||||
while adsb_using_service:
|
||||
try:
|
||||
data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore')
|
||||
if not data:
|
||||
flush_pending_updates(force=True)
|
||||
logger.warning("SBS connection closed (no data)")
|
||||
break
|
||||
adsb_bytes_received += len(data)
|
||||
@@ -439,6 +521,12 @@ def parse_sbs_stream(service_addr):
|
||||
if parts[16]:
|
||||
try:
|
||||
aircraft['vertical_rate'] = int(float(parts[16]))
|
||||
if abs(aircraft['vertical_rate']) > 4000:
|
||||
process_event('adsb', {
|
||||
'type': 'vertical_rate_anomaly', 'icao': icao,
|
||||
'callsign': aircraft.get('callsign', ''),
|
||||
'vertical_rate': aircraft['vertical_rate'],
|
||||
}, 'vertical_rate_anomaly')
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
@@ -456,44 +544,49 @@ def parse_sbs_stream(service_addr):
|
||||
elif msg_type == '6' and len(parts) > 17:
|
||||
if parts[17]:
|
||||
aircraft['squawk'] = parts[17]
|
||||
sq = parts[17].strip()
|
||||
_EMERGENCY_SQUAWKS = {'7700': 'General Emergency', '7600': 'Comms Failure', '7500': 'Hijack'}
|
||||
if sq in _EMERGENCY_SQUAWKS:
|
||||
process_event('adsb', {
|
||||
'type': 'squawk_emergency', 'icao': icao,
|
||||
'callsign': aircraft.get('callsign', ''),
|
||||
'squawk': sq, 'meaning': _EMERGENCY_SQUAWKS[sq],
|
||||
}, 'squawk_emergency')
|
||||
|
||||
elif msg_type == '2' and len(parts) > 15:
|
||||
if parts[11]:
|
||||
try:
|
||||
aircraft['altitude'] = int(float(parts[11]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if parts[12]:
|
||||
try:
|
||||
aircraft['speed'] = int(float(parts[12]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if parts[13]:
|
||||
try:
|
||||
aircraft['heading'] = int(float(parts[13]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if parts[14] and parts[15]:
|
||||
try:
|
||||
aircraft['lat'] = float(parts[14])
|
||||
aircraft['lon'] = float(parts[15])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
app_module.adsb_aircraft.set(icao, aircraft)
|
||||
pending_updates.add(icao)
|
||||
adsb_messages_received += 1
|
||||
adsb_last_message_time = time.time()
|
||||
|
||||
now = time.time()
|
||||
if now - last_update >= ADSB_UPDATE_INTERVAL:
|
||||
for update_icao in pending_updates:
|
||||
if update_icao in app_module.adsb_aircraft:
|
||||
snapshot = app_module.adsb_aircraft[update_icao]
|
||||
app_module.adsb_queue.put({
|
||||
'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,
|
||||
})
|
||||
pending_updates.clear()
|
||||
last_update = now
|
||||
flush_pending_updates()
|
||||
|
||||
except socket.timeout:
|
||||
flush_pending_updates()
|
||||
continue
|
||||
|
||||
flush_pending_updates(force=True)
|
||||
sock.close()
|
||||
adsb_connected = False
|
||||
except OSError as e:
|
||||
@@ -553,7 +646,7 @@ def adsb_status():
|
||||
'last_message_time': adsb_last_message_time,
|
||||
'aircraft_count': len(app_module.adsb_aircraft),
|
||||
'aircraft': dict(app_module.adsb_aircraft), # Full aircraft data
|
||||
'queue_size': app_module.adsb_queue.qsize(),
|
||||
'queue_size': _adsb_stream_queue_depth(),
|
||||
'dump1090_path': find_dump1090(),
|
||||
'dump1090_running': dump1090_running,
|
||||
'port_30003_open': check_dump1090_service() is not None
|
||||
@@ -844,23 +937,39 @@ def stop_adsb():
|
||||
@adsb_bp.route('/stream')
|
||||
def stream_adsb():
|
||||
"""SSE stream for ADS-B aircraft."""
|
||||
client_queue: queue.Queue = queue.Queue(maxsize=_ADSB_STREAM_CLIENT_QUEUE_SIZE)
|
||||
with _adsb_stream_subscribers_lock:
|
||||
_adsb_stream_subscribers.add(client_queue)
|
||||
|
||||
# Prime new clients with current known aircraft so they don't wait for the
|
||||
# next positional update before rendering.
|
||||
for snapshot in list(app_module.adsb_aircraft.values()):
|
||||
try:
|
||||
client_queue.put_nowait({'type': 'aircraft', **snapshot})
|
||||
except queue.Full:
|
||||
break
|
||||
|
||||
def generate():
|
||||
last_keepalive = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
process_event('adsb', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
msg = client_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('adsb', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
finally:
|
||||
with _adsb_stream_subscribers_lock:
|
||||
_adsb_stream_subscribers.discard(client_queue)
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
@@ -871,10 +980,12 @@ def stream_adsb():
|
||||
@adsb_bp.route('/dashboard')
|
||||
def adsb_dashboard():
|
||||
"""Popout ADS-B dashboard."""
|
||||
embedded = request.args.get('embedded', 'false') == 'true'
|
||||
return render_template(
|
||||
'adsb_dashboard.html',
|
||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
adsb_auto_start=ADSB_AUTO_START,
|
||||
embedded=embedded,
|
||||
)
|
||||
|
||||
|
||||
@@ -1103,3 +1214,17 @@ def aircraft_photo(registration: str):
|
||||
except Exception as e:
|
||||
logger.debug(f"Error fetching aircraft photo: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@adsb_bp.route('/aircraft/<icao>/messages')
|
||||
def get_aircraft_messages(icao: str):
|
||||
"""Get correlated ACARS/VDL2 messages for an aircraft."""
|
||||
if not icao or not all(c in '0123456789ABCDEFabcdef' for c in icao):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid ICAO'}), 400
|
||||
|
||||
aircraft = app_module.adsb_aircraft.get(icao.upper())
|
||||
callsign = aircraft.get('callsign') if aircraft else None
|
||||
|
||||
from utils.flight_correlator import get_flight_correlator
|
||||
messages = get_flight_correlator().get_messages_for_aircraft(icao=icao.upper(), callsign=callsign)
|
||||
return jsonify({'status': 'success', 'icao': icao.upper(), **messages})
|
||||
|
||||
@@ -18,7 +18,7 @@ import app as app_module
|
||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||
from utils.logging import get_logger
|
||||
from utils.validation import validate_device_index, validate_gain
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
@@ -124,13 +124,27 @@ def parse_ais_stream(port: int):
|
||||
if now - last_update >= AIS_UPDATE_INTERVAL:
|
||||
for mmsi in pending_updates:
|
||||
if mmsi in app_module.ais_vessels:
|
||||
_vessel_snap = app_module.ais_vessels[mmsi]
|
||||
try:
|
||||
app_module.ais_queue.put_nowait({
|
||||
'type': 'vessel',
|
||||
**app_module.ais_vessels[mmsi]
|
||||
**_vessel_snap
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
# Geofence check
|
||||
_v_lat = _vessel_snap.get('lat')
|
||||
_v_lon = _vessel_snap.get('lon')
|
||||
if _v_lat and _v_lon:
|
||||
try:
|
||||
from utils.geofence import get_geofence_manager
|
||||
for _gf_evt in get_geofence_manager().check_position(
|
||||
mmsi, 'vessel', _v_lat, _v_lon,
|
||||
{'name': _vessel_snap.get('name'), 'ship_type': _vessel_snap.get('ship_type_text')}
|
||||
):
|
||||
process_event('ais', _gf_evt, 'geofence')
|
||||
except Exception:
|
||||
pass
|
||||
pending_updates.clear()
|
||||
last_update = now
|
||||
|
||||
@@ -282,6 +296,16 @@ def process_ais_message(msg: dict) -> dict | None:
|
||||
# Timestamp
|
||||
vessel['last_seen'] = time.time()
|
||||
|
||||
# Check for DSC DISTRESS matching this MMSI
|
||||
try:
|
||||
for _dsc_key, _dsc_msg in app_module.dsc_messages.items():
|
||||
if (str(_dsc_msg.get('source_mmsi', '')) == mmsi
|
||||
and _dsc_msg.get('category', '').upper() == 'DISTRESS'):
|
||||
vessel['dsc_distress'] = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return vessel
|
||||
|
||||
|
||||
@@ -478,34 +502,47 @@ def stop_ais():
|
||||
@ais_bp.route('/stream')
|
||||
def stream_ais():
|
||||
"""SSE stream for AIS vessels."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('ais', msg, msg.get('type'))
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('ais', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.ais_queue,
|
||||
channel_key='ais',
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
|
||||
@ais_bp.route('/vessel/<mmsi>/dsc')
|
||||
def get_vessel_dsc(mmsi: str):
|
||||
"""Get DSC messages associated with a vessel MMSI."""
|
||||
if not mmsi or not mmsi.isdigit():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid MMSI'}), 400
|
||||
|
||||
matches = []
|
||||
try:
|
||||
for key, msg in app_module.dsc_messages.items():
|
||||
if str(msg.get('source_mmsi', '')) == mmsi:
|
||||
matches.append(dict(msg))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({'status': 'success', 'mmsi': mmsi, 'dsc_messages': matches})
|
||||
|
||||
|
||||
@ais_bp.route('/dashboard')
|
||||
def ais_dashboard():
|
||||
"""Popout AIS dashboard."""
|
||||
embedded = request.args.get('embedded', 'false') == 'true'
|
||||
return render_template(
|
||||
'ais_dashboard.html',
|
||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
embedded=embedded,
|
||||
)
|
||||
|
||||
@@ -19,16 +19,16 @@ from typing import Generator, Optional
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
)
|
||||
|
||||
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
||||
@@ -47,6 +47,8 @@ APRS_FREQUENCIES = {
|
||||
'brazil': '145.570',
|
||||
'japan': '144.640',
|
||||
'china': '144.640',
|
||||
'iss': '145.825',
|
||||
'sonate2': '145.825',
|
||||
}
|
||||
|
||||
# Statistics
|
||||
@@ -73,19 +75,19 @@ def find_multimon_ng() -> Optional[str]:
|
||||
return shutil.which('multimon-ng')
|
||||
|
||||
|
||||
def find_rtl_fm() -> Optional[str]:
|
||||
"""Find rtl_fm binary."""
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def find_rx_fm() -> Optional[str]:
|
||||
"""Find SoapySDR rx_fm binary."""
|
||||
return shutil.which('rx_fm')
|
||||
|
||||
|
||||
def find_rtl_power() -> Optional[str]:
|
||||
"""Find rtl_power binary for spectrum scanning."""
|
||||
return shutil.which('rtl_power')
|
||||
def find_rtl_fm() -> Optional[str]:
|
||||
"""Find rtl_fm binary."""
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def find_rx_fm() -> Optional[str]:
|
||||
"""Find SoapySDR rx_fm binary."""
|
||||
return shutil.which('rx_fm')
|
||||
|
||||
|
||||
def find_rtl_power() -> Optional[str]:
|
||||
"""Find rtl_power binary for spectrum scanning."""
|
||||
return shutil.which('rtl_power')
|
||||
|
||||
|
||||
# Path to direwolf config file
|
||||
@@ -1378,6 +1380,19 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
'last_seen': packet.get('timestamp'),
|
||||
'packet_type': packet.get('packet_type'),
|
||||
}
|
||||
# Geofence check
|
||||
_aprs_lat = packet.get('lat')
|
||||
_aprs_lon = packet.get('lon')
|
||||
if _aprs_lat and _aprs_lon:
|
||||
try:
|
||||
from utils.geofence import get_geofence_manager
|
||||
for _gf_evt in get_geofence_manager().check_position(
|
||||
callsign, 'aprs_station', _aprs_lat, _aprs_lon,
|
||||
{'callsign': callsign}
|
||||
):
|
||||
process_event('aprs', _gf_evt, 'geofence')
|
||||
except Exception:
|
||||
pass
|
||||
# Evict oldest stations when limit is exceeded
|
||||
if len(aprs_stations) > APRS_MAX_STATIONS:
|
||||
oldest = min(
|
||||
@@ -1420,22 +1435,22 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
|
||||
|
||||
@aprs_bp.route('/tools')
|
||||
def check_aprs_tools() -> Response:
|
||||
"""Check for APRS decoding tools."""
|
||||
has_rtl_fm = find_rtl_fm() is not None
|
||||
has_rx_fm = find_rx_fm() is not None
|
||||
has_direwolf = find_direwolf() is not None
|
||||
has_multimon = find_multimon_ng() is not None
|
||||
has_fm_demod = has_rtl_fm or has_rx_fm
|
||||
|
||||
return jsonify({
|
||||
'rtl_fm': has_rtl_fm,
|
||||
'rx_fm': has_rx_fm,
|
||||
'direwolf': has_direwolf,
|
||||
'multimon_ng': has_multimon,
|
||||
'ready': has_fm_demod and (has_direwolf or has_multimon),
|
||||
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
|
||||
})
|
||||
def check_aprs_tools() -> Response:
|
||||
"""Check for APRS decoding tools."""
|
||||
has_rtl_fm = find_rtl_fm() is not None
|
||||
has_rx_fm = find_rx_fm() is not None
|
||||
has_direwolf = find_direwolf() is not None
|
||||
has_multimon = find_multimon_ng() is not None
|
||||
has_fm_demod = has_rtl_fm or has_rx_fm
|
||||
|
||||
return jsonify({
|
||||
'rtl_fm': has_rtl_fm,
|
||||
'rx_fm': has_rx_fm,
|
||||
'direwolf': has_direwolf,
|
||||
'multimon_ng': has_multimon,
|
||||
'ready': has_fm_demod and (has_direwolf or has_multimon),
|
||||
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
|
||||
})
|
||||
|
||||
|
||||
@aprs_bp.route('/status')
|
||||
@@ -1476,12 +1491,12 @@ def start_aprs() -> Response:
|
||||
'message': 'APRS decoder already running'
|
||||
}), 409
|
||||
|
||||
# Check for decoder (prefer direwolf, fallback to multimon-ng)
|
||||
direwolf_path = find_direwolf()
|
||||
multimon_path = find_multimon_ng()
|
||||
|
||||
if not direwolf_path and not multimon_path:
|
||||
return jsonify({
|
||||
# Check for decoder (prefer direwolf, fallback to multimon-ng)
|
||||
direwolf_path = find_direwolf()
|
||||
multimon_path = find_multimon_ng()
|
||||
|
||||
if not direwolf_path and not multimon_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No APRS decoder found. Install direwolf or multimon-ng'
|
||||
}), 400
|
||||
@@ -1489,31 +1504,31 @@ def start_aprs() -> Response:
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
gain = validate_gain(data.get('gain', '40'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
if find_rtl_fm() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
|
||||
}), 400
|
||||
else:
|
||||
if find_rx_fm() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
|
||||
}), 400
|
||||
try:
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
gain = validate_gain(data.get('gain', '40'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
if find_rtl_fm() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
|
||||
}), 400
|
||||
else:
|
||||
if find_rx_fm() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
|
||||
}), 400
|
||||
|
||||
# Reserve SDR device to prevent conflicts with other modes
|
||||
error = app_module.claim_sdr_device(device, 'aprs')
|
||||
@@ -1545,29 +1560,29 @@ def start_aprs() -> Response:
|
||||
aprs_last_packet_time = None
|
||||
aprs_stations = {}
|
||||
|
||||
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
|
||||
try:
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
rtl_cmd = builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=float(frequency),
|
||||
sample_rate=22050,
|
||||
gain=float(gain) if gain and str(gain) != '0' else None,
|
||||
ppm=int(ppm) if ppm and str(ppm) != '0' else None,
|
||||
modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm',
|
||||
squelch=None,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
|
||||
if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-':
|
||||
# APRS benefits from DC blocking + fast AGC on rtl_fm.
|
||||
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
|
||||
except Exception as e:
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
aprs_active_device = None
|
||||
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
||||
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
|
||||
try:
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
rtl_cmd = builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=float(frequency),
|
||||
sample_rate=22050,
|
||||
gain=float(gain) if gain and str(gain) != '0' else None,
|
||||
ppm=int(ppm) if ppm and str(ppm) != '0' else None,
|
||||
modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm',
|
||||
squelch=None,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
|
||||
if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-':
|
||||
# APRS benefits from DC blocking + fast AGC on rtl_fm.
|
||||
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
|
||||
except Exception as e:
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
aprs_active_device = None
|
||||
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
||||
|
||||
# Build decoder command
|
||||
if direwolf_path:
|
||||
@@ -1690,14 +1705,14 @@ def start_aprs() -> Response:
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'region': region,
|
||||
'device': device,
|
||||
'sdr_type': sdr_type.value,
|
||||
'decoder': decoder_name
|
||||
})
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'region': region,
|
||||
'device': device,
|
||||
'sdr_type': sdr_type.value,
|
||||
'decoder': decoder_name
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start APRS decoder: {e}")
|
||||
@@ -1748,31 +1763,25 @@ def stop_aprs() -> Response:
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@aprs_bp.route('/stream')
|
||||
def stream_aprs() -> Response:
|
||||
"""SSE stream for APRS packets."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('aprs', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
@aprs_bp.route('/stream')
|
||||
def stream_aprs() -> Response:
|
||||
"""SSE stream for APRS packets."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('aprs', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.aprs_queue,
|
||||
channel_key='aprs',
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
|
||||
@aprs_bp.route('/frequencies')
|
||||
|
||||
@@ -20,7 +20,7 @@ from flask import Blueprint, jsonify, request, Response
|
||||
import app as app_module
|
||||
from utils.dependencies import check_tool
|
||||
from utils.logging import bluetooth_logger as logger
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.validation import validate_bluetooth_interface
|
||||
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||
@@ -553,30 +553,23 @@ def get_bt_devices():
|
||||
})
|
||||
|
||||
|
||||
@bluetooth_bp.route('/stream')
|
||||
def stream_bt():
|
||||
"""SSE stream for Bluetooth events."""
|
||||
def generate():
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.bt_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('bluetooth', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
@bluetooth_bp.route('/stream')
|
||||
def stream_bt():
|
||||
"""SSE stream for Bluetooth events."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('bluetooth', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.bt_queue,
|
||||
channel_key='bluetooth',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
@@ -33,16 +33,18 @@ def start_session():
|
||||
"""
|
||||
Start a locate session.
|
||||
|
||||
Request JSON:
|
||||
- mac_address: Target MAC address (optional)
|
||||
- name_pattern: Target name substring (optional)
|
||||
- irk_hex: Identity Resolving Key hex string (optional)
|
||||
- device_id: Device ID from Bluetooth scanner (optional)
|
||||
- known_name: Hand-off device name (optional)
|
||||
- known_manufacturer: Hand-off manufacturer (optional)
|
||||
- last_known_rssi: Hand-off last RSSI (optional)
|
||||
- environment: 'FREE_SPACE', 'OUTDOOR', 'INDOOR', 'CUSTOM' (default: OUTDOOR)
|
||||
- custom_exponent: Path loss exponent for CUSTOM environment (optional)
|
||||
Request JSON:
|
||||
- mac_address: Target MAC address (optional)
|
||||
- name_pattern: Target name substring (optional)
|
||||
- irk_hex: Identity Resolving Key hex string (optional)
|
||||
- device_id: Device ID from Bluetooth scanner (optional)
|
||||
- device_key: Stable device key from Bluetooth scanner (optional)
|
||||
- fingerprint_id: Payload fingerprint ID from Bluetooth scanner (optional)
|
||||
- known_name: Hand-off device name (optional)
|
||||
- known_manufacturer: Hand-off manufacturer (optional)
|
||||
- last_known_rssi: Hand-off last RSSI (optional)
|
||||
- environment: 'FREE_SPACE', 'OUTDOOR', 'INDOOR', 'CUSTOM' (default: OUTDOOR)
|
||||
- custom_exponent: Path loss exponent for CUSTOM environment (optional)
|
||||
|
||||
Returns:
|
||||
JSON with session status.
|
||||
@@ -50,19 +52,33 @@ def start_session():
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Build target
|
||||
target = LocateTarget(
|
||||
mac_address=data.get('mac_address'),
|
||||
name_pattern=data.get('name_pattern'),
|
||||
irk_hex=data.get('irk_hex'),
|
||||
device_id=data.get('device_id'),
|
||||
known_name=data.get('known_name'),
|
||||
known_manufacturer=data.get('known_manufacturer'),
|
||||
last_known_rssi=data.get('last_known_rssi'),
|
||||
)
|
||||
target = LocateTarget(
|
||||
mac_address=data.get('mac_address'),
|
||||
name_pattern=data.get('name_pattern'),
|
||||
irk_hex=data.get('irk_hex'),
|
||||
device_id=data.get('device_id'),
|
||||
device_key=data.get('device_key'),
|
||||
fingerprint_id=data.get('fingerprint_id'),
|
||||
known_name=data.get('known_name'),
|
||||
known_manufacturer=data.get('known_manufacturer'),
|
||||
last_known_rssi=data.get('last_known_rssi'),
|
||||
)
|
||||
|
||||
# At least one identifier required
|
||||
if not any([target.mac_address, target.name_pattern, target.irk_hex, target.device_id]):
|
||||
return jsonify({'error': 'At least one target identifier required (mac_address, name_pattern, irk_hex, or device_id)'}), 400
|
||||
if not any([
|
||||
target.mac_address,
|
||||
target.name_pattern,
|
||||
target.irk_hex,
|
||||
target.device_id,
|
||||
target.device_key,
|
||||
target.fingerprint_id,
|
||||
]):
|
||||
return jsonify({
|
||||
'error': (
|
||||
'At least one target identifier required '
|
||||
'(mac_address, name_pattern, irk_hex, device_id, device_key, or fingerprint_id)'
|
||||
)
|
||||
}), 400
|
||||
|
||||
# Parse environment
|
||||
env_str = data.get('environment', 'OUTDOOR').upper()
|
||||
@@ -93,14 +109,27 @@ def start_session():
|
||||
f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})"
|
||||
)
|
||||
|
||||
session = start_locate_session(
|
||||
target, environment, custom_exponent, fallback_lat, fallback_lon
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'session': session.get_status(),
|
||||
})
|
||||
try:
|
||||
session = start_locate_session(
|
||||
target, environment, custom_exponent, fallback_lat, fallback_lon
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
logger.warning(f"Unable to start BT Locate session: {exc}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error': 'Bluetooth scanner could not be started. Check adapter permissions/capabilities.',
|
||||
}), 503
|
||||
except Exception as exc:
|
||||
logger.exception(f"Unexpected error starting BT Locate session: {exc}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error': 'Failed to start locate session',
|
||||
}), 500
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'session': session.get_status(),
|
||||
})
|
||||
|
||||
|
||||
@bt_locate_bp.route('/stop', methods=['POST'])
|
||||
@@ -114,17 +143,18 @@ def stop_session():
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@bt_locate_bp.route('/status', methods=['GET'])
|
||||
def get_status():
|
||||
"""Get locate session status."""
|
||||
session = get_locate_session()
|
||||
if not session:
|
||||
@bt_locate_bp.route('/status', methods=['GET'])
|
||||
def get_status():
|
||||
"""Get locate session status."""
|
||||
session = get_locate_session()
|
||||
if not session:
|
||||
return jsonify({
|
||||
'active': False,
|
||||
'target': None,
|
||||
})
|
||||
|
||||
return jsonify(session.get_status())
|
||||
'active': False,
|
||||
'target': None,
|
||||
})
|
||||
|
||||
include_debug = str(request.args.get('debug', '')).lower() in ('1', 'true', 'yes')
|
||||
return jsonify(session.get_status(include_debug=include_debug))
|
||||
|
||||
|
||||
@bt_locate_bp.route('/trail', methods=['GET'])
|
||||
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Generator
|
||||
@@ -36,10 +37,28 @@ from utils.trilateration import (
|
||||
|
||||
logger = logging.getLogger('intercept.controller')
|
||||
|
||||
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
||||
|
||||
# Multi-agent data queue for combined SSE stream
|
||||
agent_data_queue: queue.Queue = queue.Queue(maxsize=1000)
|
||||
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
||||
|
||||
# Multi-agent SSE fanout state (per-client queues).
|
||||
_agent_stream_subscribers: set[queue.Queue] = set()
|
||||
_agent_stream_subscribers_lock = threading.Lock()
|
||||
_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500
|
||||
|
||||
|
||||
def _broadcast_agent_data(payload: dict) -> None:
|
||||
"""Fan out an ingested payload to all active /controller/stream/all clients."""
|
||||
with _agent_stream_subscribers_lock:
|
||||
subscribers = tuple(_agent_stream_subscribers)
|
||||
|
||||
for subscriber in subscribers:
|
||||
try:
|
||||
subscriber.put_nowait(payload)
|
||||
except queue.Full:
|
||||
try:
|
||||
subscriber.get_nowait()
|
||||
subscriber.put_nowait(payload)
|
||||
except (queue.Empty, queue.Full):
|
||||
continue
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -625,19 +644,16 @@ def ingest_push_data():
|
||||
received_at=data.get('received_at')
|
||||
)
|
||||
|
||||
# Emit to SSE stream
|
||||
try:
|
||||
agent_data_queue.put_nowait({
|
||||
'type': 'agent_data',
|
||||
'agent_id': agent['id'],
|
||||
'agent_name': agent_name,
|
||||
'scan_type': data.get('scan_type'),
|
||||
'interface': data.get('interface'),
|
||||
'payload': data.get('payload'),
|
||||
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
|
||||
})
|
||||
except queue.Full:
|
||||
logger.warning("Agent data queue full, data may be lost")
|
||||
# Emit to SSE stream (fanout to all connected clients)
|
||||
_broadcast_agent_data({
|
||||
'type': 'agent_data',
|
||||
'agent_id': agent['id'],
|
||||
'agent_name': agent_name,
|
||||
'scan_type': data.get('scan_type'),
|
||||
'interface': data.get('interface'),
|
||||
'payload': data.get('payload'),
|
||||
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'status': 'accepted',
|
||||
@@ -674,27 +690,35 @@ def get_payloads():
|
||||
# =============================================================================
|
||||
|
||||
@controller_bp.route('/stream/all')
|
||||
def stream_all_agents():
|
||||
def stream_all_agents():
|
||||
"""
|
||||
Combined SSE stream for data from all agents.
|
||||
|
||||
This endpoint streams push data as it arrives from agents.
|
||||
Each message is tagged with agent_id and agent_name.
|
||||
"""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = agent_data_queue.get(timeout=1.0)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
client_queue: queue.Queue = queue.Queue(maxsize=_AGENT_STREAM_CLIENT_QUEUE_SIZE)
|
||||
with _agent_stream_subscribers_lock:
|
||||
_agent_stream_subscribers.add(client_queue)
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
msg = client_queue.get(timeout=1.0)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
finally:
|
||||
with _agent_stream_subscribers_lock:
|
||||
_agent_stream_subscribers.discard(client_queue)
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
|
||||
@@ -1,758 +0,0 @@
|
||||
"""DMR / P25 / Digital Voice decoding routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator, Optional
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import register_process, unregister_process
|
||||
from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
QUEUE_MAX_SIZE,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.dmr')
|
||||
|
||||
dmr_bp = Blueprint('dmr', __name__, url_prefix='/dmr')
|
||||
|
||||
# ============================================
|
||||
# GLOBAL STATE
|
||||
# ============================================
|
||||
|
||||
dmr_rtl_process: Optional[subprocess.Popen] = None
|
||||
dmr_dsd_process: Optional[subprocess.Popen] = None
|
||||
dmr_thread: Optional[threading.Thread] = None
|
||||
dmr_running = False
|
||||
dmr_has_audio = False # True when ffmpeg available and dsd outputs audio
|
||||
dmr_lock = threading.Lock()
|
||||
dmr_queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
dmr_active_device: Optional[int] = None
|
||||
|
||||
# Audio mux: the sole reader of dsd-fme stdout. Fans out bytes to all
|
||||
# active ffmpeg stdin sinks when streaming clients are connected.
|
||||
# This prevents dsd-fme from blocking on stdout (which would also
|
||||
# freeze stderr / text data output).
|
||||
_ffmpeg_sinks: set[object] = set()
|
||||
_ffmpeg_sinks_lock = threading.Lock()
|
||||
|
||||
VALID_PROTOCOLS = ['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']
|
||||
|
||||
# Classic dsd flags
|
||||
_DSD_PROTOCOL_FLAGS = {
|
||||
'auto': [],
|
||||
'dmr': ['-fd'],
|
||||
'p25': ['-fp'],
|
||||
'nxdn': ['-fn'],
|
||||
'dstar': ['-fi'],
|
||||
'provoice': ['-fv'],
|
||||
}
|
||||
|
||||
# dsd-fme remapped several flags from classic DSD:
|
||||
# -fs = DMR Simplex (NOT -fd which is D-STAR!),
|
||||
# -fd = D-STAR (NOT DMR!), -fp = ProVoice (NOT P25),
|
||||
# -fi = NXDN48 (NOT D-Star), -f1 = P25 Phase 1,
|
||||
# -ft = XDMA multi-protocol decoder
|
||||
_DSD_FME_PROTOCOL_FLAGS = {
|
||||
'auto': ['-fa'], # Broad auto: P25 (P1/P2), DMR, D-STAR, YSF, X2-TDMA
|
||||
'dmr': ['-fs'], # DMR Simplex (-fd is D-STAR in dsd-fme!)
|
||||
'p25': ['-ft'], # P25 P1/P2 coverage (also includes DMR in dsd-fme)
|
||||
'nxdn': ['-fn'], # NXDN96
|
||||
'dstar': ['-fd'], # D-STAR (-fd in dsd-fme, NOT DMR!)
|
||||
'provoice': ['-fp'], # ProVoice (-fp in dsd-fme, not -fv)
|
||||
}
|
||||
|
||||
# Modulation hints: force C4FM for protocols that use it, improving
|
||||
# sync reliability vs letting dsd-fme auto-detect modulation type.
|
||||
_DSD_FME_MODULATION = {
|
||||
'dmr': ['-mc'], # C4FM
|
||||
'nxdn': ['-mc'], # C4FM
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# HELPERS
|
||||
# ============================================
|
||||
|
||||
|
||||
def find_dsd() -> tuple[str | None, bool]:
|
||||
"""Find DSD (Digital Speech Decoder) binary.
|
||||
|
||||
Checks for dsd-fme first (common fork), then falls back to dsd.
|
||||
Returns (path, is_fme) tuple.
|
||||
"""
|
||||
path = shutil.which('dsd-fme')
|
||||
if path:
|
||||
return path, True
|
||||
path = shutil.which('dsd')
|
||||
if path:
|
||||
return path, False
|
||||
return None, False
|
||||
|
||||
|
||||
def find_rtl_fm() -> str | None:
|
||||
"""Find rtl_fm binary."""
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def find_rx_fm() -> str | None:
|
||||
"""Find SoapySDR rx_fm binary."""
|
||||
return shutil.which('rx_fm')
|
||||
|
||||
|
||||
def find_ffmpeg() -> str | None:
|
||||
"""Find ffmpeg for audio encoding."""
|
||||
return shutil.which('ffmpeg')
|
||||
|
||||
|
||||
def parse_dsd_output(line: str) -> dict | None:
|
||||
"""Parse a line of DSD stderr output into a structured event.
|
||||
|
||||
Handles output from both classic ``dsd`` and ``dsd-fme`` which use
|
||||
different formatting for talkgroup / source / voice frame lines.
|
||||
"""
|
||||
line = line.strip()
|
||||
if not line:
|
||||
return None
|
||||
|
||||
# Skip DSD/dsd-fme startup banner lines (ASCII art, version info, etc.)
|
||||
# Only filter lines that are purely decorative — dsd-fme uses box-drawing
|
||||
# characters (│, ─) as column separators in DATA lines, so we must not
|
||||
# discard lines that also contain alphanumeric content.
|
||||
stripped_of_box = re.sub(r'[╔╗╚╝║═██▀▄╗╝╩╦╠╣╬│┤├┘└┐┌─┼█▓▒░\s]', '', line)
|
||||
if not stripped_of_box:
|
||||
return None
|
||||
if re.match(r'^\s*(Build Version|MBElib|CODEC2|Audio (Out|In)|Decoding )', line):
|
||||
return None
|
||||
|
||||
ts = datetime.now().strftime('%H:%M:%S')
|
||||
|
||||
# Sync detection: "Sync: +DMR (data)" or "Sync: +P25 Phase 1"
|
||||
sync_match = re.match(r'Sync:\s*\+?(\S+.*)', line)
|
||||
if sync_match:
|
||||
return {
|
||||
'type': 'sync',
|
||||
'protocol': sync_match.group(1).strip(),
|
||||
'timestamp': ts,
|
||||
}
|
||||
|
||||
# Talkgroup and Source — check BEFORE slot so "Slot 1 Voice LC, TG: …"
|
||||
# is captured as a call event rather than a bare slot event.
|
||||
# Classic dsd: "TG: 12345 Src: 67890"
|
||||
# dsd-fme: "TG: 12345, Src: 67890" or "Talkgroup: 12345, Source: 67890"
|
||||
# "TGT: 12345 | SRC: 67890" (pipe-delimited variant)
|
||||
tg_match = re.search(
|
||||
r'(?:TGT?|Talkgroup)[:\s]+(\d+)[,|│\s]+(?:Src|Source|SRC)[:\s]+(\d+)', line, re.IGNORECASE
|
||||
)
|
||||
if tg_match:
|
||||
result = {
|
||||
'type': 'call',
|
||||
'talkgroup': int(tg_match.group(1)),
|
||||
'source_id': int(tg_match.group(2)),
|
||||
'timestamp': ts,
|
||||
}
|
||||
# Extract slot if present on the same line
|
||||
slot_inline = re.search(r'Slot\s*(\d+)', line)
|
||||
if slot_inline:
|
||||
result['slot'] = int(slot_inline.group(1))
|
||||
return result
|
||||
|
||||
# P25 NAC (Network Access Code) — check before voice/slot
|
||||
nac_match = re.search(r'NAC[:\s]+([0-9A-Fa-f]+)', line)
|
||||
if nac_match:
|
||||
return {
|
||||
'type': 'nac',
|
||||
'nac': nac_match.group(1),
|
||||
'timestamp': ts,
|
||||
}
|
||||
|
||||
# Voice frame detection — check BEFORE bare slot match
|
||||
# Classic dsd: "Voice" keyword in frame lines
|
||||
# dsd-fme: "voice" or "Voice LC" or "VOICE" in output
|
||||
if re.search(r'\bvoice\b', line, re.IGNORECASE):
|
||||
result = {
|
||||
'type': 'voice',
|
||||
'detail': line,
|
||||
'timestamp': ts,
|
||||
}
|
||||
slot_inline = re.search(r'Slot\s*(\d+)', line)
|
||||
if slot_inline:
|
||||
result['slot'] = int(slot_inline.group(1))
|
||||
return result
|
||||
|
||||
# Bare slot info (only when line is *just* slot info, not voice/call)
|
||||
slot_match = re.match(r'\s*Slot\s*(\d+)\s*$', line)
|
||||
if slot_match:
|
||||
return {
|
||||
'type': 'slot',
|
||||
'slot': int(slot_match.group(1)),
|
||||
'timestamp': ts,
|
||||
}
|
||||
|
||||
# dsd-fme status lines we can surface: "TDMA", "CACH", "PI", "BS", etc.
|
||||
# Also catches "Closing", "Input", and other lifecycle lines.
|
||||
# Forward as raw so the frontend can show decoder is alive.
|
||||
return {
|
||||
'type': 'raw',
|
||||
'text': line[:200],
|
||||
'timestamp': ts,
|
||||
}
|
||||
|
||||
|
||||
_HEARTBEAT_INTERVAL = 3.0 # seconds between heartbeats when decoder is idle
|
||||
|
||||
# 100ms of silence at 8kHz 16-bit mono = 1600 bytes
|
||||
_SILENCE_CHUNK = b'\x00' * 1600
|
||||
|
||||
|
||||
def _register_audio_sink(sink: object) -> None:
|
||||
"""Register an ffmpeg stdin sink for mux fanout."""
|
||||
with _ffmpeg_sinks_lock:
|
||||
_ffmpeg_sinks.add(sink)
|
||||
|
||||
|
||||
def _unregister_audio_sink(sink: object) -> None:
|
||||
"""Remove an ffmpeg stdin sink from mux fanout."""
|
||||
with _ffmpeg_sinks_lock:
|
||||
_ffmpeg_sinks.discard(sink)
|
||||
|
||||
|
||||
def _get_audio_sinks() -> tuple[object, ...]:
|
||||
"""Snapshot current audio sinks for lock-free iteration."""
|
||||
with _ffmpeg_sinks_lock:
|
||||
return tuple(_ffmpeg_sinks)
|
||||
|
||||
|
||||
def _stop_process(proc: Optional[subprocess.Popen]) -> None:
|
||||
"""Terminate and unregister a subprocess if present."""
|
||||
if not proc:
|
||||
return
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(proc)
|
||||
|
||||
|
||||
def _reset_runtime_state(*, release_device: bool) -> None:
|
||||
"""Reset process + runtime state and optionally release SDR ownership."""
|
||||
global dmr_rtl_process, dmr_dsd_process
|
||||
global dmr_running, dmr_has_audio, dmr_active_device
|
||||
|
||||
_stop_process(dmr_dsd_process)
|
||||
_stop_process(dmr_rtl_process)
|
||||
dmr_rtl_process = None
|
||||
dmr_dsd_process = None
|
||||
dmr_running = False
|
||||
dmr_has_audio = False
|
||||
with _ffmpeg_sinks_lock:
|
||||
_ffmpeg_sinks.clear()
|
||||
|
||||
if release_device and dmr_active_device is not None:
|
||||
app_module.release_sdr_device(dmr_active_device)
|
||||
dmr_active_device = None
|
||||
|
||||
|
||||
def _dsd_audio_mux(dsd_stdout):
|
||||
"""Mux thread: sole reader of dsd-fme stdout.
|
||||
|
||||
Always drains dsd-fme's audio output to prevent the process from
|
||||
blocking on stdout writes (which would also freeze stderr / text
|
||||
data). When streaming clients are connected, forwards data to all
|
||||
active ffmpeg stdin sinks with silence fill during voice gaps.
|
||||
"""
|
||||
try:
|
||||
while dmr_running:
|
||||
ready, _, _ = select.select([dsd_stdout], [], [], 0.1)
|
||||
if ready:
|
||||
data = os.read(dsd_stdout.fileno(), 4096)
|
||||
if not data:
|
||||
break
|
||||
sinks = _get_audio_sinks()
|
||||
for sink in sinks:
|
||||
try:
|
||||
sink.write(data)
|
||||
sink.flush()
|
||||
except (BrokenPipeError, OSError, ValueError):
|
||||
_unregister_audio_sink(sink)
|
||||
else:
|
||||
# No audio from decoder — feed silence if client connected
|
||||
sinks = _get_audio_sinks()
|
||||
for sink in sinks:
|
||||
try:
|
||||
sink.write(_SILENCE_CHUNK)
|
||||
sink.flush()
|
||||
except (BrokenPipeError, OSError, ValueError):
|
||||
_unregister_audio_sink(sink)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _queue_put(event: dict):
|
||||
"""Put an event on the DMR queue, dropping oldest if full."""
|
||||
try:
|
||||
dmr_queue.put_nowait(event)
|
||||
except queue.Full:
|
||||
try:
|
||||
dmr_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
dmr_queue.put_nowait(event)
|
||||
except queue.Full:
|
||||
pass
|
||||
|
||||
|
||||
def stream_dsd_output(rtl_process: subprocess.Popen, dsd_process: subprocess.Popen):
|
||||
"""Read DSD stderr output and push parsed events to the queue.
|
||||
|
||||
Uses select() with a timeout so we can send periodic heartbeat
|
||||
events while readline() would otherwise block indefinitely during
|
||||
silence (no signal being decoded).
|
||||
"""
|
||||
global dmr_running
|
||||
|
||||
try:
|
||||
_queue_put({'type': 'status', 'text': 'started'})
|
||||
last_heartbeat = time.time()
|
||||
|
||||
while dmr_running:
|
||||
if dsd_process.poll() is not None:
|
||||
break
|
||||
|
||||
# Wait up to 1s for data on stderr instead of blocking forever
|
||||
ready, _, _ = select.select([dsd_process.stderr], [], [], 1.0)
|
||||
|
||||
if ready:
|
||||
line = dsd_process.stderr.readline()
|
||||
if not line:
|
||||
if dsd_process.poll() is not None:
|
||||
break
|
||||
continue
|
||||
|
||||
text = line.decode('utf-8', errors='replace').strip()
|
||||
if not text:
|
||||
continue
|
||||
|
||||
logger.debug("DSD raw: %s", text)
|
||||
parsed = parse_dsd_output(text)
|
||||
if parsed:
|
||||
_queue_put(parsed)
|
||||
last_heartbeat = time.time()
|
||||
else:
|
||||
# No stderr output — send heartbeat so frontend knows
|
||||
# decoder is still alive and listening
|
||||
now = time.time()
|
||||
if now - last_heartbeat >= _HEARTBEAT_INTERVAL:
|
||||
_queue_put({
|
||||
'type': 'heartbeat',
|
||||
'timestamp': datetime.now().strftime('%H:%M:%S'),
|
||||
})
|
||||
last_heartbeat = now
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"DSD stream error: {e}")
|
||||
finally:
|
||||
global dmr_active_device, dmr_rtl_process, dmr_dsd_process
|
||||
global dmr_has_audio
|
||||
dmr_running = False
|
||||
dmr_has_audio = False
|
||||
with _ffmpeg_sinks_lock:
|
||||
_ffmpeg_sinks.clear()
|
||||
# Capture exit info for diagnostics
|
||||
rc = dsd_process.poll()
|
||||
reason = 'stopped'
|
||||
detail = ''
|
||||
if rc is not None and rc != 0:
|
||||
reason = 'crashed'
|
||||
try:
|
||||
remaining = dsd_process.stderr.read(1024)
|
||||
if remaining:
|
||||
detail = remaining.decode('utf-8', errors='replace').strip()[:200]
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning(f"DSD process exited with code {rc}: {detail}")
|
||||
# Cleanup decoder + demod processes
|
||||
_stop_process(dsd_process)
|
||||
_stop_process(rtl_process)
|
||||
dmr_rtl_process = None
|
||||
dmr_dsd_process = None
|
||||
_queue_put({'type': 'status', 'text': reason, 'exit_code': rc, 'detail': detail})
|
||||
# Release SDR device
|
||||
if dmr_active_device is not None:
|
||||
app_module.release_sdr_device(dmr_active_device)
|
||||
dmr_active_device = None
|
||||
logger.info("DSD stream thread stopped")
|
||||
|
||||
|
||||
# ============================================
|
||||
# API ENDPOINTS
|
||||
# ============================================
|
||||
|
||||
@dmr_bp.route('/tools')
|
||||
def check_tools() -> Response:
|
||||
"""Check for required tools."""
|
||||
dsd_path, _ = find_dsd()
|
||||
rtl_fm = find_rtl_fm()
|
||||
rx_fm = find_rx_fm()
|
||||
ffmpeg = find_ffmpeg()
|
||||
return jsonify({
|
||||
'dsd': dsd_path is not None,
|
||||
'rtl_fm': rtl_fm is not None,
|
||||
'rx_fm': rx_fm is not None,
|
||||
'ffmpeg': ffmpeg is not None,
|
||||
'available': dsd_path is not None and (rtl_fm is not None or rx_fm is not None),
|
||||
'protocols': VALID_PROTOCOLS,
|
||||
})
|
||||
|
||||
|
||||
@dmr_bp.route('/start', methods=['POST'])
|
||||
def start_dmr() -> Response:
|
||||
"""Start digital voice decoding."""
|
||||
global dmr_rtl_process, dmr_dsd_process, dmr_thread
|
||||
global dmr_running, dmr_has_audio, dmr_active_device
|
||||
|
||||
dsd_path, is_fme = find_dsd()
|
||||
if not dsd_path:
|
||||
return jsonify({'status': 'error', 'message': 'dsd not found. Install dsd-fme or dsd.'}), 503
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
try:
|
||||
frequency = validate_frequency(data.get('frequency', 462.5625))
|
||||
gain = int(validate_gain(data.get('gain', 40)))
|
||||
device = validate_device_index(data.get('device', 0))
|
||||
protocol = str(data.get('protocol', 'auto')).lower()
|
||||
ppm = validate_ppm(data.get('ppm', 0))
|
||||
except (ValueError, TypeError) as e:
|
||||
return jsonify({'status': 'error', 'message': f'Invalid parameter: {e}'}), 400
|
||||
|
||||
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
if protocol not in VALID_PROTOCOLS:
|
||||
return jsonify({'status': 'error', 'message': f'Invalid protocol. Use: {", ".join(VALID_PROTOCOLS)}'}), 400
|
||||
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
if not find_rtl_fm():
|
||||
return jsonify({'status': 'error', 'message': 'rtl_fm not found. Install rtl-sdr tools.'}), 503
|
||||
else:
|
||||
if not find_rx_fm():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
|
||||
}), 503
|
||||
|
||||
# Clear stale queue
|
||||
try:
|
||||
while True:
|
||||
dmr_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
# Reserve running state before we start claiming resources/processes
|
||||
# so concurrent /start requests cannot race each other.
|
||||
with dmr_lock:
|
||||
if dmr_running:
|
||||
return jsonify({'status': 'error', 'message': 'Already running'}), 409
|
||||
dmr_running = True
|
||||
dmr_has_audio = False
|
||||
|
||||
# Claim SDR device — use protocol name so the device panel shows
|
||||
# "D-STAR", "P25", etc. instead of always "DMR"
|
||||
mode_label = protocol.upper() if protocol != 'auto' else 'DMR'
|
||||
error = app_module.claim_sdr_device(device, mode_label)
|
||||
if error:
|
||||
with dmr_lock:
|
||||
dmr_running = False
|
||||
return jsonify({'status': 'error', 'error_type': 'DEVICE_BUSY', 'message': error}), 409
|
||||
|
||||
dmr_active_device = device
|
||||
|
||||
# Build FM demodulation command via SDR abstraction.
|
||||
try:
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
rtl_cmd = builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=frequency,
|
||||
sample_rate=48000,
|
||||
gain=float(gain) if gain > 0 else None,
|
||||
ppm=int(ppm) if ppm != 0 else None,
|
||||
modulation='fm',
|
||||
squelch=None,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
# Keep squelch fully open for digital bitstreams.
|
||||
rtl_cmd.extend(['-l', '0'])
|
||||
except Exception as e:
|
||||
_reset_runtime_state(release_device=True)
|
||||
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
||||
|
||||
# Build DSD command
|
||||
# Audio output: pipe decoded audio (8kHz s16le PCM) to stdout for
|
||||
# ffmpeg transcoding. Both dsd-fme and classic dsd support '-o -'.
|
||||
# If ffmpeg is unavailable, fall back to discarding audio.
|
||||
ffmpeg_path = find_ffmpeg()
|
||||
if ffmpeg_path:
|
||||
audio_out = '-'
|
||||
else:
|
||||
audio_out = 'null' if is_fme else '-'
|
||||
logger.warning("ffmpeg not found — audio streaming disabled, data-only mode")
|
||||
dsd_cmd = [dsd_path, '-i', '-', '-o', audio_out]
|
||||
if is_fme:
|
||||
dsd_cmd.extend(_DSD_FME_PROTOCOL_FLAGS.get(protocol, []))
|
||||
dsd_cmd.extend(_DSD_FME_MODULATION.get(protocol, []))
|
||||
# Event log to stderr so we capture TG/Source/Voice data that
|
||||
# dsd-fme may not output on stderr by default.
|
||||
dsd_cmd.extend(['-J', '/dev/stderr'])
|
||||
# Relax CRC checks for marginal signals — lets more frames
|
||||
# through at the cost of occasional decode errors.
|
||||
if data.get('relaxCrc', False):
|
||||
dsd_cmd.append('-F')
|
||||
else:
|
||||
dsd_cmd.extend(_DSD_PROTOCOL_FLAGS.get(protocol, []))
|
||||
|
||||
try:
|
||||
dmr_rtl_process = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
register_process(dmr_rtl_process)
|
||||
|
||||
# DSD stdout → PIPE when ffmpeg available (audio pipeline),
|
||||
# otherwise DEVNULL (data-only mode)
|
||||
dsd_stdout = subprocess.PIPE if ffmpeg_path else subprocess.DEVNULL
|
||||
dmr_dsd_process = subprocess.Popen(
|
||||
dsd_cmd,
|
||||
stdin=dmr_rtl_process.stdout,
|
||||
stdout=dsd_stdout,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
register_process(dmr_dsd_process)
|
||||
|
||||
# Allow rtl_fm to send directly to dsd
|
||||
dmr_rtl_process.stdout.close()
|
||||
|
||||
# Start mux thread: always drains dsd-fme stdout to prevent the
|
||||
# process from blocking (which would freeze stderr / text data).
|
||||
# ffmpeg is started lazily per-client in /dmr/audio/stream.
|
||||
if ffmpeg_path and dmr_dsd_process.stdout:
|
||||
dmr_has_audio = True
|
||||
threading.Thread(
|
||||
target=_dsd_audio_mux,
|
||||
args=(dmr_dsd_process.stdout,),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
rtl_rc = dmr_rtl_process.poll()
|
||||
dsd_rc = dmr_dsd_process.poll()
|
||||
if rtl_rc is not None or dsd_rc is not None:
|
||||
# Process died — capture stderr for diagnostics
|
||||
rtl_err = ''
|
||||
if dmr_rtl_process.stderr:
|
||||
rtl_err = dmr_rtl_process.stderr.read().decode('utf-8', errors='replace')[:500]
|
||||
dsd_err = ''
|
||||
if dmr_dsd_process.stderr:
|
||||
dsd_err = dmr_dsd_process.stderr.read().decode('utf-8', errors='replace')[:500]
|
||||
logger.error(f"DSD pipeline died: rtl_fm rc={rtl_rc} err={rtl_err!r}, dsd rc={dsd_rc} err={dsd_err!r}")
|
||||
# Terminate surviving processes and release resources.
|
||||
_reset_runtime_state(release_device=True)
|
||||
# Surface a clear error to the user
|
||||
detail = rtl_err.strip() or dsd_err.strip()
|
||||
if 'usb_claim_interface' in rtl_err or 'Failed to open' in rtl_err:
|
||||
msg = f'SDR device {device} is busy — it may be in use by another mode or process. Try a different device.'
|
||||
elif detail:
|
||||
msg = f'Failed to start DSD pipeline: {detail}'
|
||||
else:
|
||||
msg = 'Failed to start DSD pipeline'
|
||||
return jsonify({'status': 'error', 'message': msg}), 500
|
||||
|
||||
# Drain rtl_fm stderr in background to prevent pipe blocking
|
||||
def _drain_rtl_stderr(proc):
|
||||
try:
|
||||
for line in proc.stderr:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_drain_rtl_stderr, args=(dmr_rtl_process,), daemon=True).start()
|
||||
|
||||
dmr_thread = threading.Thread(
|
||||
target=stream_dsd_output,
|
||||
args=(dmr_rtl_process, dmr_dsd_process),
|
||||
daemon=True,
|
||||
)
|
||||
dmr_thread.start()
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'protocol': protocol,
|
||||
'sdr_type': sdr_type.value,
|
||||
'has_audio': dmr_has_audio,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start DMR: {e}")
|
||||
_reset_runtime_state(release_device=True)
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@dmr_bp.route('/stop', methods=['POST'])
|
||||
def stop_dmr() -> Response:
|
||||
"""Stop digital voice decoding."""
|
||||
with dmr_lock:
|
||||
_reset_runtime_state(release_device=True)
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@dmr_bp.route('/status')
|
||||
def dmr_status() -> Response:
|
||||
"""Get DMR decoder status."""
|
||||
return jsonify({
|
||||
'running': dmr_running,
|
||||
'device': dmr_active_device,
|
||||
'has_audio': dmr_has_audio,
|
||||
})
|
||||
|
||||
|
||||
@dmr_bp.route('/audio/stream')
|
||||
def stream_dmr_audio() -> Response:
|
||||
"""Stream decoded digital voice audio as WAV.
|
||||
|
||||
Starts a per-client ffmpeg encoder. The global mux thread
|
||||
(_dsd_audio_mux) forwards DSD audio to this ffmpeg's stdin while
|
||||
the client is connected, and discards audio otherwise. This avoids
|
||||
the pipe-buffer deadlock that occurs when ffmpeg is started at
|
||||
decoder launch (its stdout fills up before any HTTP client reads
|
||||
it, back-pressuring the entire pipeline and freezing stderr/text
|
||||
data output).
|
||||
"""
|
||||
if not dmr_running or not dmr_has_audio:
|
||||
return Response(b'', mimetype='audio/wav', status=204)
|
||||
|
||||
ffmpeg_path = find_ffmpeg()
|
||||
if not ffmpeg_path:
|
||||
return Response(b'', mimetype='audio/wav', status=503)
|
||||
|
||||
encoder_cmd = [
|
||||
ffmpeg_path, '-hide_banner', '-loglevel', 'error',
|
||||
'-fflags', 'nobuffer', '-flags', 'low_delay',
|
||||
'-probesize', '32', '-analyzeduration', '0',
|
||||
'-f', 's16le', '-ar', '8000', '-ac', '1', '-i', 'pipe:0',
|
||||
'-acodec', 'pcm_s16le', '-ar', '44100', '-f', 'wav', 'pipe:1',
|
||||
]
|
||||
audio_proc = subprocess.Popen(
|
||||
encoder_cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
# Drain ffmpeg stderr to prevent blocking
|
||||
threading.Thread(
|
||||
target=lambda p: [None for _ in p.stderr],
|
||||
args=(audio_proc,), daemon=True,
|
||||
).start()
|
||||
|
||||
if audio_proc.stdin:
|
||||
_register_audio_sink(audio_proc.stdin)
|
||||
|
||||
def generate():
|
||||
try:
|
||||
while dmr_running and audio_proc.poll() is None:
|
||||
ready, _, _ = select.select([audio_proc.stdout], [], [], 2.0)
|
||||
if ready:
|
||||
chunk = audio_proc.stdout.read(4096)
|
||||
if chunk:
|
||||
yield chunk
|
||||
else:
|
||||
break
|
||||
else:
|
||||
if audio_proc.poll() is not None:
|
||||
break
|
||||
except GeneratorExit:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"DMR audio stream error: {e}")
|
||||
finally:
|
||||
# Disconnect mux → ffmpeg, then clean up
|
||||
if audio_proc.stdin:
|
||||
_unregister_audio_sink(audio_proc.stdin)
|
||||
try:
|
||||
audio_proc.stdin.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
audio_proc.terminate()
|
||||
audio_proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
audio_proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
mimetype='audio/wav',
|
||||
headers={
|
||||
'Content-Type': 'audio/wav',
|
||||
'Cache-Control': 'no-cache, no-store',
|
||||
'X-Accel-Buffering': 'no',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@dmr_bp.route('/stream')
|
||||
def stream_dmr() -> Response:
|
||||
"""SSE stream for DMR decoder events."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
while True:
|
||||
try:
|
||||
msg = dmr_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('dmr', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
@@ -35,7 +35,7 @@ from utils.database import (
|
||||
get_dsc_alert_summary,
|
||||
)
|
||||
from utils.dsc.parser import parse_dsc_message
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.validation import validate_device_index, validate_gain
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
@@ -518,26 +518,19 @@ def stop_decoding() -> Response:
|
||||
@dsc_bp.route('/stream')
|
||||
def stream() -> Response:
|
||||
"""SSE stream for real-time DSC messages."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('dsc', msg, msg.get('type'))
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.dsc_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('dsc', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.dsc_queue,
|
||||
channel_key='dsc',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
|
||||
@@ -21,7 +21,7 @@ from utils.gps import (
|
||||
stop_gpsd_daemon,
|
||||
)
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import sse_stream_fanout
|
||||
|
||||
logger = get_logger('intercept.gps')
|
||||
|
||||
@@ -228,26 +228,19 @@ def get_satellites():
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/stream')
|
||||
def stream_gps():
|
||||
"""SSE stream of GPS position and sky updates."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
data = _gps_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(data)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
@gps_bp.route('/stream')
|
||||
def stream_gps():
|
||||
"""SSE stream of GPS position and sky updates."""
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_gps_queue,
|
||||
channel_key='gps',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
@@ -17,7 +17,7 @@ from typing import Generator
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.meshtastic import (
|
||||
get_meshtastic_client,
|
||||
start_meshtastic,
|
||||
@@ -453,8 +453,8 @@ def get_messages():
|
||||
})
|
||||
|
||||
|
||||
@meshtastic_bp.route('/stream')
|
||||
def stream_messages():
|
||||
@meshtastic_bp.route('/stream')
|
||||
def stream_messages():
|
||||
"""
|
||||
SSE stream of Meshtastic messages.
|
||||
|
||||
@@ -469,25 +469,18 @@ def stream_messages():
|
||||
Returns:
|
||||
SSE stream (text/event-stream)
|
||||
"""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = _mesh_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_mesh_queue,
|
||||
channel_key='meshtastic',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@@ -1051,3 +1044,19 @@ def request_store_forward():
|
||||
'status': 'error',
|
||||
'message': error or 'Failed to request S&F history'
|
||||
}), 500
|
||||
|
||||
|
||||
@meshtastic_bp.route('/topology')
|
||||
def mesh_topology():
|
||||
"""Return mesh network topology graph."""
|
||||
if not is_meshtastic_available():
|
||||
return jsonify({'status': 'error', 'message': 'Meshtastic SDK not installed'}), 400
|
||||
|
||||
client = get_meshtastic_client()
|
||||
if not client or not client.is_running:
|
||||
return jsonify({'status': 'error', 'message': 'Not connected'}), 400
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'topology': client.get_topology(),
|
||||
})
|
||||
|
||||
@@ -9,13 +9,14 @@ import os
|
||||
offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
|
||||
|
||||
# Default offline settings
|
||||
OFFLINE_DEFAULTS = {
|
||||
'offline.enabled': False,
|
||||
'offline.assets_source': 'cdn',
|
||||
'offline.fonts_source': 'cdn',
|
||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||
'offline.tile_server_url': ''
|
||||
}
|
||||
OFFLINE_DEFAULTS = {
|
||||
'offline.enabled': False,
|
||||
# Default to bundled assets/fonts to avoid third-party CDN privacy blocks.
|
||||
'offline.assets_source': 'local',
|
||||
'offline.fonts_source': 'local',
|
||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||
'offline.tile_server_url': ''
|
||||
}
|
||||
|
||||
# Asset paths to check
|
||||
ASSET_PATHS = {
|
||||
|
||||
@@ -24,7 +24,7 @@ from utils.validation import (
|
||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import safe_terminate, register_process, unregister_process
|
||||
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
||||
@@ -96,7 +96,7 @@ def parse_multimon_output(line: str) -> dict[str, str] | None:
|
||||
return None
|
||||
|
||||
|
||||
def log_message(msg: dict[str, Any]) -> None:
|
||||
def log_message(msg: dict[str, Any]) -> None:
|
||||
"""Log a message to file if logging is enabled."""
|
||||
if not app_module.logging_enabled:
|
||||
return
|
||||
@@ -104,25 +104,39 @@ def log_message(msg: dict[str, Any]) -> None:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log message: {e}")
|
||||
|
||||
|
||||
def audio_relay_thread(
|
||||
rtl_stdout,
|
||||
multimon_stdin,
|
||||
output_queue: queue.Queue,
|
||||
stop_event: threading.Event,
|
||||
) -> None:
|
||||
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
|
||||
|
||||
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
||||
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
||||
event onto *output_queue*.
|
||||
"""
|
||||
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
||||
INTERVAL = 0.1 # seconds between scope updates
|
||||
last_scope = time.monotonic()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log message: {e}")
|
||||
|
||||
|
||||
def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]:
|
||||
"""Compress recent PCM samples into a signed 8-bit waveform for SSE."""
|
||||
if not samples:
|
||||
return []
|
||||
|
||||
window = samples[-window_size:] if len(samples) > window_size else samples
|
||||
waveform: list[int] = []
|
||||
for sample in window:
|
||||
# Convert int16 PCM to int8 range for lightweight transport.
|
||||
packed = int(round(sample / 256))
|
||||
waveform.append(max(-127, min(127, packed)))
|
||||
return waveform
|
||||
|
||||
|
||||
def audio_relay_thread(
|
||||
rtl_stdout,
|
||||
multimon_stdin,
|
||||
output_queue: queue.Queue,
|
||||
stop_event: threading.Event,
|
||||
) -> None:
|
||||
"""Relay audio from rtl_fm to multimon-ng while computing signal levels.
|
||||
|
||||
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
||||
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
||||
event plus a compact waveform sample onto *output_queue*.
|
||||
"""
|
||||
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
||||
INTERVAL = 0.1 # seconds between scope updates
|
||||
last_scope = time.monotonic()
|
||||
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
@@ -146,15 +160,16 @@ def audio_relay_thread(
|
||||
if n_samples == 0:
|
||||
continue
|
||||
samples = struct.unpack(f'<{n_samples}h', data[:n_samples * 2])
|
||||
peak = max(abs(s) for s in samples)
|
||||
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
|
||||
output_queue.put_nowait({
|
||||
'type': 'scope',
|
||||
'rms': rms,
|
||||
'peak': peak,
|
||||
})
|
||||
except (struct.error, ValueError, queue.Full):
|
||||
pass
|
||||
peak = max(abs(s) for s in samples)
|
||||
rms = int(math.sqrt(sum(s * s for s in samples) / n_samples))
|
||||
output_queue.put_nowait({
|
||||
'type': 'scope',
|
||||
'rms': rms,
|
||||
'peak': peak,
|
||||
'waveform': _encode_scope_waveform(samples),
|
||||
})
|
||||
except (struct.error, ValueError, queue.Full):
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"Audio relay error: {e}")
|
||||
finally:
|
||||
@@ -538,31 +553,22 @@ def toggle_logging() -> Response:
|
||||
return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
|
||||
|
||||
|
||||
@pager_bp.route('/stream')
|
||||
def stream() -> Response:
|
||||
import json
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0 # Send keepalive every 30 seconds instead of 1 second
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.output_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('pager', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
@pager_bp.route('/stream')
|
||||
def stream() -> Response:
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('pager', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.output_queue,
|
||||
channel_key='pager',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, jsonify, request, send_file
|
||||
@@ -107,3 +108,59 @@ def download_recording(session_id: str):
|
||||
as_attachment=True,
|
||||
download_name=file_path.name,
|
||||
)
|
||||
|
||||
|
||||
@recordings_bp.route('/<session_id>/events', methods=['GET'])
|
||||
def get_recording_events(session_id: str):
|
||||
"""Return parsed events from a recording for in-app replay."""
|
||||
manager = get_recording_manager()
|
||||
rec = manager.get_recording(session_id)
|
||||
if not rec:
|
||||
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
|
||||
|
||||
file_path = Path(rec['file_path'])
|
||||
try:
|
||||
resolved_root = RECORDING_ROOT.resolve()
|
||||
resolved_file = file_path.resolve()
|
||||
if resolved_root not in resolved_file.parents:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
||||
except Exception:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
||||
|
||||
if not file_path.exists():
|
||||
return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404
|
||||
|
||||
limit = max(1, min(5000, request.args.get('limit', default=500, type=int)))
|
||||
offset = max(0, request.args.get('offset', default=0, type=int))
|
||||
|
||||
events: list[dict] = []
|
||||
seen = 0
|
||||
with file_path.open('r', encoding='utf-8', errors='replace') as fh:
|
||||
for idx, line in enumerate(fh):
|
||||
if idx < offset:
|
||||
continue
|
||||
if seen >= limit:
|
||||
break
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
events.append(json.loads(line))
|
||||
seen += 1
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'recording': {
|
||||
'id': rec['id'],
|
||||
'mode': rec['mode'],
|
||||
'started_at': rec['started_at'],
|
||||
'stopped_at': rec['stopped_at'],
|
||||
'event_count': rec['event_count'],
|
||||
},
|
||||
'offset': offset,
|
||||
'limit': limit,
|
||||
'returned': len(events),
|
||||
'events': events,
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ from utils.logging import sensor_logger as logger
|
||||
from utils.validation import (
|
||||
validate_frequency, validate_device_index, validate_gain, validate_ppm
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import safe_terminate, register_process, unregister_process
|
||||
|
||||
@@ -288,26 +288,19 @@ def stop_rtlamr() -> Response:
|
||||
|
||||
@rtlamr_bp.route('/stream_rtlamr')
|
||||
def stream_rtlamr() -> Response:
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('rtlamr', msg, msg.get('type'))
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.rtlamr_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('rtlamr', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.rtlamr_queue,
|
||||
channel_key='rtlamr',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
|
||||
@@ -166,9 +166,11 @@ def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Opti
|
||||
@satellite_bp.route('/dashboard')
|
||||
def satellite_dashboard():
|
||||
"""Popout satellite tracking dashboard."""
|
||||
embedded = request.args.get('embedded', 'false') == 'true'
|
||||
return render_template(
|
||||
'satellite_dashboard.html',
|
||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||
embedded=embedded,
|
||||
)
|
||||
|
||||
|
||||
@@ -588,14 +590,14 @@ def list_tracked_satellites():
|
||||
def add_tracked_satellites_endpoint():
|
||||
"""Add one or more tracked satellites."""
|
||||
global _tle_cache
|
||||
data = request.json
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
||||
|
||||
# Accept a single satellite dict or a list
|
||||
sat_list = data if isinstance(data, list) else [data]
|
||||
|
||||
added = 0
|
||||
normalized: list[dict] = []
|
||||
for sat in sat_list:
|
||||
norad_id = str(sat.get('norad_id', sat.get('norad', '')))
|
||||
name = sat.get('name', '')
|
||||
@@ -605,19 +607,46 @@ def add_tracked_satellites_endpoint():
|
||||
tle2 = sat.get('tle_line2', sat.get('tle2'))
|
||||
enabled = sat.get('enabled', True)
|
||||
|
||||
if add_tracked_satellite(norad_id, name, tle1, tle2, enabled):
|
||||
added += 1
|
||||
normalized.append({
|
||||
'norad_id': norad_id,
|
||||
'name': name,
|
||||
'tle_line1': tle1,
|
||||
'tle_line2': tle2,
|
||||
'enabled': bool(enabled),
|
||||
'builtin': False,
|
||||
})
|
||||
|
||||
# Also inject into TLE cache if we have TLE data
|
||||
if tle1 and tle2:
|
||||
cache_key = name.replace(' ', '-').upper()
|
||||
_tle_cache[cache_key] = (name, tle1, tle2)
|
||||
|
||||
return jsonify({
|
||||
# Single inserts preserve previous behavior; list inserts use DB-level bulk path.
|
||||
if len(normalized) == 1:
|
||||
sat = normalized[0]
|
||||
added = 1 if add_tracked_satellite(
|
||||
sat['norad_id'],
|
||||
sat['name'],
|
||||
sat.get('tle_line1'),
|
||||
sat.get('tle_line2'),
|
||||
sat.get('enabled', True),
|
||||
sat.get('builtin', False),
|
||||
) else 0
|
||||
else:
|
||||
added = bulk_add_tracked_satellites(normalized)
|
||||
|
||||
response_payload = {
|
||||
'status': 'success',
|
||||
'added': added,
|
||||
'satellites': get_tracked_satellites(),
|
||||
})
|
||||
'processed': len(normalized),
|
||||
}
|
||||
|
||||
# Returning all tracked satellites for very large imports can stall the UI.
|
||||
include_satellites = request.args.get('include_satellites', '').lower() == 'true'
|
||||
if include_satellites or len(normalized) <= 32:
|
||||
response_payload['satellites'] = get_tracked_satellites()
|
||||
|
||||
return jsonify(response_payload)
|
||||
|
||||
|
||||
@satellite_bp.route('/tracked/<norad_id>', methods=['PUT'])
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"""RTL_433 sensor monitoring routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
@@ -18,7 +19,7 @@ from utils.validation import (
|
||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.process import safe_terminate, register_process, unregister_process
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
@@ -28,8 +29,42 @@ sensor_bp = Blueprint('sensor', __name__)
|
||||
# Track which device is being used
|
||||
sensor_active_device: int | None = None
|
||||
|
||||
|
||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
# RSSI history per device (model_id -> list of (timestamp, rssi))
|
||||
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
|
||||
_MAX_RSSI_HISTORY = 60
|
||||
|
||||
|
||||
def _build_scope_waveform(rssi: float, snr: float, noise: float, points: int = 256) -> list[int]:
|
||||
"""Synthesize a compact waveform from rtl_433 level metrics."""
|
||||
points = max(32, min(points, 512))
|
||||
|
||||
# rssi is usually negative; stronger signals are closer to 0 dBm.
|
||||
rssi_norm = min(max(abs(rssi) / 40.0, 0.0), 1.0)
|
||||
snr_norm = min(max((snr + 5.0) / 35.0, 0.0), 1.0)
|
||||
noise_norm = min(max(abs(noise) / 40.0, 0.0), 1.0)
|
||||
|
||||
amplitude = max(0.06, min(1.0, (0.6 * rssi_norm + 0.4 * snr_norm) - (0.22 * noise_norm)))
|
||||
cycles = 3.0 + (snr_norm * 8.0)
|
||||
harmonic = 0.25 + (0.35 * snr_norm)
|
||||
hiss = 0.08 + (0.18 * noise_norm)
|
||||
phase = (time.monotonic() * (1.4 + (snr_norm * 2.2))) % (2.0 * math.pi)
|
||||
|
||||
waveform: list[int] = []
|
||||
for i in range(points):
|
||||
t = i / (points - 1)
|
||||
base = math.sin((2.0 * math.pi * cycles * t) + phase)
|
||||
overtone = math.sin((2.0 * math.pi * (cycles * 2.4) * t) + (phase * 0.7))
|
||||
noise_wobble = math.sin((2.0 * math.pi * (cycles * 7.0) * t) + (phase * 2.1))
|
||||
|
||||
sample = amplitude * (base + (harmonic * overtone) + (hiss * noise_wobble))
|
||||
sample /= (1.0 + harmonic + hiss)
|
||||
packed = int(round(max(-1.0, min(1.0, sample)) * 127.0))
|
||||
waveform.append(max(-127, min(127, packed)))
|
||||
|
||||
return waveform
|
||||
|
||||
|
||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
"""Stream rtl_433 JSON output to queue."""
|
||||
try:
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
|
||||
@@ -45,20 +80,39 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
data['type'] = 'sensor'
|
||||
app_module.sensor_queue.put(data)
|
||||
|
||||
# Track RSSI history per device
|
||||
_model = data.get('model', '')
|
||||
_dev_id = data.get('id', '')
|
||||
_rssi_val = data.get('rssi')
|
||||
if _rssi_val is not None and _model:
|
||||
_hist_key = f"{_model}_{_dev_id}"
|
||||
hist = sensor_rssi_history.setdefault(_hist_key, [])
|
||||
hist.append((time.time(), float(_rssi_val)))
|
||||
if len(hist) > _MAX_RSSI_HISTORY:
|
||||
del hist[: len(hist) - _MAX_RSSI_HISTORY]
|
||||
|
||||
# Push scope event when signal level data is present
|
||||
rssi = data.get('rssi')
|
||||
snr = data.get('snr')
|
||||
noise = data.get('noise')
|
||||
if rssi is not None or snr is not None:
|
||||
try:
|
||||
app_module.sensor_queue.put_nowait({
|
||||
'type': 'scope',
|
||||
'rssi': rssi if rssi is not None else 0,
|
||||
'snr': snr if snr is not None else 0,
|
||||
'noise': noise if noise is not None else 0,
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
if rssi is not None or snr is not None:
|
||||
try:
|
||||
rssi_value = float(rssi) if rssi is not None else 0.0
|
||||
snr_value = float(snr) if snr is not None else 0.0
|
||||
noise_value = float(noise) if noise is not None else 0.0
|
||||
app_module.sensor_queue.put_nowait({
|
||||
'type': 'scope',
|
||||
'rssi': rssi_value,
|
||||
'snr': snr_value,
|
||||
'noise': noise_value,
|
||||
'waveform': _build_scope_waveform(
|
||||
rssi=rssi_value,
|
||||
snr=snr_value,
|
||||
noise=noise_value,
|
||||
),
|
||||
})
|
||||
except (TypeError, ValueError, queue.Full):
|
||||
pass
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
@@ -257,29 +311,31 @@ def stop_sensor() -> Response:
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
|
||||
@sensor_bp.route('/stream_sensor')
|
||||
def stream_sensor() -> Response:
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
@sensor_bp.route('/stream_sensor')
|
||||
def stream_sensor() -> Response:
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('sensor', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.sensor_queue,
|
||||
channel_key='sensor',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.sensor_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('sensor', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
@sensor_bp.route('/sensor/rssi_history')
|
||||
def get_rssi_history() -> Response:
|
||||
"""Return RSSI history for all tracked sensor devices."""
|
||||
result = {}
|
||||
for key, entries in sensor_rssi_history.items():
|
||||
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
|
||||
return jsonify({'status': 'success', 'devices': result})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Space Weather routes - proxies NOAA SWPC, NASA SDO, and HamQSL data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, Response, jsonify
|
||||
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger('intercept.space_weather')
|
||||
|
||||
space_weather_bp = Blueprint('space_weather', __name__, url_prefix='/space-weather')
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TTL Cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_cache: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# Cache TTLs in seconds
|
||||
TTL_REALTIME = 300 # 5 min for real-time data
|
||||
TTL_FORECAST = 1800 # 30 min for forecasts
|
||||
TTL_DAILY = 3600 # 1 hr for daily summaries
|
||||
TTL_IMAGE = 600 # 10 min for images
|
||||
|
||||
|
||||
def _cache_get(key: str) -> Any | None:
|
||||
entry = _cache.get(key)
|
||||
if entry and time.time() < entry['expires']:
|
||||
return entry['data']
|
||||
return None
|
||||
|
||||
|
||||
def _cache_set(key: str, data: Any, ttl: int) -> None:
|
||||
_cache[key] = {'data': data, 'expires': time.time() + ttl}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TIMEOUT = 15 # seconds
|
||||
|
||||
SWPC_BASE = 'https://services.swpc.noaa.gov'
|
||||
SWPC_JSON = f'{SWPC_BASE}/products'
|
||||
|
||||
|
||||
def _fetch_json(url: str, timeout: int = _TIMEOUT) -> Any | None:
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to fetch %s: %s', url, exc)
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_text(url: str, timeout: int = _TIMEOUT) -> str | None:
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.read().decode()
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to fetch %s: %s', url, exc)
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_bytes(url: str, timeout: int = _TIMEOUT) -> bytes | None:
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.read()
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to fetch %s: %s', url, exc)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data source fetchers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fetch_cached_json(cache_key: str, url: str, ttl: int) -> Any | None:
|
||||
cached = _cache_get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
data = _fetch_json(url)
|
||||
if data is not None:
|
||||
_cache_set(cache_key, data, ttl)
|
||||
return data
|
||||
|
||||
|
||||
def _fetch_kp_index() -> Any | None:
|
||||
return _fetch_cached_json('kp_index', f'{SWPC_JSON}/noaa-planetary-k-index.json', TTL_REALTIME)
|
||||
|
||||
|
||||
def _fetch_kp_forecast() -> Any | None:
|
||||
return _fetch_cached_json('kp_forecast', f'{SWPC_JSON}/noaa-planetary-k-index-forecast.json', TTL_FORECAST)
|
||||
|
||||
|
||||
def _fetch_scales() -> Any | None:
|
||||
return _fetch_cached_json('scales', f'{SWPC_JSON}/noaa-scales.json', TTL_REALTIME)
|
||||
|
||||
|
||||
def _fetch_flux() -> Any | None:
|
||||
return _fetch_cached_json('flux', f'{SWPC_JSON}/10cm-flux-30-day.json', TTL_DAILY)
|
||||
|
||||
|
||||
def _fetch_alerts() -> Any | None:
|
||||
return _fetch_cached_json('alerts', f'{SWPC_JSON}/alerts.json', TTL_REALTIME)
|
||||
|
||||
|
||||
def _fetch_solar_wind_plasma() -> Any | None:
|
||||
return _fetch_cached_json('sw_plasma', f'{SWPC_JSON}/solar-wind/plasma-6-hour.json', TTL_REALTIME)
|
||||
|
||||
|
||||
def _fetch_solar_wind_mag() -> Any | None:
|
||||
return _fetch_cached_json('sw_mag', f'{SWPC_JSON}/solar-wind/mag-6-hour.json', TTL_REALTIME)
|
||||
|
||||
|
||||
def _fetch_xrays() -> Any | None:
|
||||
return _fetch_cached_json('xrays', f'{SWPC_BASE}/json/goes/primary/xrays-1-day.json', TTL_REALTIME)
|
||||
|
||||
|
||||
def _fetch_xray_flares() -> Any | None:
|
||||
return _fetch_cached_json('xray_flares', f'{SWPC_BASE}/json/goes/primary/xray-flares-7-day.json', TTL_REALTIME)
|
||||
|
||||
|
||||
def _fetch_flare_probability() -> Any | None:
|
||||
return _fetch_cached_json('flare_prob', f'{SWPC_BASE}/json/solar_probabilities.json', TTL_FORECAST)
|
||||
|
||||
|
||||
def _fetch_solar_regions() -> Any | None:
|
||||
return _fetch_cached_json('solar_regions', f'{SWPC_BASE}/json/solar_regions.json', TTL_DAILY)
|
||||
|
||||
|
||||
def _fetch_sunspot_report() -> Any | None:
|
||||
return _fetch_cached_json('sunspot_report', f'{SWPC_BASE}/json/sunspot_report.json', TTL_DAILY)
|
||||
|
||||
|
||||
def _parse_hamqsl_xml(xml_text: str) -> dict[str, Any] | None:
|
||||
"""Parse HamQSL solar XML into a dict of band conditions."""
|
||||
try:
|
||||
root = ET.fromstring(xml_text)
|
||||
solar = root.find('.//solardata')
|
||||
if solar is None:
|
||||
return None
|
||||
result: dict[str, Any] = {}
|
||||
# Scalar fields
|
||||
for tag in ('sfi', 'aindex', 'kindex', 'kindexnt', 'xray', 'sunspots',
|
||||
'heliumline', 'protonflux', 'electonflux', 'aurora',
|
||||
'normalization', 'latdegree', 'solarwind', 'magneticfield',
|
||||
'calculatedconditions', 'calculatedvhfconditions',
|
||||
'geomagfield', 'signalnoise', 'fof2', 'muffactor', 'muf'):
|
||||
el = solar.find(tag)
|
||||
if el is not None and el.text:
|
||||
result[tag] = el.text.strip()
|
||||
# Band conditions
|
||||
bands: list[dict[str, str]] = []
|
||||
for band_el in solar.findall('.//calculatedconditions/band'):
|
||||
bands.append({
|
||||
'name': band_el.get('name', ''),
|
||||
'time': band_el.get('time', ''),
|
||||
'condition': band_el.text.strip() if band_el.text else ''
|
||||
})
|
||||
result['bands'] = bands
|
||||
# VHF conditions
|
||||
vhf: list[dict[str, str]] = []
|
||||
for phen_el in solar.findall('.//calculatedvhfconditions/phenomenon'):
|
||||
vhf.append({
|
||||
'name': phen_el.get('name', ''),
|
||||
'location': phen_el.get('location', ''),
|
||||
'condition': phen_el.text.strip() if phen_el.text else ''
|
||||
})
|
||||
result['vhf'] = vhf
|
||||
return result
|
||||
except ET.ParseError as exc:
|
||||
logger.warning('Failed to parse HamQSL XML: %s', exc)
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_band_conditions() -> dict[str, Any] | None:
|
||||
cached = _cache_get('band_conditions')
|
||||
if cached is not None:
|
||||
return cached
|
||||
xml_text = _fetch_text('https://www.hamqsl.com/solarxml.php')
|
||||
if xml_text is None:
|
||||
return None
|
||||
data = _parse_hamqsl_xml(xml_text)
|
||||
if data is not None:
|
||||
_cache_set('band_conditions', data, TTL_FORECAST)
|
||||
return data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Image proxy whitelist
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
IMAGE_WHITELIST: dict[str, dict[str, str]] = {
|
||||
# D-RAP absorption maps
|
||||
'drap_global': {
|
||||
'url': f'{SWPC_BASE}/images/animations/d-rap/global/latest.png',
|
||||
'content_type': 'image/png',
|
||||
},
|
||||
'drap_5': {
|
||||
'url': f'{SWPC_BASE}/images/d-rap/global_f05.png',
|
||||
'content_type': 'image/png',
|
||||
},
|
||||
'drap_10': {
|
||||
'url': f'{SWPC_BASE}/images/d-rap/global_f10.png',
|
||||
'content_type': 'image/png',
|
||||
},
|
||||
'drap_15': {
|
||||
'url': f'{SWPC_BASE}/images/d-rap/global_f15.png',
|
||||
'content_type': 'image/png',
|
||||
},
|
||||
'drap_20': {
|
||||
'url': f'{SWPC_BASE}/images/d-rap/global_f20.png',
|
||||
'content_type': 'image/png',
|
||||
},
|
||||
'drap_25': {
|
||||
'url': f'{SWPC_BASE}/images/d-rap/global_f25.png',
|
||||
'content_type': 'image/png',
|
||||
},
|
||||
'drap_30': {
|
||||
'url': f'{SWPC_BASE}/images/d-rap/global_f30.png',
|
||||
'content_type': 'image/png',
|
||||
},
|
||||
# Aurora forecast
|
||||
'aurora_north': {
|
||||
'url': f'{SWPC_BASE}/images/animations/ovation/north/latest.jpg',
|
||||
'content_type': 'image/jpeg',
|
||||
},
|
||||
# SDO solar imagery
|
||||
'sdo_193': {
|
||||
'url': 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0193.jpg',
|
||||
'content_type': 'image/jpeg',
|
||||
},
|
||||
'sdo_304': {
|
||||
'url': 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0304.jpg',
|
||||
'content_type': 'image/jpeg',
|
||||
},
|
||||
'sdo_magnetogram': {
|
||||
'url': 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_HMIBC.jpg',
|
||||
'content_type': 'image/jpeg',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@space_weather_bp.route('/data')
|
||||
def get_data():
|
||||
"""Return aggregated space weather data from all sources."""
|
||||
data = {
|
||||
'kp_index': _fetch_kp_index(),
|
||||
'kp_forecast': _fetch_kp_forecast(),
|
||||
'scales': _fetch_scales(),
|
||||
'flux': _fetch_flux(),
|
||||
'alerts': _fetch_alerts(),
|
||||
'solar_wind_plasma': _fetch_solar_wind_plasma(),
|
||||
'solar_wind_mag': _fetch_solar_wind_mag(),
|
||||
'xrays': _fetch_xrays(),
|
||||
'xray_flares': _fetch_xray_flares(),
|
||||
'flare_probability': _fetch_flare_probability(),
|
||||
'solar_regions': _fetch_solar_regions(),
|
||||
'sunspot_report': _fetch_sunspot_report(),
|
||||
'band_conditions': _fetch_band_conditions(),
|
||||
'timestamp': time.time(),
|
||||
}
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@space_weather_bp.route('/image/<key>')
|
||||
def get_image(key: str):
|
||||
"""Proxy and cache whitelisted space weather images."""
|
||||
entry = IMAGE_WHITELIST.get(key)
|
||||
if not entry:
|
||||
return jsonify({'error': 'Unknown image key'}), 404
|
||||
|
||||
cache_key = f'img_{key}'
|
||||
cached = _cache_get(cache_key)
|
||||
if cached is not None:
|
||||
return Response(cached, content_type=entry['content_type'],
|
||||
headers={'Cache-Control': 'public, max-age=300'})
|
||||
|
||||
img_data = _fetch_bytes(entry['url'])
|
||||
if img_data is None:
|
||||
return jsonify({'error': 'Failed to fetch image'}), 502
|
||||
|
||||
_cache_set(cache_key, img_data, TTL_IMAGE)
|
||||
return Response(img_data, content_type=entry['content_type'],
|
||||
headers={'Cache-Control': 'public, max-age=300'})
|
||||
@@ -15,7 +15,7 @@ from flask import Blueprint, jsonify, request, Response, send_file
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sstv import (
|
||||
get_sstv_decoder,
|
||||
@@ -27,6 +27,12 @@ logger = get_logger('intercept.sstv')
|
||||
|
||||
sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
|
||||
|
||||
# ISS SSTV runs on a fixed downlink; allow a small entry tolerance so users
|
||||
# can type nearby values and still land on the canonical center frequency.
|
||||
ISS_SSTV_MODULATION = 'fm'
|
||||
ISS_SSTV_FREQUENCIES = (ISS_SSTV_FREQ,)
|
||||
ISS_SSTV_FREQ_TOLERANCE_MHZ = 0.05
|
||||
|
||||
# Queue for SSE progress streaming
|
||||
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
|
||||
@@ -46,6 +52,14 @@ def _progress_callback(data: dict) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_iss_frequency(frequency_mhz: float) -> float | None:
|
||||
"""Snap near-match user input to a supported ISS SSTV center frequency."""
|
||||
for supported in ISS_SSTV_FREQUENCIES:
|
||||
if abs(frequency_mhz - supported) <= ISS_SSTV_FREQ_TOLERANCE_MHZ:
|
||||
return supported
|
||||
return None
|
||||
|
||||
|
||||
@sstv_bp.route('/status')
|
||||
def get_status():
|
||||
"""
|
||||
@@ -62,6 +76,7 @@ def get_status():
|
||||
'decoder': decoder.decoder_available,
|
||||
'running': decoder.is_running,
|
||||
'iss_frequency': ISS_SSTV_FREQ,
|
||||
'modulation': ISS_SSTV_MODULATION,
|
||||
'image_count': len(decoder.get_images()),
|
||||
'doppler_enabled': decoder.doppler_enabled,
|
||||
}
|
||||
@@ -82,6 +97,7 @@ def start_decoder():
|
||||
JSON body (optional):
|
||||
{
|
||||
"frequency": 145.800, // Frequency in MHz (default: ISS 145.800)
|
||||
"modulation": "fm", // ISS mode is FM-only
|
||||
"device": 0, // RTL-SDR device index
|
||||
"latitude": 40.7128, // Observer latitude for Doppler correction
|
||||
"longitude": -74.0060 // Observer longitude for Doppler correction
|
||||
@@ -106,6 +122,7 @@ def start_decoder():
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'frequency': ISS_SSTV_FREQ,
|
||||
'modulation': ISS_SSTV_MODULATION,
|
||||
'doppler_enabled': decoder.doppler_enabled
|
||||
})
|
||||
|
||||
@@ -119,18 +136,29 @@ def start_decoder():
|
||||
# Get parameters
|
||||
data = request.get_json(silent=True) or {}
|
||||
frequency = data.get('frequency', ISS_SSTV_FREQ)
|
||||
modulation = str(data.get('modulation', ISS_SSTV_MODULATION)).strip().lower()
|
||||
device_index = data.get('device', 0)
|
||||
latitude = data.get('latitude')
|
||||
longitude = data.get('longitude')
|
||||
|
||||
# Validate modulation (ISS mode is FM-only)
|
||||
if modulation != ISS_SSTV_MODULATION:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Modulation must be {ISS_SSTV_MODULATION} for ISS SSTV mode'
|
||||
}), 400
|
||||
|
||||
# Validate frequency
|
||||
try:
|
||||
frequency = float(frequency)
|
||||
if not (100 <= frequency <= 500): # VHF range
|
||||
normalized_frequency = _normalize_iss_frequency(frequency)
|
||||
if normalized_frequency is None:
|
||||
supported = ', '.join(f'{freq:.3f}' for freq in ISS_SSTV_FREQUENCIES)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Frequency must be between 100-500 MHz'
|
||||
'message': f'Supported ISS SSTV frequency: {supported} MHz FM'
|
||||
}), 400
|
||||
frequency = normalized_frequency
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -178,7 +206,8 @@ def start_decoder():
|
||||
frequency=frequency,
|
||||
device_index=device_index,
|
||||
latitude=latitude,
|
||||
longitude=longitude
|
||||
longitude=longitude,
|
||||
modulation=ISS_SSTV_MODULATION,
|
||||
)
|
||||
|
||||
if success:
|
||||
@@ -187,6 +216,7 @@ def start_decoder():
|
||||
result = {
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'modulation': ISS_SSTV_MODULATION,
|
||||
'device': device_index,
|
||||
'doppler_enabled': decoder.doppler_enabled
|
||||
}
|
||||
@@ -379,8 +409,8 @@ def delete_all_images():
|
||||
return jsonify({'status': 'ok', 'deleted': count})
|
||||
|
||||
|
||||
@sstv_bp.route('/stream')
|
||||
def stream_progress():
|
||||
@sstv_bp.route('/stream')
|
||||
def stream_progress():
|
||||
"""
|
||||
SSE stream of SSTV decode progress.
|
||||
|
||||
@@ -392,29 +422,22 @@ def stream_progress():
|
||||
Returns:
|
||||
SSE stream (text/event-stream)
|
||||
"""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
progress = _sstv_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('sstv', progress, progress.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(progress)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('sstv', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_sstv_queue,
|
||||
channel_key='sstv',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@@ -13,8 +13,9 @@ from pathlib import Path
|
||||
|
||||
from flask import Blueprint, Response, jsonify, request, send_file
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sstv import (
|
||||
get_general_sstv_decoder,
|
||||
@@ -27,6 +28,9 @@ sstv_general_bp = Blueprint('sstv_general', __name__, url_prefix='/sstv-general'
|
||||
# Queue for SSE progress streaming
|
||||
_sstv_general_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
|
||||
# Track which device is being used
|
||||
_sstv_general_active_device: int | None = None
|
||||
|
||||
# Predefined SSTV frequencies
|
||||
SSTV_FREQUENCIES = [
|
||||
{'band': '80 m', 'frequency': 3.845, 'modulation': 'lsb', 'notes': 'Common US SSTV calling frequency', 'type': 'Terrestrial HF'},
|
||||
@@ -40,7 +44,7 @@ SSTV_FREQUENCIES = [
|
||||
{'band': '15 m', 'frequency': 21.340, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
|
||||
{'band': '10 m', 'frequency': 28.680, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
|
||||
{'band': '6 m', 'frequency': 50.950, 'modulation': 'usb', 'notes': 'SSTV calling (less common)', 'type': 'Terrestrial VHF'},
|
||||
{'band': '2 m', 'frequency': 145.625, 'modulation': 'fm', 'notes': 'Australia/common simplex (FM sometimes used)', 'type': 'Terrestrial VHF'},
|
||||
{'band': '2 m', 'frequency': 145.500, 'modulation': 'fm', 'notes': 'Australia/common simplex (FM sometimes used)', 'type': 'Terrestrial VHF'},
|
||||
{'band': '70 cm', 'frequency': 433.775, 'modulation': 'fm', 'notes': 'Australia/common simplex', 'type': 'Terrestrial UHF'},
|
||||
]
|
||||
|
||||
@@ -150,6 +154,17 @@ def start_decoder():
|
||||
'message': 'Modulation must be fm, usb, or lsb',
|
||||
}), 400
|
||||
|
||||
# Claim SDR device
|
||||
global _sstv_general_active_device
|
||||
device_int = int(device_index)
|
||||
error = app_module.claim_sdr_device(device_int, 'sstv_general')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error,
|
||||
}), 409
|
||||
|
||||
# Set callback and start
|
||||
decoder.set_callback(_progress_callback)
|
||||
success = decoder.start(
|
||||
@@ -159,6 +174,7 @@ def start_decoder():
|
||||
)
|
||||
|
||||
if success:
|
||||
_sstv_general_active_device = device_int
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
@@ -166,6 +182,7 @@ def start_decoder():
|
||||
'device': device_index,
|
||||
})
|
||||
else:
|
||||
app_module.release_sdr_device(device_int)
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start decoder',
|
||||
@@ -175,8 +192,14 @@ def start_decoder():
|
||||
@sstv_general_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoder():
|
||||
"""Stop general SSTV decoder."""
|
||||
global _sstv_general_active_device
|
||||
decoder = get_general_sstv_decoder()
|
||||
decoder.stop()
|
||||
|
||||
if _sstv_general_active_device is not None:
|
||||
app_module.release_sdr_device(_sstv_general_active_device)
|
||||
_sstv_general_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@@ -266,26 +289,19 @@ def delete_all_images():
|
||||
@sstv_general_bp.route('/stream')
|
||||
def stream_progress():
|
||||
"""SSE stream of SSTV decode progress."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('sstv_general', msg, msg.get('type'))
|
||||
|
||||
while True:
|
||||
try:
|
||||
progress = _sstv_general_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('sstv_general', progress, progress.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(progress)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=_sstv_general_queue,
|
||||
channel_key='sstv_general',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
|
||||
@@ -10,9 +10,10 @@ import queue
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, send_file
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import sse_stream
|
||||
from utils.subghz import get_subghz_manager
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import sse_stream
|
||||
from utils.subghz import get_subghz_manager
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.constants import (
|
||||
SUBGHZ_FREQ_MIN_MHZ,
|
||||
SUBGHZ_FREQ_MAX_MHZ,
|
||||
@@ -32,10 +33,14 @@ subghz_bp = Blueprint('subghz', __name__, url_prefix='/subghz')
|
||||
_subghz_queue: queue.Queue = queue.Queue(maxsize=200)
|
||||
|
||||
|
||||
def _event_callback(event: dict) -> None:
|
||||
"""Forward SubGhzManager events to the SSE queue."""
|
||||
try:
|
||||
_subghz_queue.put_nowait(event)
|
||||
def _event_callback(event: dict) -> None:
|
||||
"""Forward SubGhzManager events to the SSE queue."""
|
||||
try:
|
||||
process_event('subghz', event, event.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_subghz_queue.put_nowait(event)
|
||||
except queue.Full:
|
||||
try:
|
||||
_subghz_queue.get_nowait()
|
||||
|
||||
@@ -61,6 +61,7 @@ from utils.tscm.device_identity import (
|
||||
ingest_wifi_dict,
|
||||
)
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sse import sse_stream_fanout
|
||||
|
||||
# Import unified Bluetooth scanner helper for TSCM integration
|
||||
try:
|
||||
@@ -629,24 +630,17 @@ def sweep_status():
|
||||
@tscm_bp.route('/sweep/stream')
|
||||
def sweep_stream():
|
||||
"""SSE stream for real-time sweep updates."""
|
||||
def generate():
|
||||
while True:
|
||||
try:
|
||||
if tscm_queue:
|
||||
msg = tscm_queue.get(timeout=1)
|
||||
try:
|
||||
process_event('tscm', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield f"data: {json.dumps(msg)}\n\n"
|
||||
else:
|
||||
time.sleep(1)
|
||||
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
|
||||
except queue.Empty:
|
||||
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('tscm', msg, msg.get('type'))
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
sse_stream_fanout(
|
||||
source_queue=tscm_queue,
|
||||
channel_key='tscm',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
"""VDL2 aircraft datalink routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import pty
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.sse import sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
)
|
||||
from utils.process import register_process, unregister_process
|
||||
|
||||
vdl2_bp = Blueprint('vdl2', __name__, url_prefix='/vdl2')
|
||||
|
||||
# Default VDL2 frequencies (MHz) - common worldwide
|
||||
DEFAULT_VDL2_FREQUENCIES = [
|
||||
'136975000', # Primary worldwide
|
||||
'136725000', # Europe
|
||||
'136775000', # Europe
|
||||
'136800000', # Multi-region
|
||||
'136875000', # Multi-region
|
||||
]
|
||||
|
||||
# Message counter for statistics
|
||||
vdl2_message_count = 0
|
||||
vdl2_last_message_time = None
|
||||
|
||||
# Track which device is being used
|
||||
vdl2_active_device: int | None = None
|
||||
|
||||
|
||||
def find_dumpvdl2():
|
||||
"""Find dumpvdl2 binary."""
|
||||
return shutil.which('dumpvdl2')
|
||||
|
||||
|
||||
def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> None:
|
||||
"""Stream dumpvdl2 JSON output to queue."""
|
||||
global vdl2_message_count, vdl2_last_message_time
|
||||
|
||||
try:
|
||||
app_module.vdl2_queue.put({'type': 'status', 'status': 'started'})
|
||||
|
||||
# Use appropriate sentinel based on mode (text mode for pty on macOS)
|
||||
sentinel = '' if is_text_mode else b''
|
||||
for line in iter(process.stdout.readline, sentinel):
|
||||
if is_text_mode:
|
||||
line = line.strip()
|
||||
else:
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(line)
|
||||
|
||||
# Add our metadata
|
||||
data['type'] = 'vdl2'
|
||||
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
|
||||
|
||||
# Update stats
|
||||
vdl2_message_count += 1
|
||||
vdl2_last_message_time = time.time()
|
||||
|
||||
app_module.vdl2_queue.put(data)
|
||||
|
||||
# Feed flight correlator
|
||||
try:
|
||||
from utils.flight_correlator import get_flight_correlator
|
||||
get_flight_correlator().add_vdl2_message(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{ts} | VDL2 | {json.dumps(data)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON - could be status message
|
||||
if line:
|
||||
logger.debug(f"dumpvdl2 non-JSON: {line[:100]}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"VDL2 stream error: {e}")
|
||||
app_module.vdl2_queue.put({'type': 'error', 'message': str(e)})
|
||||
finally:
|
||||
global vdl2_active_device
|
||||
# Ensure process is terminated
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
unregister_process(process)
|
||||
app_module.vdl2_queue.put({'type': 'status', 'status': 'stopped'})
|
||||
with app_module.vdl2_lock:
|
||||
app_module.vdl2_process = None
|
||||
# Release SDR device
|
||||
if vdl2_active_device is not None:
|
||||
app_module.release_sdr_device(vdl2_active_device)
|
||||
vdl2_active_device = None
|
||||
|
||||
|
||||
@vdl2_bp.route('/tools')
|
||||
def check_vdl2_tools() -> Response:
|
||||
"""Check for VDL2 decoding tools."""
|
||||
has_dumpvdl2 = find_dumpvdl2() is not None
|
||||
|
||||
return jsonify({
|
||||
'dumpvdl2': has_dumpvdl2,
|
||||
'ready': has_dumpvdl2
|
||||
})
|
||||
|
||||
|
||||
@vdl2_bp.route('/status')
|
||||
def vdl2_status() -> Response:
|
||||
"""Get VDL2 decoder status."""
|
||||
running = False
|
||||
if app_module.vdl2_process:
|
||||
running = app_module.vdl2_process.poll() is None
|
||||
|
||||
return jsonify({
|
||||
'running': running,
|
||||
'message_count': vdl2_message_count,
|
||||
'last_message_time': vdl2_last_message_time,
|
||||
'queue_size': app_module.vdl2_queue.qsize()
|
||||
})
|
||||
|
||||
|
||||
@vdl2_bp.route('/start', methods=['POST'])
|
||||
def start_vdl2() -> Response:
|
||||
"""Start VDL2 decoder."""
|
||||
global vdl2_message_count, vdl2_last_message_time, vdl2_active_device
|
||||
|
||||
with app_module.vdl2_lock:
|
||||
if app_module.vdl2_process and app_module.vdl2_process.poll() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'VDL2 decoder already running'
|
||||
}), 409
|
||||
|
||||
# Check for dumpvdl2
|
||||
dumpvdl2_path = find_dumpvdl2()
|
||||
if not dumpvdl2_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'dumpvdl2 not found. Install from: https://github.com/szpajder/dumpvdl2'
|
||||
}), 400
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
gain = validate_gain(data.get('gain', '40'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Check if device is available
|
||||
device_int = int(device)
|
||||
error = app_module.claim_sdr_device(device_int, 'vdl2')
|
||||
if error:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
'message': error
|
||||
}), 409
|
||||
|
||||
vdl2_active_device = device_int
|
||||
|
||||
# Get frequencies - use provided or defaults
|
||||
# dumpvdl2 expects frequencies in Hz (integers)
|
||||
frequencies = data.get('frequencies', DEFAULT_VDL2_FREQUENCIES)
|
||||
if isinstance(frequencies, str):
|
||||
frequencies = [f.strip() for f in frequencies.split(',')]
|
||||
|
||||
# Clear queue
|
||||
while not app_module.vdl2_queue.empty():
|
||||
try:
|
||||
app_module.vdl2_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Reset stats
|
||||
vdl2_message_count = 0
|
||||
vdl2_last_message_time = None
|
||||
|
||||
# Resolve SDR type for device selection
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
|
||||
|
||||
# Build dumpvdl2 command
|
||||
# dumpvdl2 --output decoded:json --rtlsdr <device> --gain <gain> --correction <ppm> <freq1> <freq2> ...
|
||||
cmd = [dumpvdl2_path]
|
||||
cmd.extend(['--output', 'decoded:json:file:path=-'])
|
||||
|
||||
if is_soapy:
|
||||
# SoapySDR device
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_int)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
device_str = builder._build_device_string(sdr_device)
|
||||
cmd.extend(['--soapysdr', device_str])
|
||||
else:
|
||||
cmd.extend(['--rtlsdr', str(device)])
|
||||
|
||||
# Add gain
|
||||
if gain and str(gain) != '0':
|
||||
cmd.extend(['--gain', str(gain)])
|
||||
|
||||
# Add PPM correction if specified
|
||||
if ppm and str(ppm) != '0':
|
||||
cmd.extend(['--correction', str(ppm)])
|
||||
|
||||
# Add frequencies (dumpvdl2 takes them as positional args in Hz)
|
||||
cmd.extend(frequencies)
|
||||
|
||||
logger.info(f"Starting VDL2 decoder: {' '.join(cmd)}")
|
||||
|
||||
try:
|
||||
is_text_mode = False
|
||||
|
||||
# On macOS, use pty to avoid stdout buffering issues
|
||||
if platform.system() == 'Darwin':
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=slave_fd,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
os.close(slave_fd)
|
||||
# Wrap master_fd as a text file for line-buffered reading
|
||||
process.stdout = io.open(master_fd, 'r', buffering=1)
|
||||
is_text_mode = True
|
||||
else:
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True
|
||||
)
|
||||
|
||||
# Wait briefly to check if process started
|
||||
time.sleep(PROCESS_START_WAIT)
|
||||
|
||||
if process.poll() is not None:
|
||||
# Process died - release device
|
||||
if vdl2_active_device is not None:
|
||||
app_module.release_sdr_device(vdl2_active_device)
|
||||
vdl2_active_device = None
|
||||
stderr = ''
|
||||
if process.stderr:
|
||||
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||
error_msg = 'dumpvdl2 failed to start'
|
||||
if stderr:
|
||||
error_msg += f': {stderr[:200]}'
|
||||
logger.error(error_msg)
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||
|
||||
app_module.vdl2_process = process
|
||||
register_process(process)
|
||||
|
||||
# Start output streaming thread
|
||||
thread = threading.Thread(
|
||||
target=stream_vdl2_output,
|
||||
args=(process, is_text_mode),
|
||||
daemon=True
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequencies': frequencies,
|
||||
'device': device,
|
||||
'gain': gain
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
# Release device on failure
|
||||
if vdl2_active_device is not None:
|
||||
app_module.release_sdr_device(vdl2_active_device)
|
||||
vdl2_active_device = None
|
||||
logger.error(f"Failed to start VDL2 decoder: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@vdl2_bp.route('/stop', methods=['POST'])
|
||||
def stop_vdl2() -> Response:
|
||||
"""Stop VDL2 decoder."""
|
||||
global vdl2_active_device
|
||||
|
||||
with app_module.vdl2_lock:
|
||||
if not app_module.vdl2_process:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'VDL2 decoder not running'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
app_module.vdl2_process.terminate()
|
||||
app_module.vdl2_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.vdl2_process.kill()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping VDL2: {e}")
|
||||
|
||||
app_module.vdl2_process = None
|
||||
|
||||
# Release device from registry
|
||||
if vdl2_active_device is not None:
|
||||
app_module.release_sdr_device(vdl2_active_device)
|
||||
vdl2_active_device = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@vdl2_bp.route('/stream')
|
||||
def stream_vdl2() -> Response:
|
||||
"""SSE stream for VDL2 messages."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('vdl2', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.vdl2_queue,
|
||||
channel_key='vdl2',
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
|
||||
@vdl2_bp.route('/frequencies')
|
||||
def get_frequencies() -> Response:
|
||||
"""Get default VDL2 frequencies."""
|
||||
return jsonify({
|
||||
'default': DEFAULT_VDL2_FREQUENCIES,
|
||||
'regions': {
|
||||
'north_america': ['136975000', '136100000', '136650000', '136700000', '136800000'],
|
||||
'europe': ['136975000', '136675000', '136725000', '136775000', '136825000'],
|
||||
'asia_pacific': ['136975000', '136900000'],
|
||||
}
|
||||
})
|
||||
@@ -1,13 +1,16 @@
|
||||
"""WebSocket-based waterfall streaming with I/Q capture and server-side FFT."""
|
||||
|
||||
import json
|
||||
import queue
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
from flask import Flask
|
||||
"""WebSocket-based waterfall streaming with I/Q capture and server-side FFT."""
|
||||
|
||||
import json
|
||||
import queue
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from flask import Flask
|
||||
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
@@ -16,31 +19,277 @@ except ImportError:
|
||||
WEBSOCKET_AVAILABLE = False
|
||||
Sock = None
|
||||
|
||||
from utils.logging import get_logger
|
||||
from utils.process import safe_terminate, register_process, unregister_process
|
||||
from utils.waterfall_fft import (
|
||||
build_binary_frame,
|
||||
compute_power_spectrum,
|
||||
cu8_to_complex,
|
||||
quantize_to_uint8,
|
||||
)
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.sdr.base import SDRCapabilities, SDRDevice
|
||||
from utils.logging import get_logger
|
||||
from utils.process import register_process, safe_terminate, unregister_process
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.sdr.base import SDRCapabilities, SDRDevice
|
||||
from utils.waterfall_fft import (
|
||||
build_binary_frame,
|
||||
compute_power_spectrum,
|
||||
cu8_to_complex,
|
||||
quantize_to_uint8,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.waterfall_ws')
|
||||
|
||||
AUDIO_SAMPLE_RATE = 48000
|
||||
_shared_state_lock = threading.Lock()
|
||||
_shared_audio_queue: queue.Queue[bytes] = queue.Queue(maxsize=80)
|
||||
_shared_state: dict[str, Any] = {
|
||||
'running': False,
|
||||
'device': None,
|
||||
'center_mhz': 0.0,
|
||||
'span_mhz': 0.0,
|
||||
'sample_rate': 0,
|
||||
'monitor_enabled': False,
|
||||
'monitor_freq_mhz': 0.0,
|
||||
'monitor_modulation': 'wfm',
|
||||
'monitor_squelch': 0,
|
||||
}
|
||||
|
||||
logger = get_logger('intercept.waterfall_ws')
|
||||
|
||||
# Maximum bandwidth per SDR type (Hz)
|
||||
MAX_BANDWIDTH = {
|
||||
SDRType.RTL_SDR: 2400000,
|
||||
SDRType.HACKRF: 20000000,
|
||||
SDRType.LIME_SDR: 20000000,
|
||||
SDRType.AIRSPY: 10000000,
|
||||
SDRType.SDRPLAY: 2000000,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_sdr_type(sdr_type_str: str) -> SDRType:
|
||||
"""Convert client sdr_type string to SDRType enum."""
|
||||
# Maximum bandwidth per SDR type (Hz)
|
||||
MAX_BANDWIDTH = {
|
||||
SDRType.RTL_SDR: 2400000,
|
||||
SDRType.HACKRF: 20000000,
|
||||
SDRType.LIME_SDR: 20000000,
|
||||
SDRType.AIRSPY: 10000000,
|
||||
SDRType.SDRPLAY: 2000000,
|
||||
}
|
||||
|
||||
|
||||
def _clear_shared_audio_queue() -> None:
|
||||
while True:
|
||||
try:
|
||||
_shared_audio_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
|
||||
def _set_shared_capture_state(
|
||||
*,
|
||||
running: bool,
|
||||
device: int | None = None,
|
||||
center_mhz: float | None = None,
|
||||
span_mhz: float | None = None,
|
||||
sample_rate: int | None = None,
|
||||
) -> None:
|
||||
with _shared_state_lock:
|
||||
_shared_state['running'] = bool(running)
|
||||
_shared_state['device'] = device if running else None
|
||||
if center_mhz is not None:
|
||||
_shared_state['center_mhz'] = float(center_mhz)
|
||||
if span_mhz is not None:
|
||||
_shared_state['span_mhz'] = float(span_mhz)
|
||||
if sample_rate is not None:
|
||||
_shared_state['sample_rate'] = int(sample_rate)
|
||||
if not running:
|
||||
_shared_state['monitor_enabled'] = False
|
||||
if not running:
|
||||
_clear_shared_audio_queue()
|
||||
|
||||
|
||||
def _set_shared_monitor(
|
||||
*,
|
||||
enabled: bool,
|
||||
frequency_mhz: float | None = None,
|
||||
modulation: str | None = None,
|
||||
squelch: int | None = None,
|
||||
) -> None:
|
||||
was_enabled = False
|
||||
with _shared_state_lock:
|
||||
was_enabled = bool(_shared_state.get('monitor_enabled'))
|
||||
_shared_state['monitor_enabled'] = bool(enabled)
|
||||
if frequency_mhz is not None:
|
||||
_shared_state['monitor_freq_mhz'] = float(frequency_mhz)
|
||||
if modulation is not None:
|
||||
_shared_state['monitor_modulation'] = str(modulation).lower().strip()
|
||||
if squelch is not None:
|
||||
_shared_state['monitor_squelch'] = max(0, min(100, int(squelch)))
|
||||
if was_enabled and not enabled:
|
||||
_clear_shared_audio_queue()
|
||||
|
||||
|
||||
def get_shared_capture_status() -> dict[str, Any]:
|
||||
with _shared_state_lock:
|
||||
return {
|
||||
'running': bool(_shared_state['running']),
|
||||
'device': _shared_state['device'],
|
||||
'center_mhz': float(_shared_state.get('center_mhz', 0.0) or 0.0),
|
||||
'span_mhz': float(_shared_state.get('span_mhz', 0.0) or 0.0),
|
||||
'sample_rate': int(_shared_state.get('sample_rate', 0) or 0),
|
||||
'monitor_enabled': bool(_shared_state.get('monitor_enabled')),
|
||||
'monitor_freq_mhz': float(_shared_state.get('monitor_freq_mhz', 0.0) or 0.0),
|
||||
'monitor_modulation': str(_shared_state.get('monitor_modulation', 'wfm')),
|
||||
'monitor_squelch': int(_shared_state.get('monitor_squelch', 0) or 0),
|
||||
}
|
||||
|
||||
|
||||
def start_shared_monitor_from_capture(
|
||||
*,
|
||||
device: int,
|
||||
frequency_mhz: float,
|
||||
modulation: str,
|
||||
squelch: int,
|
||||
) -> tuple[bool, str]:
|
||||
with _shared_state_lock:
|
||||
if not _shared_state['running']:
|
||||
return False, 'Waterfall IQ stream not active'
|
||||
if _shared_state['device'] != device:
|
||||
return False, 'Waterfall stream is using a different SDR device'
|
||||
_shared_state['monitor_enabled'] = True
|
||||
_shared_state['monitor_freq_mhz'] = float(frequency_mhz)
|
||||
_shared_state['monitor_modulation'] = str(modulation).lower().strip()
|
||||
_shared_state['monitor_squelch'] = max(0, min(100, int(squelch)))
|
||||
_clear_shared_audio_queue()
|
||||
return True, 'started'
|
||||
|
||||
|
||||
def stop_shared_monitor_from_capture() -> None:
|
||||
_set_shared_monitor(enabled=False)
|
||||
|
||||
|
||||
def read_shared_monitor_audio_chunk(timeout: float = 1.0) -> bytes | None:
|
||||
with _shared_state_lock:
|
||||
if not _shared_state['running'] or not _shared_state['monitor_enabled']:
|
||||
return None
|
||||
try:
|
||||
return _shared_audio_queue.get(timeout=max(0.0, float(timeout)))
|
||||
except queue.Empty:
|
||||
return None
|
||||
|
||||
|
||||
def _snapshot_monitor_config() -> dict[str, Any] | None:
|
||||
with _shared_state_lock:
|
||||
if not (_shared_state['running'] and _shared_state['monitor_enabled']):
|
||||
return None
|
||||
return {
|
||||
'center_mhz': float(_shared_state['center_mhz']),
|
||||
'monitor_freq_mhz': float(_shared_state['monitor_freq_mhz']),
|
||||
'modulation': str(_shared_state['monitor_modulation']),
|
||||
'squelch': int(_shared_state['monitor_squelch']),
|
||||
}
|
||||
|
||||
|
||||
def _push_shared_audio_chunk(chunk: bytes) -> None:
|
||||
if not chunk:
|
||||
return
|
||||
if _shared_audio_queue.full():
|
||||
with suppress(queue.Empty):
|
||||
_shared_audio_queue.get_nowait()
|
||||
with suppress(queue.Full):
|
||||
_shared_audio_queue.put_nowait(chunk)
|
||||
|
||||
|
||||
def _demodulate_monitor_audio(
|
||||
samples: np.ndarray,
|
||||
sample_rate: int,
|
||||
center_mhz: float,
|
||||
monitor_freq_mhz: float,
|
||||
modulation: str,
|
||||
squelch: int,
|
||||
) -> bytes | None:
|
||||
if samples.size < 32 or sample_rate <= 0:
|
||||
return None
|
||||
|
||||
fs = float(sample_rate)
|
||||
freq_offset_hz = (float(monitor_freq_mhz) - float(center_mhz)) * 1e6
|
||||
nyquist = fs * 0.5
|
||||
if abs(freq_offset_hz) > nyquist * 0.98:
|
||||
return None
|
||||
|
||||
n = np.arange(samples.size, dtype=np.float32)
|
||||
rotator = np.exp(-1j * (2.0 * np.pi * freq_offset_hz / fs) * n)
|
||||
shifted = samples * rotator
|
||||
|
||||
mod = str(modulation or 'wfm').lower().strip()
|
||||
target_bb = 220000.0 if mod == 'wfm' else 48000.0
|
||||
pre_decim = max(1, int(fs // target_bb))
|
||||
if pre_decim > 1:
|
||||
usable = (shifted.size // pre_decim) * pre_decim
|
||||
if usable < pre_decim:
|
||||
return None
|
||||
shifted = shifted[:usable].reshape(-1, pre_decim).mean(axis=1)
|
||||
fs1 = fs / pre_decim
|
||||
if shifted.size < 16:
|
||||
return None
|
||||
|
||||
if mod in ('wfm', 'fm'):
|
||||
audio = np.angle(shifted[1:] * np.conj(shifted[:-1])).astype(np.float32)
|
||||
elif mod == 'am':
|
||||
envelope = np.abs(shifted).astype(np.float32)
|
||||
audio = envelope - float(np.mean(envelope))
|
||||
elif mod == 'usb':
|
||||
audio = np.real(shifted).astype(np.float32)
|
||||
elif mod == 'lsb':
|
||||
audio = -np.real(shifted).astype(np.float32)
|
||||
else:
|
||||
audio = np.real(shifted).astype(np.float32)
|
||||
|
||||
if audio.size < 8:
|
||||
return None
|
||||
|
||||
audio = audio - float(np.mean(audio))
|
||||
|
||||
if mod in ('fm', 'am', 'usb', 'lsb'):
|
||||
taps = int(max(1, min(31, fs1 / 12000.0)))
|
||||
if taps > 1:
|
||||
kernel = np.ones(taps, dtype=np.float32) / float(taps)
|
||||
audio = np.convolve(audio, kernel, mode='same')
|
||||
|
||||
out_len = int(audio.size * AUDIO_SAMPLE_RATE / fs1)
|
||||
if out_len < 32:
|
||||
return None
|
||||
x_old = np.linspace(0.0, 1.0, audio.size, endpoint=False, dtype=np.float32)
|
||||
x_new = np.linspace(0.0, 1.0, out_len, endpoint=False, dtype=np.float32)
|
||||
audio = np.interp(x_new, x_old, audio).astype(np.float32)
|
||||
|
||||
rms = float(np.sqrt(np.mean(audio * audio) + 1e-12))
|
||||
level = min(100.0, rms * 450.0)
|
||||
if squelch > 0 and level < float(squelch):
|
||||
audio.fill(0.0)
|
||||
|
||||
peak = float(np.max(np.abs(audio))) if audio.size else 0.0
|
||||
if peak > 0:
|
||||
audio = audio * min(20.0, 0.85 / peak)
|
||||
|
||||
pcm = np.clip(audio, -1.0, 1.0)
|
||||
return (pcm * 32767.0).astype(np.int16).tobytes()
|
||||
|
||||
|
||||
def _parse_center_freq_mhz(payload: dict[str, Any]) -> float:
|
||||
"""Parse center frequency from mixed legacy/new payload formats."""
|
||||
if payload.get('center_freq_mhz') is not None:
|
||||
return float(payload['center_freq_mhz'])
|
||||
|
||||
if payload.get('center_freq_hz') is not None:
|
||||
return float(payload['center_freq_hz']) / 1e6
|
||||
|
||||
raw = float(payload.get('center_freq', 100.0))
|
||||
# Backward compatibility: some clients still send center_freq in Hz.
|
||||
if raw > 100000:
|
||||
return raw / 1e6
|
||||
return raw
|
||||
|
||||
|
||||
def _parse_span_mhz(payload: dict[str, Any]) -> float:
|
||||
"""Parse display span in MHz from mixed payload formats."""
|
||||
if payload.get('span_hz') is not None:
|
||||
return float(payload['span_hz']) / 1e6
|
||||
return float(payload.get('span_mhz', 2.0))
|
||||
|
||||
|
||||
def _pick_sample_rate(span_hz: int, caps: SDRCapabilities, sdr_type: SDRType) -> int:
|
||||
"""Pick a valid hardware sample rate nearest the requested span."""
|
||||
valid_rates = sorted({int(r) for r in caps.sample_rates if int(r) > 0})
|
||||
if valid_rates:
|
||||
return min(valid_rates, key=lambda rate: abs(rate - span_hz))
|
||||
|
||||
max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000)
|
||||
return max(62500, min(span_hz, max_bw))
|
||||
|
||||
|
||||
def _resolve_sdr_type(sdr_type_str: str) -> SDRType:
|
||||
"""Convert client sdr_type string to SDRType enum."""
|
||||
mapping = {
|
||||
'rtlsdr': SDRType.RTL_SDR,
|
||||
'rtl_sdr': SDRType.RTL_SDR,
|
||||
@@ -83,12 +332,16 @@ def init_waterfall_websocket(app: Flask):
|
||||
# Import app module for device claiming
|
||||
import app as app_module
|
||||
|
||||
iq_process = None
|
||||
reader_thread = None
|
||||
stop_event = threading.Event()
|
||||
claimed_device = None
|
||||
# Queue for outgoing messages — only the main loop touches ws.send()
|
||||
send_queue = queue.Queue(maxsize=120)
|
||||
iq_process = None
|
||||
reader_thread = None
|
||||
stop_event = threading.Event()
|
||||
claimed_device = None
|
||||
capture_center_mhz = 0.0
|
||||
capture_start_freq = 0.0
|
||||
capture_end_freq = 0.0
|
||||
capture_span_mhz = 0.0
|
||||
# Queue for outgoing messages — only the main loop touches ws.send()
|
||||
send_queue = queue.Queue(maxsize=120)
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -105,7 +358,7 @@ def init_waterfall_websocket(app: Flask):
|
||||
break
|
||||
|
||||
try:
|
||||
msg = ws.receive(timeout=0.1)
|
||||
msg = ws.receive(timeout=0.01)
|
||||
except Exception as e:
|
||||
err = str(e).lower()
|
||||
if "closed" in err:
|
||||
@@ -130,257 +383,370 @@ def init_waterfall_websocket(app: Flask):
|
||||
|
||||
cmd = data.get('cmd')
|
||||
|
||||
if cmd == 'start':
|
||||
# Stop any existing capture
|
||||
was_restarting = iq_process is not None
|
||||
stop_event.set()
|
||||
if reader_thread and reader_thread.is_alive():
|
||||
reader_thread.join(timeout=2)
|
||||
if iq_process:
|
||||
if cmd == 'start':
|
||||
# Stop any existing capture
|
||||
was_restarting = iq_process is not None
|
||||
stop_event.set()
|
||||
if reader_thread and reader_thread.is_alive():
|
||||
reader_thread.join(timeout=2)
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device)
|
||||
claimed_device = None
|
||||
_set_shared_capture_state(running=False)
|
||||
stop_event.clear()
|
||||
# Flush stale frames from previous capture
|
||||
while not send_queue.empty():
|
||||
try:
|
||||
send_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
# Allow USB device to be released by the kernel
|
||||
if was_restarting:
|
||||
time.sleep(0.5)
|
||||
|
||||
# Parse config
|
||||
try:
|
||||
center_freq_mhz = _parse_center_freq_mhz(data)
|
||||
span_mhz = _parse_span_mhz(data)
|
||||
gain_raw = data.get('gain')
|
||||
if gain_raw is None or str(gain_raw).lower() == 'auto':
|
||||
gain = None
|
||||
else:
|
||||
gain = float(gain_raw)
|
||||
device_index = int(data.get('device', 0))
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
fft_size = int(data.get('fft_size', 1024))
|
||||
fps = int(data.get('fps', 25))
|
||||
avg_count = int(data.get('avg_count', 4))
|
||||
ppm = data.get('ppm')
|
||||
if ppm is not None:
|
||||
ppm = int(ppm)
|
||||
bias_t = bool(data.get('bias_t', False))
|
||||
db_min = data.get('db_min')
|
||||
db_max = data.get('db_max')
|
||||
if db_min is not None:
|
||||
db_min = float(db_min)
|
||||
if db_max is not None:
|
||||
db_max = float(db_max)
|
||||
except (TypeError, ValueError) as exc:
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Invalid waterfall configuration: {exc}',
|
||||
}))
|
||||
continue
|
||||
|
||||
# Clamp and normalize runtime settings
|
||||
fft_size = max(256, min(8192, fft_size))
|
||||
fps = max(2, min(60, fps))
|
||||
avg_count = max(1, min(32, avg_count))
|
||||
if center_freq_mhz <= 0 or span_mhz <= 0:
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': 'center_freq_mhz and span_mhz must be > 0',
|
||||
}))
|
||||
continue
|
||||
|
||||
# Resolve SDR type and choose a valid sample rate
|
||||
sdr_type = _resolve_sdr_type(sdr_type_str)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
caps = builder.get_capabilities()
|
||||
requested_span_hz = max(1000, int(span_mhz * 1e6))
|
||||
sample_rate = _pick_sample_rate(requested_span_hz, caps, sdr_type)
|
||||
|
||||
# Compute effective frequency range
|
||||
effective_span_mhz = sample_rate / 1e6
|
||||
start_freq = center_freq_mhz - effective_span_mhz / 2
|
||||
end_freq = center_freq_mhz + effective_span_mhz / 2
|
||||
|
||||
# Claim the device
|
||||
claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
|
||||
if claim_err:
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': claim_err,
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
}))
|
||||
continue
|
||||
claimed_device = device_index
|
||||
|
||||
# Build I/Q capture command
|
||||
try:
|
||||
device = _build_dummy_device(device_index, sdr_type)
|
||||
iq_cmd = builder.build_iq_capture_command(
|
||||
device=device,
|
||||
frequency_mhz=center_freq_mhz,
|
||||
sample_rate=sample_rate,
|
||||
gain=gain,
|
||||
ppm=ppm,
|
||||
bias_t=bias_t,
|
||||
)
|
||||
except NotImplementedError as e:
|
||||
app_module.release_sdr_device(device_index)
|
||||
claimed_device = None
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': str(e),
|
||||
}))
|
||||
continue
|
||||
|
||||
# Spawn I/Q capture process (retry to handle USB release lag)
|
||||
max_attempts = 3 if was_restarting else 1
|
||||
try:
|
||||
for attempt in range(max_attempts):
|
||||
logger.info(
|
||||
f"Starting I/Q capture: {center_freq_mhz:.6f} MHz, "
|
||||
f"span={effective_span_mhz:.1f} MHz, "
|
||||
f"sr={sample_rate}, fft={fft_size}"
|
||||
)
|
||||
iq_process = subprocess.Popen(
|
||||
iq_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
bufsize=0,
|
||||
)
|
||||
register_process(iq_process)
|
||||
|
||||
# Brief check that process started
|
||||
time.sleep(0.3)
|
||||
if iq_process.poll() is not None:
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if attempt < max_attempts - 1:
|
||||
logger.info(
|
||||
f"I/Q process exited immediately, "
|
||||
f"retrying ({attempt + 1}/{max_attempts})..."
|
||||
)
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
raise RuntimeError(
|
||||
"I/Q capture process exited immediately"
|
||||
)
|
||||
break # Process started successfully
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start I/Q capture: {e}")
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
app_module.release_sdr_device(device_index)
|
||||
claimed_device = None
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Failed to start I/Q capture: {e}',
|
||||
}))
|
||||
continue
|
||||
|
||||
capture_center_mhz = center_freq_mhz
|
||||
capture_start_freq = start_freq
|
||||
capture_end_freq = end_freq
|
||||
capture_span_mhz = effective_span_mhz
|
||||
|
||||
_set_shared_capture_state(
|
||||
running=True,
|
||||
device=device_index,
|
||||
center_mhz=center_freq_mhz,
|
||||
span_mhz=effective_span_mhz,
|
||||
sample_rate=sample_rate,
|
||||
)
|
||||
_set_shared_monitor(
|
||||
enabled=False,
|
||||
frequency_mhz=center_freq_mhz,
|
||||
modulation='wfm',
|
||||
squelch=0,
|
||||
)
|
||||
|
||||
# Send started confirmation
|
||||
ws.send(json.dumps({
|
||||
'status': 'started',
|
||||
'center_mhz': center_freq_mhz,
|
||||
'start_freq': start_freq,
|
||||
'end_freq': end_freq,
|
||||
'fft_size': fft_size,
|
||||
'sample_rate': sample_rate,
|
||||
'effective_span_mhz': effective_span_mhz,
|
||||
'db_min': db_min,
|
||||
'db_max': db_max,
|
||||
'vfo_freq_mhz': center_freq_mhz,
|
||||
}))
|
||||
|
||||
# Start reader thread — puts frames on queue, never calls ws.send()
|
||||
def fft_reader(
|
||||
proc, _send_q, stop_evt,
|
||||
_fft_size, _avg_count, _fps, _sample_rate,
|
||||
_start_freq, _end_freq, _center_mhz,
|
||||
_db_min=None, _db_max=None,
|
||||
):
|
||||
"""Read I/Q from subprocess, compute FFT, enqueue binary frames."""
|
||||
required_fft_samples = _fft_size * _avg_count
|
||||
timeslice_samples = max(required_fft_samples, int(_sample_rate / max(1, _fps)))
|
||||
bytes_per_frame = timeslice_samples * 2
|
||||
frame_interval = 1.0 / _fps
|
||||
|
||||
try:
|
||||
while not stop_evt.is_set():
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
|
||||
frame_start = time.monotonic()
|
||||
|
||||
# Read raw I/Q bytes
|
||||
raw = b''
|
||||
remaining = bytes_per_frame
|
||||
while remaining > 0 and not stop_evt.is_set():
|
||||
chunk = proc.stdout.read(min(remaining, 65536))
|
||||
if not chunk:
|
||||
break
|
||||
raw += chunk
|
||||
remaining -= len(chunk)
|
||||
|
||||
if len(raw) < _fft_size * 2:
|
||||
break
|
||||
|
||||
# Process FFT pipeline
|
||||
samples = cu8_to_complex(raw)
|
||||
fft_samples = samples[-required_fft_samples:] if len(samples) > required_fft_samples else samples
|
||||
power_db = compute_power_spectrum(
|
||||
fft_samples,
|
||||
fft_size=_fft_size,
|
||||
avg_count=_avg_count,
|
||||
)
|
||||
quantized = quantize_to_uint8(
|
||||
power_db,
|
||||
db_min=_db_min,
|
||||
db_max=_db_max,
|
||||
)
|
||||
frame = build_binary_frame(
|
||||
_start_freq, _end_freq, quantized,
|
||||
)
|
||||
|
||||
# Drop frame if main loop cannot keep up.
|
||||
with suppress(queue.Full):
|
||||
_send_q.put_nowait(frame)
|
||||
|
||||
monitor_cfg = _snapshot_monitor_config()
|
||||
if monitor_cfg:
|
||||
audio_chunk = _demodulate_monitor_audio(
|
||||
samples=samples,
|
||||
sample_rate=_sample_rate,
|
||||
center_mhz=monitor_cfg.get('center_mhz', _center_mhz),
|
||||
monitor_freq_mhz=monitor_cfg.get('monitor_freq_mhz', _center_mhz),
|
||||
modulation=monitor_cfg.get('modulation', 'wfm'),
|
||||
squelch=int(monitor_cfg.get('squelch', 0)),
|
||||
)
|
||||
if audio_chunk:
|
||||
_push_shared_audio_chunk(audio_chunk)
|
||||
|
||||
# Pace to target FPS
|
||||
elapsed = time.monotonic() - frame_start
|
||||
sleep_time = frame_interval - elapsed
|
||||
if sleep_time > 0:
|
||||
stop_evt.wait(sleep_time)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"FFT reader stopped: {e}")
|
||||
|
||||
reader_thread = threading.Thread(
|
||||
target=fft_reader,
|
||||
args=(
|
||||
iq_process, send_queue, stop_event,
|
||||
fft_size, avg_count, fps, sample_rate,
|
||||
start_freq, end_freq, center_freq_mhz,
|
||||
db_min, db_max,
|
||||
),
|
||||
daemon=True,
|
||||
)
|
||||
reader_thread.start()
|
||||
|
||||
elif cmd in ('tune', 'set_vfo'):
|
||||
if not iq_process or claimed_device is None or iq_process.poll() is not None:
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': 'Waterfall capture is not running',
|
||||
}))
|
||||
continue
|
||||
try:
|
||||
shared = get_shared_capture_status()
|
||||
vfo_freq_mhz = float(
|
||||
data.get(
|
||||
'vfo_freq_mhz',
|
||||
data.get('frequency_mhz', data.get('center_freq_mhz', capture_center_mhz)),
|
||||
)
|
||||
)
|
||||
squelch = int(data.get('squelch', shared.get('monitor_squelch', 0)))
|
||||
modulation = str(data.get('modulation', shared.get('monitor_modulation', 'wfm')))
|
||||
except (TypeError, ValueError) as exc:
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Invalid tune request: {exc}',
|
||||
}))
|
||||
continue
|
||||
|
||||
if not (capture_start_freq <= vfo_freq_mhz <= capture_end_freq):
|
||||
ws.send(json.dumps({
|
||||
'status': 'retune_required',
|
||||
'message': 'Frequency outside current capture span',
|
||||
'capture_start_freq': capture_start_freq,
|
||||
'capture_end_freq': capture_end_freq,
|
||||
'vfo_freq_mhz': vfo_freq_mhz,
|
||||
}))
|
||||
continue
|
||||
|
||||
monitor_enabled = bool(shared.get('monitor_enabled'))
|
||||
_set_shared_monitor(
|
||||
enabled=monitor_enabled,
|
||||
frequency_mhz=vfo_freq_mhz,
|
||||
modulation=modulation,
|
||||
squelch=squelch,
|
||||
)
|
||||
ws.send(json.dumps({
|
||||
'status': 'tuned',
|
||||
'vfo_freq_mhz': vfo_freq_mhz,
|
||||
'start_freq': capture_start_freq,
|
||||
'end_freq': capture_end_freq,
|
||||
'center_mhz': capture_center_mhz,
|
||||
}))
|
||||
|
||||
elif cmd == 'stop':
|
||||
stop_event.set()
|
||||
if reader_thread and reader_thread.is_alive():
|
||||
reader_thread.join(timeout=2)
|
||||
reader_thread = None
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device)
|
||||
claimed_device = None
|
||||
stop_event.clear()
|
||||
# Flush stale frames from previous capture
|
||||
while not send_queue.empty():
|
||||
try:
|
||||
send_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
# Allow USB device to be released by the kernel
|
||||
if was_restarting:
|
||||
time.sleep(0.5)
|
||||
|
||||
# Parse config
|
||||
center_freq = float(data.get('center_freq', 100.0))
|
||||
span_mhz = float(data.get('span_mhz', 2.0))
|
||||
gain = data.get('gain')
|
||||
if gain is not None:
|
||||
gain = float(gain)
|
||||
device_index = int(data.get('device', 0))
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
fft_size = int(data.get('fft_size', 1024))
|
||||
fps = int(data.get('fps', 25))
|
||||
avg_count = int(data.get('avg_count', 4))
|
||||
ppm = data.get('ppm')
|
||||
if ppm is not None:
|
||||
ppm = int(ppm)
|
||||
bias_t = bool(data.get('bias_t', False))
|
||||
|
||||
# Clamp FFT size to valid powers of 2
|
||||
fft_size = max(256, min(8192, fft_size))
|
||||
|
||||
# Resolve SDR type and bandwidth
|
||||
sdr_type = _resolve_sdr_type(sdr_type_str)
|
||||
max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000)
|
||||
span_hz = int(span_mhz * 1e6)
|
||||
sample_rate = min(span_hz, max_bw)
|
||||
|
||||
# Compute effective frequency range
|
||||
effective_span_mhz = sample_rate / 1e6
|
||||
start_freq = center_freq - effective_span_mhz / 2
|
||||
end_freq = center_freq + effective_span_mhz / 2
|
||||
|
||||
# Claim the device
|
||||
claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
|
||||
if claim_err:
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': claim_err,
|
||||
'error_type': 'DEVICE_BUSY',
|
||||
}))
|
||||
continue
|
||||
claimed_device = device_index
|
||||
|
||||
# Build I/Q capture command
|
||||
try:
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
device = _build_dummy_device(device_index, sdr_type)
|
||||
iq_cmd = builder.build_iq_capture_command(
|
||||
device=device,
|
||||
frequency_mhz=center_freq,
|
||||
sample_rate=sample_rate,
|
||||
gain=gain,
|
||||
ppm=ppm,
|
||||
bias_t=bias_t,
|
||||
)
|
||||
except NotImplementedError as e:
|
||||
app_module.release_sdr_device(device_index)
|
||||
claimed_device = None
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': str(e),
|
||||
}))
|
||||
continue
|
||||
|
||||
# Spawn I/Q capture process (retry to handle USB release lag)
|
||||
max_attempts = 3 if was_restarting else 1
|
||||
try:
|
||||
for attempt in range(max_attempts):
|
||||
logger.info(
|
||||
f"Starting I/Q capture: {center_freq} MHz, "
|
||||
f"span={effective_span_mhz:.1f} MHz, "
|
||||
f"sr={sample_rate}, fft={fft_size}"
|
||||
)
|
||||
iq_process = subprocess.Popen(
|
||||
iq_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
bufsize=0,
|
||||
)
|
||||
register_process(iq_process)
|
||||
|
||||
# Brief check that process started
|
||||
time.sleep(0.3)
|
||||
if iq_process.poll() is not None:
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if attempt < max_attempts - 1:
|
||||
logger.info(
|
||||
f"I/Q process exited immediately, "
|
||||
f"retrying ({attempt + 1}/{max_attempts})..."
|
||||
)
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
raise RuntimeError(
|
||||
"I/Q capture process exited immediately"
|
||||
)
|
||||
break # Process started successfully
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start I/Q capture: {e}")
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
app_module.release_sdr_device(device_index)
|
||||
claimed_device = None
|
||||
ws.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Failed to start I/Q capture: {e}',
|
||||
}))
|
||||
continue
|
||||
|
||||
# Send started confirmation
|
||||
ws.send(json.dumps({
|
||||
'status': 'started',
|
||||
'start_freq': start_freq,
|
||||
'end_freq': end_freq,
|
||||
'fft_size': fft_size,
|
||||
'sample_rate': sample_rate,
|
||||
}))
|
||||
|
||||
# Start reader thread — puts frames on queue, never calls ws.send()
|
||||
def fft_reader(
|
||||
proc, _send_q, stop_evt,
|
||||
_fft_size, _avg_count, _fps,
|
||||
_start_freq, _end_freq,
|
||||
):
|
||||
"""Read I/Q from subprocess, compute FFT, enqueue binary frames."""
|
||||
bytes_per_frame = _fft_size * _avg_count * 2
|
||||
frame_interval = 1.0 / _fps
|
||||
|
||||
try:
|
||||
while not stop_evt.is_set():
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
|
||||
frame_start = time.monotonic()
|
||||
|
||||
# Read raw I/Q bytes
|
||||
raw = b''
|
||||
remaining = bytes_per_frame
|
||||
while remaining > 0 and not stop_evt.is_set():
|
||||
chunk = proc.stdout.read(min(remaining, 65536))
|
||||
if not chunk:
|
||||
break
|
||||
raw += chunk
|
||||
remaining -= len(chunk)
|
||||
|
||||
if len(raw) < _fft_size * 2:
|
||||
break
|
||||
|
||||
# Process FFT pipeline
|
||||
samples = cu8_to_complex(raw)
|
||||
power_db = compute_power_spectrum(
|
||||
samples,
|
||||
fft_size=_fft_size,
|
||||
avg_count=_avg_count,
|
||||
)
|
||||
quantized = quantize_to_uint8(power_db)
|
||||
frame = build_binary_frame(
|
||||
_start_freq, _end_freq, quantized,
|
||||
)
|
||||
|
||||
try:
|
||||
_send_q.put_nowait(frame)
|
||||
except queue.Full:
|
||||
# Drop frame if main loop can't keep up
|
||||
pass
|
||||
|
||||
# Pace to target FPS
|
||||
elapsed = time.monotonic() - frame_start
|
||||
sleep_time = frame_interval - elapsed
|
||||
if sleep_time > 0:
|
||||
stop_evt.wait(sleep_time)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"FFT reader stopped: {e}")
|
||||
|
||||
reader_thread = threading.Thread(
|
||||
target=fft_reader,
|
||||
args=(
|
||||
iq_process, send_queue, stop_event,
|
||||
fft_size, avg_count, fps,
|
||||
start_freq, end_freq,
|
||||
),
|
||||
daemon=True,
|
||||
)
|
||||
reader_thread.start()
|
||||
|
||||
elif cmd == 'stop':
|
||||
stop_event.set()
|
||||
if reader_thread and reader_thread.is_alive():
|
||||
reader_thread.join(timeout=2)
|
||||
reader_thread = None
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
iq_process = None
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device)
|
||||
claimed_device = None
|
||||
stop_event.clear()
|
||||
ws.send(json.dumps({'status': 'stopped'}))
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device)
|
||||
claimed_device = None
|
||||
_set_shared_capture_state(running=False)
|
||||
stop_event.clear()
|
||||
ws.send(json.dumps({'status': 'stopped'}))
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"WebSocket waterfall closed: {e}")
|
||||
finally:
|
||||
# Cleanup
|
||||
stop_event.set()
|
||||
if reader_thread and reader_thread.is_alive():
|
||||
reader_thread.join(timeout=2)
|
||||
finally:
|
||||
# Cleanup
|
||||
stop_event.set()
|
||||
if reader_thread and reader_thread.is_alive():
|
||||
reader_thread.join(timeout=2)
|
||||
if iq_process:
|
||||
safe_terminate(iq_process)
|
||||
unregister_process(iq_process)
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device)
|
||||
# Complete WebSocket close handshake, then shut down the
|
||||
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||
if claimed_device is not None:
|
||||
app_module.release_sdr_device(claimed_device)
|
||||
_set_shared_capture_state(running=False)
|
||||
# Complete WebSocket close handshake, then shut down the
|
||||
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||
# on top of the WebSocket stream (which browsers see as
|
||||
# "Invalid frame header").
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ws.sock.shutdown(socket.SHUT_RDWR)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ws.sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("WebSocket waterfall client disconnected")
|
||||
with suppress(Exception):
|
||||
ws.close()
|
||||
with suppress(Exception):
|
||||
ws.sock.shutdown(socket.SHUT_RDWR)
|
||||
with suppress(Exception):
|
||||
ws.sock.close()
|
||||
logger.info("WebSocket waterfall client disconnected")
|
||||
|
||||
@@ -563,19 +563,26 @@ def enable_schedule():
|
||||
'message': 'Invalid parameter value'
|
||||
}), 400
|
||||
|
||||
scheduler = get_weather_sat_scheduler()
|
||||
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
||||
|
||||
result = scheduler.enable(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
min_elevation=min_elev,
|
||||
device=device,
|
||||
gain=gain_val,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
|
||||
return jsonify({'status': 'ok', **result})
|
||||
scheduler = get_weather_sat_scheduler()
|
||||
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
||||
|
||||
try:
|
||||
result = scheduler.enable(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
min_elevation=min_elev,
|
||||
device=device,
|
||||
gain=gain_val,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to enable weather sat scheduler")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to enable scheduler'
|
||||
}), 500
|
||||
|
||||
return jsonify({'status': 'ok', **result})
|
||||
|
||||
|
||||
@weather_sat_bp.route('/schedule/disable', methods=['POST'])
|
||||
|
||||
@@ -20,7 +20,7 @@ from utils.dependencies import check_tool, get_tool_path
|
||||
from utils.logging import wifi_logger as logger
|
||||
from utils.process import is_valid_mac, is_valid_channel
|
||||
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
||||
from utils.sse import format_sse
|
||||
from utils.sse import format_sse, sse_stream_fanout
|
||||
from utils.event_pipeline import process_event
|
||||
from data.oui import get_manufacturer
|
||||
from utils.constants import (
|
||||
@@ -1132,33 +1132,26 @@ def get_wifi_networks():
|
||||
})
|
||||
|
||||
|
||||
@wifi_bp.route('/stream')
|
||||
def stream_wifi():
|
||||
"""SSE stream for WiFi events."""
|
||||
def generate():
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.wifi_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
try:
|
||||
process_event('wifi', msg, msg.get('type'))
|
||||
except Exception:
|
||||
pass
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
@wifi_bp.route('/stream')
|
||||
def stream_wifi():
|
||||
"""SSE stream for WiFi events."""
|
||||
def _on_msg(msg: dict[str, Any]) -> None:
|
||||
process_event('wifi', msg, msg.get('type'))
|
||||
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.wifi_queue,
|
||||
channel_key='wifi',
|
||||
timeout=1.0,
|
||||
keepalive_interval=30.0,
|
||||
on_message=_on_msg,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -1545,8 +1538,8 @@ def v2_deauth_status():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/deauth/stream')
|
||||
def v2_deauth_stream():
|
||||
@wifi_bp.route('/v2/deauth/stream')
|
||||
def v2_deauth_stream():
|
||||
"""
|
||||
SSE stream for real-time deauth alerts.
|
||||
|
||||
@@ -1557,26 +1550,18 @@ def v2_deauth_stream():
|
||||
- deauth_error: An error occurred
|
||||
- keepalive: Periodic keepalive
|
||||
"""
|
||||
def generate():
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = SSE_KEEPALIVE_INTERVAL
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Try to get from the dedicated deauth queue
|
||||
msg = app_module.deauth_detector_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
response = Response(
|
||||
sse_stream_fanout(
|
||||
source_queue=app_module.deauth_detector_queue,
|
||||
channel_key='wifi_deauth',
|
||||
timeout=SSE_QUEUE_TIMEOUT,
|
||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||
),
|
||||
mimetype='text/event-stream',
|
||||
)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@@ -226,16 +226,13 @@ check_tools() {
|
||||
check_optional "hackrf_sweep" "HackRF spectrum analyzer" hackrf_sweep
|
||||
check_required "dump1090" "ADS-B decoder" dump1090
|
||||
check_required "acarsdec" "ACARS decoder" acarsdec
|
||||
check_optional "dumpvdl2" "VDL2 decoder" dumpvdl2
|
||||
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
||||
check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
|
||||
echo
|
||||
info "GPS:"
|
||||
check_required "gpsd" "GPS daemon" gpsd
|
||||
|
||||
echo
|
||||
info "Digital Voice:"
|
||||
check_optional "dsd" "Digital Speech Decoder (DMR/P25)" dsd dsd-fme
|
||||
|
||||
echo
|
||||
info "Audio:"
|
||||
check_required "ffmpeg" "Audio encoder/decoder" ffmpeg
|
||||
@@ -312,28 +309,40 @@ install_python_deps() {
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source venv/bin/activate
|
||||
local PIP="venv/bin/python -m pip"
|
||||
local PY="venv/bin/python"
|
||||
|
||||
python -m pip install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
|
||||
$PIP install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
|
||||
ok "Upgraded pip tooling"
|
||||
|
||||
progress "Installing Python dependencies"
|
||||
# Try pip install, but don't fail if apt packages already satisfied deps
|
||||
if ! python -m pip install -r requirements.txt 2>/dev/null; then
|
||||
warn "Some pip packages failed - checking if apt packages cover them..."
|
||||
# Verify critical packages are available
|
||||
python -c "import flask; import requests; from flask_limiter import Limiter" 2>/dev/null || {
|
||||
fail "Critical Python packages (flask, requests, flask-limiter) not installed"
|
||||
echo "Try: pip install flask requests flask-limiter"
|
||||
exit 1
|
||||
}
|
||||
ok "Core Python dependencies available"
|
||||
else
|
||||
ok "Python dependencies installed"
|
||||
fi
|
||||
|
||||
# Ensure Flask 3.0+ is installed (required for Werkzeug 3.x compatibility)
|
||||
# System apt packages may have older Flask 2.x which is incompatible
|
||||
python -m pip install --upgrade "flask>=3.0.0" >/dev/null 2>&1 || true
|
||||
# Install critical packages first to avoid all-or-nothing failures
|
||||
# (C extension packages like scipy/numpy can fail on newer Python versions
|
||||
# and cause pip to roll back pure-Python packages like flask)
|
||||
info "Installing core packages..."
|
||||
$PIP install --quiet "flask>=3.0.0" "flask-limiter>=2.5.4" "requests>=2.28.0" \
|
||||
"Werkzeug>=3.1.5" "pyserial>=3.5" "flask-sock" "websocket-client>=1.6.0" 2>/dev/null || true
|
||||
|
||||
# Verify critical packages
|
||||
$PY -c "import flask; import requests; from flask_limiter import Limiter" 2>/dev/null || {
|
||||
fail "Critical Python packages (flask, requests, flask-limiter) not installed"
|
||||
echo "Try: venv/bin/pip install flask requests flask-limiter"
|
||||
exit 1
|
||||
}
|
||||
ok "Core Python packages installed"
|
||||
|
||||
# Install optional packages individually (some may fail on newer Python)
|
||||
info "Installing optional packages..."
|
||||
for pkg in "numpy>=1.24.0" "scipy>=1.10.0" "Pillow>=9.0.0" "skyfield>=1.45" \
|
||||
"bleak>=0.21.0" "psycopg2-binary>=2.9.9" "meshtastic>=2.0.0" \
|
||||
"scapy>=2.4.5" "qrcode[pil]>=7.4" "cryptography>=41.0.0"; do
|
||||
pkg_name="${pkg%%>=*}"
|
||||
if ! $PIP install "$pkg" 2>/dev/null; then
|
||||
warn "${pkg_name} failed to install (optional - related features may be unavailable)"
|
||||
fi
|
||||
done
|
||||
ok "Optional packages processed"
|
||||
echo
|
||||
}
|
||||
|
||||
@@ -445,95 +454,6 @@ install_multimon_ng_from_source_macos() {
|
||||
)
|
||||
}
|
||||
|
||||
install_dsd_from_source() {
|
||||
info "Building DSD (Digital Speech Decoder) from source..."
|
||||
info "This requires mbelib (vocoder library) as a prerequisite."
|
||||
|
||||
if [[ "$OS" == "macos" ]]; then
|
||||
brew_install cmake
|
||||
brew_install libsndfile
|
||||
brew_install ncurses
|
||||
brew_install fftw
|
||||
brew_install codec2
|
||||
brew_install librtlsdr
|
||||
brew_install pulseaudio || true
|
||||
else
|
||||
apt_install build-essential git cmake libsndfile1-dev libpulse-dev \
|
||||
libfftw3-dev liblapack-dev libncurses-dev librtlsdr-dev libcodec2-dev
|
||||
fi
|
||||
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
# Step 1: Build and install mbelib (required dependency)
|
||||
info "Building mbelib (vocoder library)..."
|
||||
git clone https://github.com/lwvmobile/mbelib.git "$tmp_dir/mbelib" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone mbelib"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/mbelib"
|
||||
git checkout ambe_tones >/dev/null 2>&1 || true
|
||||
mkdir -p build && cd build
|
||||
|
||||
if cmake .. >/dev/null 2>&1 && make -j "$(nproc 2>/dev/null || sysctl -n hw.ncpu)" >/dev/null 2>&1; then
|
||||
if [[ "$OS" == "macos" ]]; then
|
||||
if [[ -w /usr/local/lib ]]; then
|
||||
make install >/dev/null 2>&1
|
||||
else
|
||||
refresh_sudo
|
||||
$SUDO make install >/dev/null 2>&1
|
||||
fi
|
||||
else
|
||||
$SUDO make install >/dev/null 2>&1
|
||||
$SUDO ldconfig 2>/dev/null || true
|
||||
fi
|
||||
ok "mbelib installed"
|
||||
else
|
||||
warn "Failed to build mbelib. Cannot build DSD without it."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 2: Build dsd-fme (or fall back to original dsd)
|
||||
info "Building dsd-fme..."
|
||||
git clone --depth 1 https://github.com/lwvmobile/dsd-fme.git "$tmp_dir/dsd-fme" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone dsd-fme, trying original DSD...";
|
||||
git clone --depth 1 https://github.com/szechyjs/dsd.git "$tmp_dir/dsd-fme" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone DSD"; exit 1; }; }
|
||||
|
||||
cd "$tmp_dir/dsd-fme"
|
||||
mkdir -p build && cd build
|
||||
|
||||
# On macOS, help cmake find Homebrew ncurses
|
||||
local cmake_flags=""
|
||||
if [[ "$OS" == "macos" ]]; then
|
||||
local ncurses_prefix
|
||||
ncurses_prefix="$(brew --prefix ncurses 2>/dev/null || echo /opt/homebrew/opt/ncurses)"
|
||||
cmake_flags="-DCMAKE_PREFIX_PATH=$ncurses_prefix"
|
||||
fi
|
||||
|
||||
info "Compiling DSD..."
|
||||
if cmake .. $cmake_flags >/dev/null 2>&1 && make -j "$(nproc 2>/dev/null || sysctl -n hw.ncpu)" >/dev/null 2>&1; then
|
||||
if [[ "$OS" == "macos" ]]; then
|
||||
if [[ -w /usr/local/bin ]]; then
|
||||
install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
|
||||
else
|
||||
refresh_sudo
|
||||
$SUDO install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || $SUDO install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
$SUDO make install >/dev/null 2>&1 \
|
||||
|| $SUDO install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null \
|
||||
|| $SUDO install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null \
|
||||
|| true
|
||||
$SUDO ldconfig 2>/dev/null || true
|
||||
fi
|
||||
ok "DSD installed successfully"
|
||||
else
|
||||
warn "Failed to build DSD from source. DMR/P25 decoding will not be available."
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
install_dump1090_from_source_macos() {
|
||||
info "dump1090 not available via Homebrew. Building from source..."
|
||||
|
||||
@@ -616,6 +536,7 @@ install_acarsdec_from_source_macos() {
|
||||
info "Compiling acarsdec..."
|
||||
build_log="$tmp_dir/acarsdec-build.log"
|
||||
if cmake .. -Drtl=ON \
|
||||
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
|
||||
-DCMAKE_C_FLAGS="-I${HOMEBREW_PREFIX}/include" \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-L${HOMEBREW_PREFIX}/lib" \
|
||||
>"$build_log" 2>&1 \
|
||||
@@ -635,6 +556,80 @@ install_acarsdec_from_source_macos() {
|
||||
)
|
||||
}
|
||||
|
||||
install_dumpvdl2_from_source_macos() {
|
||||
info "Building dumpvdl2 from source (with libacars dependency)..."
|
||||
|
||||
brew_install cmake
|
||||
brew_install librtlsdr
|
||||
brew_install pkg-config
|
||||
brew_install glib
|
||||
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
HOMEBREW_PREFIX="$(brew --prefix)"
|
||||
export PKG_CONFIG_PATH="${HOMEBREW_PREFIX}/lib/pkgconfig:${PKG_CONFIG_PATH:-}"
|
||||
export CMAKE_PREFIX_PATH="${HOMEBREW_PREFIX}"
|
||||
|
||||
# Build libacars first
|
||||
info "Cloning libacars..."
|
||||
git clone --depth 1 https://github.com/szpajder/libacars.git "$tmp_dir/libacars" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone libacars"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/libacars"
|
||||
mkdir -p build && cd build
|
||||
|
||||
info "Compiling libacars..."
|
||||
build_log="$tmp_dir/libacars-build.log"
|
||||
if cmake .. \
|
||||
-DCMAKE_C_FLAGS="-I${HOMEBREW_PREFIX}/include" \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-L${HOMEBREW_PREFIX}/lib" \
|
||||
>"$build_log" 2>&1 \
|
||||
&& make >>"$build_log" 2>&1; then
|
||||
if [[ -w /usr/local/lib ]]; then
|
||||
make install >>"$build_log" 2>&1
|
||||
else
|
||||
refresh_sudo
|
||||
$SUDO make install >>"$build_log" 2>&1
|
||||
fi
|
||||
ok "libacars installed"
|
||||
else
|
||||
warn "Failed to build libacars."
|
||||
tail -20 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build dumpvdl2
|
||||
info "Cloning dumpvdl2..."
|
||||
git clone --depth 1 https://github.com/szpajder/dumpvdl2.git "$tmp_dir/dumpvdl2" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone dumpvdl2"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/dumpvdl2"
|
||||
mkdir -p build && cd build
|
||||
|
||||
info "Compiling dumpvdl2..."
|
||||
build_log="$tmp_dir/dumpvdl2-build.log"
|
||||
if cmake .. \
|
||||
-DCMAKE_C_FLAGS="-I${HOMEBREW_PREFIX}/include" \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-L${HOMEBREW_PREFIX}/lib" \
|
||||
>"$build_log" 2>&1 \
|
||||
&& make >>"$build_log" 2>&1; then
|
||||
if [[ -w /usr/local/bin ]]; then
|
||||
install -m 0755 src/dumpvdl2 /usr/local/bin/dumpvdl2
|
||||
else
|
||||
refresh_sudo
|
||||
$SUDO install -m 0755 src/dumpvdl2 /usr/local/bin/dumpvdl2
|
||||
fi
|
||||
ok "dumpvdl2 installed successfully from source"
|
||||
else
|
||||
warn "Failed to build dumpvdl2. VDL2 decoding will not be available."
|
||||
warn "Build log (last 30 lines):"
|
||||
tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
install_aiscatcher_from_source_macos() {
|
||||
info "AIS-catcher not available via Homebrew. Building from source..."
|
||||
|
||||
@@ -672,11 +667,23 @@ install_aiscatcher_from_source_macos() {
|
||||
install_satdump_from_source_debian() {
|
||||
info "Building SatDump v1.2.2 from source (weather satellite decoder)..."
|
||||
|
||||
# Core deps — hard-fail if missing
|
||||
apt_install build-essential git cmake pkg-config \
|
||||
libpng-dev libtiff-dev libjemalloc-dev libvolk-dev libnng-dev \
|
||||
libzstd-dev libsoapysdr-dev libhackrf-dev liblimesuite-dev \
|
||||
libpng-dev libtiff-dev libzstd-dev \
|
||||
libsqlite3-dev libcurl4-openssl-dev zlib1g-dev libzmq3-dev libfftw3-dev
|
||||
|
||||
# libvolk: package name differs between distros
|
||||
# Ubuntu / Debian Trixie+: libvolk-dev
|
||||
# Raspberry Pi OS Bookworm / Debian Bookworm: libvolk2-dev
|
||||
apt_try_install_any libvolk-dev libvolk2-dev \
|
||||
|| warn "libvolk not found — SatDump will build without VOLK acceleration"
|
||||
|
||||
# Optional SDR hardware libs — soft-fail so missing hardware doesn't abort
|
||||
for pkg in libjemalloc-dev libnng-dev libsoapysdr-dev libhackrf-dev liblimesuite-dev; do
|
||||
$SUDO apt-get install -y --no-install-recommends "$pkg" >/dev/null 2>&1 \
|
||||
|| warn "${pkg} not available — skipping (SatDump can build without it)"
|
||||
done
|
||||
|
||||
# Run in subshell to isolate EXIT trap
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
@@ -687,6 +694,22 @@ install_satdump_from_source_debian() {
|
||||
|| { warn "Failed to clone SatDump"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/SatDump"
|
||||
|
||||
# Patch: fix deprecated std::allocator usage for newer compilers
|
||||
# GCC 13+ errors on deprecated allocator members in sol2.
|
||||
# Pragmas must go in lua_utils.cpp (the instantiation site), not sol.hpp (definition site).
|
||||
lua_utils="src-core/common/lua/lua_utils.cpp"
|
||||
if [ -f "$lua_utils" ]; then
|
||||
{
|
||||
echo '#pragma GCC diagnostic push'
|
||||
echo '#pragma GCC diagnostic ignored "-Wdeprecated"'
|
||||
echo '#pragma GCC diagnostic ignored "-Wdeprecated-declarations"'
|
||||
cat "$lua_utils"
|
||||
echo # ensure the file ends with a newline before the closing pragma
|
||||
echo '#pragma GCC diagnostic pop'
|
||||
} > "${lua_utils}.patched" && mv "${lua_utils}.patched" "$lua_utils"
|
||||
fi
|
||||
|
||||
mkdir -p build && cd build
|
||||
|
||||
info "Compiling SatDump (this is a large C++ project and may take 10-30 minutes)..."
|
||||
@@ -731,62 +754,64 @@ install_satdump_from_source_debian() {
|
||||
)
|
||||
}
|
||||
|
||||
install_satdump_from_source_macos() {
|
||||
info "Building SatDump v1.2.2 from source (weather satellite decoder)..."
|
||||
install_satdump_macos() {
|
||||
info "Installing SatDump v1.2.2 from pre-built release (weather satellite decoder)..."
|
||||
|
||||
brew_install cmake
|
||||
brew_install libpng
|
||||
brew_install libtiff
|
||||
brew_install jemalloc
|
||||
brew_install libvolk
|
||||
brew_install nng
|
||||
brew_install zstd
|
||||
brew_install soapysdr
|
||||
brew_install hackrf
|
||||
brew_install fftw
|
||||
# Determine architecture
|
||||
local arch
|
||||
arch="$(uname -m)"
|
||||
local dmg_name
|
||||
if [ "$arch" = "arm64" ]; then
|
||||
dmg_name="SatDump-macOS-Silicon.dmg"
|
||||
else
|
||||
dmg_name="SatDump-macOS-Intel.dmg"
|
||||
fi
|
||||
|
||||
local dmg_url="https://github.com/SatDump/SatDump/releases/download/1.2.2/${dmg_name}"
|
||||
local install_dir="/usr/local/lib/satdump"
|
||||
|
||||
# Run in subshell to isolate EXIT trap
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
trap 'hdiutil detach "$tmp_dir/mnt" -quiet 2>/dev/null || true; rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
info "Cloning SatDump v1.2.2..."
|
||||
git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git "$tmp_dir/SatDump" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone SatDump"; exit 1; }
|
||||
info "Downloading ${dmg_name}..."
|
||||
if ! curl -sL -o "$tmp_dir/satdump.dmg" "$dmg_url"; then
|
||||
warn "Failed to download SatDump. Weather satellite decoding will not be available."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$tmp_dir/SatDump"
|
||||
mkdir -p build && cd build
|
||||
info "Installing SatDump..."
|
||||
# Mount the DMG
|
||||
hdiutil attach "$tmp_dir/satdump.dmg" -nobrowse -quiet -mountpoint "$tmp_dir/mnt" \
|
||||
|| { warn "Failed to mount SatDump DMG"; exit 1; }
|
||||
|
||||
info "Compiling SatDump (this is a large C++ project and may take 10-30 minutes)..."
|
||||
build_log="$tmp_dir/satdump-build.log"
|
||||
local app_dir="$tmp_dir/mnt/SatDump.app"
|
||||
if [ ! -d "$app_dir" ]; then
|
||||
warn "SatDump.app not found in DMG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Show periodic progress while building so the user knows it's not hung
|
||||
(
|
||||
while true; do
|
||||
sleep 30
|
||||
if [ -f "$build_log" ]; then
|
||||
local_lines=$(wc -l < "$build_log" 2>/dev/null || echo 0)
|
||||
printf " [*] Still compiling SatDump... (%s lines of build output so far)\n" "$local_lines"
|
||||
fi
|
||||
done
|
||||
) &
|
||||
progress_pid=$!
|
||||
# Install: copy app contents to /usr/local/lib/satdump
|
||||
refresh_sudo
|
||||
$SUDO mkdir -p "$install_dir"
|
||||
$SUDO cp -R "$app_dir/Contents/MacOS/"* "$install_dir/"
|
||||
$SUDO cp -R "$app_dir/Contents/Resources/"* "$install_dir/"
|
||||
|
||||
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF .. >"$build_log" 2>&1 \
|
||||
&& make -j "$(sysctl -n hw.ncpu)" >>"$build_log" 2>&1; then
|
||||
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
|
||||
if [[ -w /usr/local/bin ]]; then
|
||||
make install >/dev/null 2>&1
|
||||
else
|
||||
refresh_sudo
|
||||
$SUDO make install >/dev/null 2>&1
|
||||
fi
|
||||
ok "SatDump installed successfully."
|
||||
# Create wrapper script so satdump can find its resources via @executable_path
|
||||
$SUDO tee /usr/local/bin/satdump >/dev/null <<'WRAPPER'
|
||||
#!/bin/sh
|
||||
exec /usr/local/lib/satdump/satdump "$@"
|
||||
WRAPPER
|
||||
$SUDO chmod +x /usr/local/bin/satdump
|
||||
|
||||
hdiutil detach "$tmp_dir/mnt" -quiet 2>/dev/null
|
||||
|
||||
# Verify installation
|
||||
if /usr/local/lib/satdump/satdump 2>&1 | grep -q "Usage"; then
|
||||
ok "SatDump v1.2.2 installed successfully."
|
||||
else
|
||||
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
|
||||
warn "Failed to build SatDump from source. Weather satellite decoding will not be available."
|
||||
warn "Build log (last 30 lines):"
|
||||
tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
||||
warn "SatDump installed but may not work correctly."
|
||||
fi
|
||||
)
|
||||
}
|
||||
@@ -800,7 +825,7 @@ install_macos_packages() {
|
||||
sudo -v || { fail "sudo authentication failed"; exit 1; }
|
||||
fi
|
||||
|
||||
TOTAL_STEPS=19
|
||||
TOTAL_STEPS=21
|
||||
CURRENT_STEP=0
|
||||
|
||||
progress "Checking Homebrew"
|
||||
@@ -823,19 +848,6 @@ install_macos_packages() {
|
||||
progress "SSTV decoder"
|
||||
ok "SSTV uses built-in pure Python decoder (no external tools needed)"
|
||||
|
||||
progress "Installing DSD (Digital Speech Decoder, optional)"
|
||||
if ! cmd_exists dsd && ! cmd_exists dsd-fme; then
|
||||
echo
|
||||
info "DSD is used for DMR, P25, NXDN, and D-STAR digital voice decoding."
|
||||
if ask_yes_no "Do you want to install DSD?"; then
|
||||
install_dsd_from_source || warn "DSD build failed. DMR/P25 decoding will not be available."
|
||||
else
|
||||
warn "Skipping DSD installation. DMR/P25 decoding will not be available."
|
||||
fi
|
||||
else
|
||||
ok "DSD already installed"
|
||||
fi
|
||||
|
||||
progress "Installing ffmpeg"
|
||||
brew_install ffmpeg
|
||||
|
||||
@@ -873,6 +885,13 @@ install_macos_packages() {
|
||||
ok "acarsdec already installed"
|
||||
fi
|
||||
|
||||
progress "Installing dumpvdl2"
|
||||
if ! cmd_exists dumpvdl2; then
|
||||
install_dumpvdl2_from_source_macos || warn "dumpvdl2 not available. VDL2 decoding will not be available."
|
||||
else
|
||||
ok "dumpvdl2 already installed"
|
||||
fi
|
||||
|
||||
progress "Installing AIS-catcher"
|
||||
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
|
||||
(brew_install aiscatcher) || install_aiscatcher_from_source_macos || warn "AIS-catcher not available"
|
||||
@@ -885,7 +904,7 @@ install_macos_packages() {
|
||||
echo
|
||||
info "SatDump is used for weather satellite imagery (NOAA APT & Meteor LRPT)."
|
||||
if ask_yes_no "Do you want to install SatDump?"; then
|
||||
install_satdump_from_source_macos || warn "SatDump build failed. Weather satellite decoding will not be available."
|
||||
install_satdump_macos || warn "SatDump installation failed. Weather satellite decoding will not be available."
|
||||
else
|
||||
warn "Skipping SatDump installation. You can install it later if needed."
|
||||
fi
|
||||
@@ -966,10 +985,13 @@ install_dump1090_from_source_debian() {
|
||||
librtlsdr-dev libusb-1.0-0-dev \
|
||||
libncurses-dev tcl-dev python3-dev
|
||||
|
||||
local JOBS
|
||||
JOBS="$(nproc 2>/dev/null || echo 1)"
|
||||
|
||||
# Run in subshell to isolate EXIT trap
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
trap '{ [[ -n "${progress_pid:-}" ]] && kill "$progress_pid" 2>/dev/null && wait "$progress_pid" 2>/dev/null || true; }; rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
info "Cloning FlightAware dump1090..."
|
||||
git clone --depth 1 https://github.com/flightaware/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|
||||
@@ -977,23 +999,45 @@ install_dump1090_from_source_debian() {
|
||||
|
||||
cd "$tmp_dir/dump1090"
|
||||
# Remove -Werror to prevent build failures on newer GCC versions
|
||||
sed -i 's/-Werror//g' Makefile 2>/dev/null || sed -i '' 's/-Werror//g' Makefile
|
||||
info "Compiling FlightAware dump1090..."
|
||||
if make BLADERF=no RTLSDR=yes >/dev/null 2>&1; then
|
||||
sed -i 's/-Werror//g' Makefile 2>/dev/null || true
|
||||
info "Compiling FlightAware dump1090 (using ${JOBS} CPU cores)..."
|
||||
build_log="$tmp_dir/dump1090-build.log"
|
||||
|
||||
(while true; do sleep 20; printf " [*] Still compiling dump1090...\n"; done) &
|
||||
progress_pid=$!
|
||||
|
||||
if make -j "$JOBS" BLADERF=no RTLSDR=yes >"$build_log" 2>&1; then
|
||||
kill "$progress_pid" 2>/dev/null; wait "$progress_pid" 2>/dev/null || true; progress_pid=
|
||||
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
||||
ok "dump1090 installed successfully (FlightAware)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
kill "$progress_pid" 2>/dev/null; wait "$progress_pid" 2>/dev/null || true; progress_pid=
|
||||
warn "FlightAware build failed. Falling back to wiedehopf/readsb..."
|
||||
warn "Build log (last 20 lines):"
|
||||
tail -20 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
||||
|
||||
rm -rf "$tmp_dir/dump1090"
|
||||
git clone --depth 1 https://github.com/wiedehopf/readsb.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|
||||
|| { fail "Failed to clone wiedehopf/readsb"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/dump1090"
|
||||
info "Compiling readsb..."
|
||||
make RTLSDR=yes >/dev/null 2>&1 || { fail "Failed to build readsb from source (required)."; exit 1; }
|
||||
info "Compiling readsb (using ${JOBS} CPU cores)..."
|
||||
build_log="$tmp_dir/readsb-build.log"
|
||||
|
||||
(while true; do sleep 20; printf " [*] Still compiling readsb...\n"; done) &
|
||||
progress_pid=$!
|
||||
|
||||
if ! make -j "$JOBS" RTLSDR=yes >"$build_log" 2>&1; then
|
||||
kill "$progress_pid" 2>/dev/null; wait "$progress_pid" 2>/dev/null || true; progress_pid=
|
||||
warn "Build log (last 20 lines):"
|
||||
tail -20 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
||||
fail "Failed to build readsb from source (required)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
kill "$progress_pid" 2>/dev/null; wait "$progress_pid" 2>/dev/null || true; progress_pid=
|
||||
$SUDO install -m 0755 readsb /usr/local/bin/dump1090
|
||||
ok "dump1090 installed successfully (via readsb)."
|
||||
)
|
||||
@@ -1018,7 +1062,7 @@ install_acarsdec_from_source_debian() {
|
||||
mkdir -p build && cd build
|
||||
|
||||
info "Compiling acarsdec..."
|
||||
if cmake .. -Drtl=ON >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||
if cmake .. -Drtl=ON -DCMAKE_POLICY_VERSION_MINIMUM=3.5 >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||
$SUDO install -m 0755 acarsdec /usr/local/bin/acarsdec
|
||||
ok "acarsdec installed successfully."
|
||||
else
|
||||
@@ -1027,6 +1071,52 @@ install_acarsdec_from_source_debian() {
|
||||
)
|
||||
}
|
||||
|
||||
install_dumpvdl2_from_source_debian() {
|
||||
info "Building dumpvdl2 from source (with libacars dependency)..."
|
||||
|
||||
apt_install build-essential git cmake \
|
||||
librtlsdr-dev libusb-1.0-0-dev libglib2.0-dev libxml2-dev
|
||||
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
# Build libacars first
|
||||
info "Cloning libacars..."
|
||||
git clone --depth 1 https://github.com/szpajder/libacars.git "$tmp_dir/libacars" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone libacars"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/libacars"
|
||||
mkdir -p build && cd build
|
||||
|
||||
info "Compiling libacars..."
|
||||
if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||
$SUDO make install >/dev/null 2>&1
|
||||
$SUDO ldconfig
|
||||
ok "libacars installed"
|
||||
else
|
||||
warn "Failed to build libacars."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build dumpvdl2
|
||||
info "Cloning dumpvdl2..."
|
||||
git clone --depth 1 https://github.com/szpajder/dumpvdl2.git "$tmp_dir/dumpvdl2" >/dev/null 2>&1 \
|
||||
|| { warn "Failed to clone dumpvdl2"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/dumpvdl2"
|
||||
mkdir -p build && cd build
|
||||
|
||||
info "Compiling dumpvdl2..."
|
||||
if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||
$SUDO install -m 0755 src/dumpvdl2 /usr/local/bin/dumpvdl2
|
||||
ok "dumpvdl2 installed successfully."
|
||||
else
|
||||
warn "Failed to build dumpvdl2 from source. VDL2 decoding will not be available."
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
install_aiscatcher_from_source_debian() {
|
||||
info "AIS-catcher not available via APT. Building from source..."
|
||||
|
||||
@@ -1119,6 +1209,17 @@ install_rtlsdr_blog_drivers_debian() {
|
||||
$SUDO udevadm trigger || true
|
||||
fi
|
||||
|
||||
# Make the Blog drivers' library take priority over the apt-installed
|
||||
# librtlsdr. Removing apt packages is too destructive (dump1090-mutability
|
||||
# and other tools depend on librtlsdr0 and get swept out). Instead,
|
||||
# prepend /usr/local/lib to ldconfig's search path — files named 00-*
|
||||
# sort before the distro's aarch64-linux-gnu.conf — so ldconfig lists
|
||||
# /usr/local/lib/librtlsdr.so.0 first and the dynamic linker uses it.
|
||||
if [[ -d /etc/ld.so.conf.d ]]; then
|
||||
echo '/usr/local/lib' | $SUDO tee /etc/ld.so.conf.d/00-local-first.conf >/dev/null
|
||||
fi
|
||||
$SUDO ldconfig
|
||||
|
||||
ok "RTL-SDR Blog drivers installed successfully."
|
||||
info "These drivers provide improved support for RTL-SDR Blog V4 and other devices."
|
||||
warn "Unplug and replug your RTL-SDR devices for the new drivers to take effect."
|
||||
@@ -1128,6 +1229,7 @@ install_rtlsdr_blog_drivers_debian() {
|
||||
warn "See: https://github.com/rtlsdrblog/rtl-sdr-blog"
|
||||
fi
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
setup_udev_rules_debian() {
|
||||
@@ -1152,24 +1254,35 @@ blacklist_kernel_drivers_debian() {
|
||||
|
||||
if [[ -f "$blacklist_file" ]]; then
|
||||
ok "RTL-SDR kernel driver blacklist already present"
|
||||
return 0
|
||||
fi
|
||||
|
||||
info "Blacklisting conflicting DVB kernel drivers..."
|
||||
$SUDO tee "$blacklist_file" >/dev/null <<'EOF'
|
||||
else
|
||||
info "Blacklisting conflicting DVB kernel drivers..."
|
||||
$SUDO tee "$blacklist_file" >/dev/null <<'EOF'
|
||||
# Blacklist DVB-T drivers to allow rtl-sdr to access RTL2832U devices
|
||||
blacklist dvb_usb_rtl28xxu
|
||||
blacklist rtl2832
|
||||
blacklist rtl2830
|
||||
blacklist r820t
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Unload modules if currently loaded
|
||||
# Always unload modules if currently loaded — this must happen even on
|
||||
# re-runs where the blacklist file already exists, since the modules may
|
||||
# still be live from the current boot (e.g. blacklist wasn't in initramfs).
|
||||
local unloaded=false
|
||||
for mod in dvb_usb_rtl28xxu rtl2832 rtl2830 r820t; do
|
||||
if lsmod | grep -q "^$mod"; then
|
||||
$SUDO modprobe -r "$mod" 2>/dev/null || true
|
||||
unloaded=true
|
||||
fi
|
||||
done
|
||||
$unloaded && info "Unloaded conflicting DVB kernel modules from current session."
|
||||
|
||||
# Bake the blacklist into the initramfs so it survives reboots on
|
||||
# Raspberry Pi OS / Debian (without this the modules can reload on boot).
|
||||
if cmd_exists update-initramfs; then
|
||||
info "Updating initramfs to persist driver blacklist across reboots..."
|
||||
$SUDO update-initramfs -u >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
ok "Kernel drivers blacklisted. Unplug/replug your RTL-SDR if connected."
|
||||
echo
|
||||
@@ -1190,7 +1303,7 @@ install_debian_packages() {
|
||||
export NEEDRESTART_MODE=a
|
||||
fi
|
||||
|
||||
TOTAL_STEPS=26
|
||||
TOTAL_STEPS=27
|
||||
CURRENT_STEP=0
|
||||
|
||||
progress "Updating APT package lists"
|
||||
@@ -1232,12 +1345,18 @@ install_debian_packages() {
|
||||
|
||||
apt_install_if_missing rtl-sdr
|
||||
|
||||
progress "RTL-SDR Blog drivers"
|
||||
if cmd_exists rtl_test; then
|
||||
ok "RTL-SDR drivers already installed"
|
||||
progress "RTL-SDR Blog drivers (V4 support)"
|
||||
if $IS_DRAGONOS; then
|
||||
info "DragonOS: skipping RTL-SDR Blog driver install (pre-configured)."
|
||||
else
|
||||
info "RTL-SDR drivers not found, installing RTL-SDR Blog drivers..."
|
||||
install_rtlsdr_blog_drivers_debian
|
||||
echo
|
||||
info "RTL-SDR Blog drivers add V4 (R828D tuner) support and bias-tee improvements."
|
||||
info "They are backward-compatible with all RTL-SDR devices."
|
||||
if ask_yes_no "Install RTL-SDR Blog drivers? (recommended for V4 users, safe for all)" "y"; then
|
||||
install_rtlsdr_blog_drivers_debian
|
||||
else
|
||||
warn "Skipping RTL-SDR Blog drivers. V4 devices may not work correctly."
|
||||
fi
|
||||
fi
|
||||
|
||||
progress "Installing multimon-ng"
|
||||
@@ -1249,19 +1368,6 @@ install_debian_packages() {
|
||||
progress "SSTV decoder"
|
||||
ok "SSTV uses built-in pure Python decoder (no external tools needed)"
|
||||
|
||||
progress "Installing DSD (Digital Speech Decoder, optional)"
|
||||
if ! cmd_exists dsd && ! cmd_exists dsd-fme; then
|
||||
echo
|
||||
info "DSD is used for DMR, P25, NXDN, and D-STAR digital voice decoding."
|
||||
if ask_yes_no "Do you want to install DSD?"; then
|
||||
install_dsd_from_source || warn "DSD build failed. DMR/P25 decoding will not be available."
|
||||
else
|
||||
warn "Skipping DSD installation. DMR/P25 decoding will not be available."
|
||||
fi
|
||||
else
|
||||
ok "DSD already installed"
|
||||
fi
|
||||
|
||||
progress "Installing ffmpeg"
|
||||
apt_install ffmpeg
|
||||
|
||||
@@ -1327,12 +1433,21 @@ install_debian_packages() {
|
||||
$SUDO apt-get install -y python3-bleak >/dev/null 2>&1 || true
|
||||
|
||||
progress "Installing dump1090"
|
||||
# Remove any stale symlink left from a previous run where dump1090-mutability
|
||||
# was later uninstalled — cmd_exists finds the broken symlink and skips the
|
||||
# real install, leaving dump1090 seemingly present but non-functional.
|
||||
local dump1090_path
|
||||
dump1090_path="$(command -v dump1090 2>/dev/null || true)"
|
||||
if [[ -n "$dump1090_path" ]] && [[ ! -x "$dump1090_path" ]]; then
|
||||
info "Removing broken dump1090 symlink: $dump1090_path"
|
||||
$SUDO rm -f "$dump1090_path"
|
||||
fi
|
||||
if ! cmd_exists dump1090 && ! cmd_exists dump1090-mutability; then
|
||||
apt_try_install_any dump1090-fa dump1090-mutability dump1090 || true
|
||||
fi
|
||||
if ! cmd_exists dump1090; then
|
||||
if cmd_exists dump1090-mutability; then
|
||||
$SUDO ln -s $(which dump1090-mutability) /usr/local/sbin/dump1090
|
||||
$SUDO ln -s "$(which dump1090-mutability)" /usr/local/sbin/dump1090
|
||||
fi
|
||||
fi
|
||||
cmd_exists dump1090 || install_dump1090_from_source_debian
|
||||
@@ -1343,6 +1458,13 @@ install_debian_packages() {
|
||||
fi
|
||||
cmd_exists acarsdec || install_acarsdec_from_source_debian
|
||||
|
||||
progress "Installing dumpvdl2"
|
||||
if ! cmd_exists dumpvdl2; then
|
||||
install_dumpvdl2_from_source_debian || warn "dumpvdl2 not available. VDL2 decoding will not be available."
|
||||
else
|
||||
ok "dumpvdl2 already installed"
|
||||
fi
|
||||
|
||||
progress "Installing AIS-catcher"
|
||||
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
|
||||
install_aiscatcher_from_source_debian
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--bg-dark: #0b1118;
|
||||
--bg-panel: #101823;
|
||||
--bg-card: #151f2b;
|
||||
@@ -27,12 +27,36 @@
|
||||
--radar-bg: #101823;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-dark: #f4f7fb;
|
||||
--bg-panel: #e9eef5;
|
||||
--bg-card: #ffffff;
|
||||
--border-color: #d1d9e6;
|
||||
--border-glow: #1f5fa8;
|
||||
--text-primary: #122034;
|
||||
--text-secondary: #3a4a5f;
|
||||
--text-dim: #6b7c93;
|
||||
--accent-green: #1f8a57;
|
||||
--accent-cyan: #1f5fa8;
|
||||
--accent-orange: #b5863a;
|
||||
--accent-red: #c74444;
|
||||
--accent-yellow: #b5863a;
|
||||
--accent-amber: #b5863a;
|
||||
--grid-line: rgba(31, 95, 168, 0.12);
|
||||
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23000000'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||
--radar-cyan: #1f5fa8;
|
||||
--radar-bg: #e9eef5;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
height: 100dvh;
|
||||
height: 100vh; /* Fallback */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Animated radar sweep background */
|
||||
@@ -227,16 +251,14 @@ body {
|
||||
}
|
||||
|
||||
/* Main dashboard grid - Mobile first */
|
||||
/* Header ~52px + Nav 44px + Stats strip ~55px = ~151px, using 160px for safety */
|
||||
.dashboard {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
height: calc(100dvh - 160px);
|
||||
height: calc(100vh - 160px); /* Fallback */
|
||||
min-height: 400px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Tablet: Two-column layout */
|
||||
@@ -249,13 +271,29 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop: Full layout with ACARS */
|
||||
/* Desktop: Full layout with ACARS/VDL2 + map + sidebar */
|
||||
@media (min-width: 1024px) {
|
||||
.dashboard {
|
||||
grid-template-columns: auto 1fr 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Left sidebars wrapper (ACARS + VDL2) */
|
||||
.left-sidebars {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.left-sidebars {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* ACARS sidebar (left of map) - Collapsible */
|
||||
.acars-sidebar {
|
||||
display: none;
|
||||
@@ -267,12 +305,10 @@ body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Show ACARS sidebar on desktop */
|
||||
@media (min-width: 1024px) {
|
||||
.acars-sidebar {
|
||||
display: flex;
|
||||
max-height: calc(100dvh - 160px);
|
||||
}
|
||||
/* Show ACARS sidebar inside wrapper */
|
||||
.left-sidebars .acars-sidebar {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.acars-collapse-btn {
|
||||
@@ -419,6 +455,335 @@ body {
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* VDL2 sidebar (left of map, after ACARS) - Collapsible */
|
||||
.vdl2-sidebar {
|
||||
display: none;
|
||||
background: var(--bg-panel);
|
||||
border-right: 1px solid var(--border-color);
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Show VDL2 sidebar inside wrapper */
|
||||
.left-sidebars .vdl2-sidebar {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.vdl2-collapse-btn {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
background: var(--bg-card);
|
||||
border: none;
|
||||
border-left: 1px solid var(--border-color);
|
||||
color: var(--accent-cyan);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.vdl2-collapse-btn:hover {
|
||||
background: rgba(74, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
.vdl2-collapse-label {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.vdl2-sidebar.collapsed .vdl2-collapse-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.vdl2-sidebar:not(.collapsed) .vdl2-collapse-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#vdl2CollapseIcon {
|
||||
font-size: 10px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.vdl2-sidebar.collapsed #vdl2CollapseIcon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.vdl2-sidebar-content {
|
||||
width: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition: width 0.3s ease, opacity 0.2s ease;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.vdl2-sidebar.collapsed .vdl2-sidebar-content {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vdl2-sidebar .panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vdl2-sidebar .panel::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vdl2-sidebar .panel-header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vdl2-sidebar #vdl2PanelContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vdl2-sidebar .vdl2-info,
|
||||
.vdl2-sidebar .vdl2-controls {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vdl2-sidebar .vdl2-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.vdl2-sidebar .vdl2-btn {
|
||||
background: var(--accent-green);
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 6px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.vdl2-sidebar .vdl2-btn:hover {
|
||||
background: #1db954;
|
||||
box-shadow: 0 0 10px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.vdl2-sidebar .vdl2-btn.active {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
|
||||
.vdl2-sidebar .vdl2-btn.active:hover {
|
||||
background: #dc2626;
|
||||
box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.vdl2-message-item {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 10px;
|
||||
animation: fadeIn 0.3s ease;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.vdl2-message-item:hover {
|
||||
background: rgba(74, 158, 255, 0.08);
|
||||
}
|
||||
|
||||
/* VDL2 Message Modal */
|
||||
.vdl2-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: vdl2ModalFadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes vdl2ModalFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.vdl2-modal {
|
||||
background: var(--bg-panel, #1a1a2e);
|
||||
border: 1px solid var(--accent-cyan, #4a9eff);
|
||||
border-radius: 8px;
|
||||
width: 520px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 1px var(--accent-cyan, #4a9eff);
|
||||
animation: vdl2ModalSlideIn 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes vdl2ModalSlideIn {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(10px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.vdl2-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vdl2-modal-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-cyan, #4a9eff);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.vdl2-modal-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.vdl2-modal-close {
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.vdl2-modal-close:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: var(--accent-red, #ef4444);
|
||||
color: var(--accent-red, #ef4444);
|
||||
}
|
||||
|
||||
.vdl2-modal-body {
|
||||
padding: 16px 18px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.vdl2-modal-section {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.vdl2-modal-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.vdl2-modal-section-title {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.vdl2-modal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px 16px;
|
||||
}
|
||||
|
||||
.vdl2-modal-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.vdl2-modal-field-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.vdl2-modal-field-value {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vdl2-modal-msg-body {
|
||||
padding: 10px 12px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.vdl2-modal-raw-toggle {
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
font-size: 10px;
|
||||
color: var(--accent-cyan, #4a9eff);
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.vdl2-modal-raw-toggle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.vdl2-modal-raw-json {
|
||||
display: none;
|
||||
margin-top: 8px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Panels */
|
||||
.panel {
|
||||
background: var(--bg-panel);
|
||||
@@ -495,6 +860,8 @@ body {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@@ -526,40 +893,90 @@ body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#radarOverlayCanvas {
|
||||
.map-crosshair-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 500;
|
||||
display: 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;
|
||||
}
|
||||
|
||||
#radarOverlayCanvas.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#radarScope {
|
||||
.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;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: none;
|
||||
background: var(--radar-bg);
|
||||
transform: translateX(var(--crosshair-x-start));
|
||||
}
|
||||
|
||||
#radarScope.active {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
.map-crosshair-horizontal {
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
top: 0;
|
||||
transform: translateY(var(--crosshair-y-start));
|
||||
}
|
||||
|
||||
#radarCanvas {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
.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 */
|
||||
@@ -588,51 +1005,21 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* View toggle */
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
gap: 8px;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid rgba(74, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.view-btn.active {
|
||||
background: var(--accent-cyan);
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
/* Selected aircraft panel */
|
||||
.selected-aircraft {
|
||||
flex-shrink: 0;
|
||||
max-height: 480px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (min-height: 900px) {
|
||||
.selected-aircraft {
|
||||
max-height: 340px;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-info {
|
||||
padding: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#aircraftPhotoContainer {
|
||||
@@ -640,7 +1027,7 @@ body {
|
||||
}
|
||||
|
||||
#aircraftPhotoContainer img {
|
||||
max-height: 140px;
|
||||
max-height: 100px;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
@@ -649,24 +1036,24 @@ body {
|
||||
|
||||
.selected-callsign {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-cyan);
|
||||
text-shadow: 0 0 15px var(--accent-cyan);
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.telemetry-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.telemetry-item {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
padding: 5px 8px;
|
||||
border-left: 2px solid var(--accent-cyan);
|
||||
}
|
||||
|
||||
@@ -776,9 +1163,10 @@ body {
|
||||
gap: 8px;
|
||||
padding: 8px 15px;
|
||||
background: var(--bg-panel);
|
||||
border-top: 1px solid rgba(74, 158, 255, 0.3);
|
||||
border-top: none;
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.controls-bar > .control-group {
|
||||
@@ -907,6 +1295,15 @@ body {
|
||||
.control-group.airband-group {
|
||||
background: rgba(245, 158, 11, 0.05);
|
||||
border-color: rgba(245, 158, 11, 0.2);
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.control-group.airband-group > .control-group-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.control-group.airband-group .control-group-label {
|
||||
@@ -1010,6 +1407,7 @@ body {
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@@ -1021,6 +1419,15 @@ body {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Hide scrollbar on controls bar */
|
||||
.controls-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.controls-bar {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* No aircraft message */
|
||||
.no-aircraft {
|
||||
text-align: center;
|
||||
@@ -1289,7 +1696,7 @@ body {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
height: auto !important;
|
||||
min-height: calc(100dvh - 160px);
|
||||
min-height: 400px;
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
@@ -1489,6 +1896,10 @@ body {
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.strip-stat.source-stat .strip-value {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.strip-stat.session-stat {
|
||||
background: rgba(34, 197, 94, 0.05);
|
||||
border-color: rgba(34, 197, 94, 0.2);
|
||||
@@ -1779,6 +2190,9 @@ body {
|
||||
.strip-btn {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
border: 1px solid rgba(74, 158, 255, 0.2);
|
||||
color: var(--text-primary);
|
||||
@@ -1789,6 +2203,12 @@ body {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.strip-btn svg {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.strip-btn:hover:not(:disabled) {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--bg-dark: #0b1118;
|
||||
--bg-panel: #101823;
|
||||
--bg-card: #151f2b;
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--bg-dark: #0b1118;
|
||||
--bg-panel: #101823;
|
||||
--bg-card: #151f2b;
|
||||
@@ -30,6 +30,27 @@
|
||||
--radar-bg: #101823;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-dark: #f4f7fb;
|
||||
--bg-panel: #e9eef5;
|
||||
--bg-card: #ffffff;
|
||||
--border-color: #d1d9e6;
|
||||
--border-glow: #1f5fa8;
|
||||
--text-primary: #122034;
|
||||
--text-secondary: #3a4a5f;
|
||||
--text-dim: #6b7c93;
|
||||
--accent-green: #1f8a57;
|
||||
--accent-cyan: #1f5fa8;
|
||||
--accent-orange: #b5863a;
|
||||
--accent-red: #c74444;
|
||||
--accent-yellow: #b5863a;
|
||||
--accent-amber: #b5863a;
|
||||
--grid-line: rgba(31, 95, 168, 0.12);
|
||||
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23000000'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||
--radar-cyan: #1f5fa8;
|
||||
--radar-bg: #e9eef5;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-dark);
|
||||
@@ -496,7 +517,7 @@ body {
|
||||
padding: 10px 15px;
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
border-bottom: 1px solid rgba(74, 158, 255, 0.1);
|
||||
font-family: 'Orbitron', 'Space Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 2px;
|
||||
@@ -568,7 +589,7 @@ body {
|
||||
}
|
||||
|
||||
.vessel-name {
|
||||
font-family: 'Orbitron', 'Space Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-cyan);
|
||||
@@ -662,7 +683,7 @@ body {
|
||||
}
|
||||
|
||||
.vessel-item-name {
|
||||
font-family: 'Orbitron', 'Space Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
@@ -1223,7 +1244,7 @@ body {
|
||||
}
|
||||
|
||||
.dsc-distress-alert .dsc-alert-header {
|
||||
font-family: 'Orbitron', 'Space Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-red);
|
||||
|
||||
@@ -13,13 +13,11 @@
|
||||
}
|
||||
|
||||
.radar-device {
|
||||
transition: transform 0.2s ease;
|
||||
transform-origin: center center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radar-device:hover {
|
||||
transform: scale(1.2);
|
||||
.radar-device:hover .radar-dot {
|
||||
filter: brightness(1.5);
|
||||
}
|
||||
|
||||
/* Invisible larger hit area to prevent hover flicker */
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
/* Shared UX platform components: run-state strip, command palette, setup assistant, and toasts */
|
||||
|
||||
.run-state-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
margin: 6px 12px 0;
|
||||
border: 1px solid rgba(74, 163, 255, 0.32);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, rgba(19, 30, 44, 0.96), rgba(11, 18, 28, 0.97));
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.run-state-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#runStateChips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.run-state-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim, #8697aa);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.run-state-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(74, 163, 255, 0.25);
|
||||
background: linear-gradient(180deg, rgba(17, 26, 38, 0.82), rgba(12, 18, 28, 0.84));
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary, #b1c2d4);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.run-state-chip .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #667788;
|
||||
box-shadow: 0 0 0 0 rgba(102, 119, 136, 0.45);
|
||||
}
|
||||
|
||||
.run-state-chip.running .dot {
|
||||
background: var(--accent-green, #28c27a);
|
||||
box-shadow: 0 0 0 4px rgba(40, 194, 122, 0.16), 0 0 12px rgba(40, 194, 122, 0.35);
|
||||
}
|
||||
|
||||
.run-state-chip.active {
|
||||
border-color: rgba(74, 163, 255, 0.65);
|
||||
color: var(--text-primary, #e6edf5);
|
||||
box-shadow: inset 0 0 0 1px rgba(74, 163, 255, 0.18);
|
||||
}
|
||||
|
||||
.run-state-right {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.run-state-value {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #8697aa);
|
||||
}
|
||||
|
||||
.run-state-btn {
|
||||
background: linear-gradient(180deg, rgba(17, 27, 41, 0.9), rgba(10, 16, 25, 0.92));
|
||||
color: var(--accent-cyan, #4aa3ff);
|
||||
border: 1px solid rgba(74, 163, 255, 0.45);
|
||||
border-radius: 6px;
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.16s ease, border-color 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
|
||||
.run-state-btn:hover {
|
||||
background: rgba(74, 163, 255, 0.14);
|
||||
border-color: rgba(74, 163, 255, 0.7);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.command-palette-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 10vh 18px 0;
|
||||
z-index: 25000;
|
||||
background: rgba(4, 8, 14, 0.65);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.command-palette-overlay.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.command-palette {
|
||||
width: min(760px, 100%);
|
||||
border: 1px solid rgba(74, 163, 255, 0.32);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, rgba(16, 26, 39, 0.98), rgba(10, 17, 27, 0.98));
|
||||
box-shadow: 0 26px 60px rgba(0, 0, 0, 0.56), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.command-palette-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||
}
|
||||
|
||||
.command-palette-input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary, #e6edf5);
|
||||
font-size: 14px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.command-palette-hint {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #8697aa);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.command-palette-list {
|
||||
max-height: min(62vh, 520px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.command-palette-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #b1c2d4);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.command-palette-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.command-palette-item .meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.command-palette-item .title {
|
||||
color: var(--text-primary, #e6edf5);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.command-palette-item .desc {
|
||||
color: var(--text-dim, #8697aa);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.command-palette-item .kbd {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #8697aa);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
|
||||
.command-palette-item.active,
|
||||
.command-palette-item:hover,
|
||||
.command-palette-item:focus-visible {
|
||||
background: rgba(74, 163, 255, 0.12);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.command-palette-empty {
|
||||
padding: 22px 16px;
|
||||
color: var(--text-dim, #8697aa);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.setup-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 26000;
|
||||
background: rgba(4, 8, 14, 0.72);
|
||||
backdrop-filter: blur(4px);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.setup-overlay.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.setup-modal {
|
||||
width: min(760px, 100%);
|
||||
max-height: 84vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color, #1e2d3d);
|
||||
border-radius: 12px;
|
||||
background: #101926;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.setup-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.setup-title {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
color: var(--text-primary, #e6edf5);
|
||||
}
|
||||
|
||||
.setup-subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #8697aa);
|
||||
}
|
||||
|
||||
.setup-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-dim, #8697aa);
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.setup-content {
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.setup-step {
|
||||
border: 1px solid var(--border-color, #1e2d3d);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.setup-step-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.setup-step-title {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary, #e6edf5);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.setup-step-status {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #8697aa);
|
||||
}
|
||||
|
||||
.setup-step-status.done {
|
||||
color: var(--accent-green, #28c27a);
|
||||
}
|
||||
|
||||
.setup-step-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #b1c2d4);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.setup-step-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.setup-btn {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color, #1e2d3d);
|
||||
background: var(--bg-tertiary, #121f2d);
|
||||
color: var(--text-secondary, #b1c2d4);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.setup-btn.primary {
|
||||
color: #fff;
|
||||
background: var(--accent-cyan, #4aa3ff);
|
||||
border-color: var(--accent-cyan, #4aa3ff);
|
||||
}
|
||||
|
||||
.setup-footer {
|
||||
padding: 12px 14px;
|
||||
border-top: 1px solid var(--border-color, #1e2d3d);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.setup-footer-note {
|
||||
color: var(--text-dim, #8697aa);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.app-toast-stack {
|
||||
position: fixed;
|
||||
right: 14px;
|
||||
bottom: 16px;
|
||||
z-index: 25500;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: min(380px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.app-toast {
|
||||
border: 1px solid var(--border-color, #1e2d3d);
|
||||
border-left: 3px solid var(--accent-cyan, #4aa3ff);
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 24, 35, 0.97);
|
||||
color: var(--text-secondary, #b1c2d4);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.35);
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.app-toast.error {
|
||||
border-left-color: var(--accent-red, #e25d5d);
|
||||
}
|
||||
|
||||
.app-toast.warning {
|
||||
border-left-color: var(--accent-orange, #d6a85e);
|
||||
}
|
||||
|
||||
.app-toast-title {
|
||||
font-size: 11px;
|
||||
color: var(--text-primary, #e6edf5);
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.app-toast-msg {
|
||||
color: var(--text-secondary, #b1c2d4);
|
||||
}
|
||||
|
||||
.app-toast-actions {
|
||||
margin-top: 7px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-toast-actions button {
|
||||
border: 1px solid var(--border-color, #1e2d3d);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary, #132133);
|
||||
color: var(--text-secondary, #b1c2d4);
|
||||
font-size: 10px;
|
||||
padding: 3px 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-toast-actions button:hover {
|
||||
border-color: rgba(74, 163, 255, 0.5);
|
||||
color: var(--text-primary, #e6edf5);
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.run-state-strip {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.run-state-right {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.command-palette-overlay {
|
||||
padding: 8vh 10px 0;
|
||||
}
|
||||
|
||||
.command-palette-item {
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
.setup-header,
|
||||
.setup-content,
|
||||
.setup-footer {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.app-toast-stack {
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@@ -28,14 +28,16 @@ body {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
background-image:
|
||||
radial-gradient(1200px 620px at 8% -12%, var(--ambient-top-left), transparent 62%),
|
||||
radial-gradient(980px 560px at 92% -16%, var(--ambient-top-right), transparent 64%),
|
||||
radial-gradient(900px 520px at 50% 126%, var(--ambient-bottom), transparent 68%),
|
||||
var(--noise-image),
|
||||
radial-gradient(circle at 15% 0%, var(--grid-dot), transparent 45%),
|
||||
linear-gradient(180deg, var(--grid-dot), transparent 35%),
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 40px 40px, auto, auto, 48px 48px, 48px 48px;
|
||||
background-size: auto, auto, auto, 40px 40px, 48px 48px, 48px 48px;
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
font-variant-numeric: tabular-nums;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@@ -123,11 +123,12 @@
|
||||
CARDS / PANELS
|
||||
============================================ */
|
||||
.card {
|
||||
background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-secondary) 100%);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--surface-panel-gradient);
|
||||
border: 1px solid rgba(74, 163, 255, 0.24);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@@ -135,8 +136,8 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid rgba(74, 163, 255, 0.18);
|
||||
background: linear-gradient(180deg, rgba(25, 38, 55, 0.88) 0%, rgba(17, 27, 40, 0.9) 100%);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -160,11 +161,12 @@
|
||||
|
||||
/* Panel variant (used in dashboards) */
|
||||
.panel {
|
||||
background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-secondary) 100%);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--surface-panel-gradient);
|
||||
border: 1px solid rgba(74, 163, 255, 0.24);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
@supports (clip-path: polygon(0 0)) {
|
||||
@@ -190,8 +192,8 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
|
||||
border-bottom: 1px solid rgba(74, 163, 255, 0.18);
|
||||
background: linear-gradient(180deg, rgba(25, 38, 55, 0.88) 0%, rgba(17, 27, 40, 0.9) 100%);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-semibold);
|
||||
text-transform: uppercase;
|
||||
@@ -720,10 +722,23 @@
|
||||
transform var(--transition-base);
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.panel:hover {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
.card:hover,
|
||||
.panel:hover {
|
||||
border-color: var(--border-glow);
|
||||
box-shadow: var(--shadow-md), var(--shadow-glow), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
[data-theme="light"] .card,
|
||||
[data-theme="light"] .panel {
|
||||
border-color: rgba(31, 95, 168, 0.24);
|
||||
}
|
||||
|
||||
[data-theme="light"] .card-header,
|
||||
[data-theme="light"] .panel-header {
|
||||
border-bottom-color: rgba(31, 95, 168, 0.2);
|
||||
background: linear-gradient(180deg, rgba(243, 247, 252, 0.96) 0%, rgba(233, 239, 247, 0.95) 100%);
|
||||
}
|
||||
|
||||
/* Stats strip value highlight on hover */
|
||||
.strip-stat {
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
--bg-card: #121a25;
|
||||
--bg-elevated: #1b2734;
|
||||
--bg-overlay: rgba(8, 13, 20, 0.75);
|
||||
--surface-glass: rgba(16, 25, 37, 0.82);
|
||||
--surface-panel-gradient: linear-gradient(160deg, rgba(20, 32, 47, 0.94) 0%, rgba(11, 18, 27, 0.96) 100%);
|
||||
--ambient-top-left: rgba(74, 163, 255, 0.14);
|
||||
--ambient-top-right: rgba(56, 193, 128, 0.09);
|
||||
--ambient-bottom: rgba(214, 168, 94, 0.06);
|
||||
|
||||
/* Background aliases for components */
|
||||
--bg-dark: var(--bg-primary);
|
||||
@@ -78,8 +83,8 @@
|
||||
/* ============================================
|
||||
TYPOGRAPHY
|
||||
============================================ */
|
||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
|
||||
/* Font sizes */
|
||||
--text-xs: 10px;
|
||||
@@ -158,6 +163,11 @@
|
||||
--bg-card: #ffffff;
|
||||
--bg-elevated: #f1f4f9;
|
||||
--bg-overlay: rgba(244, 247, 251, 0.92);
|
||||
--surface-glass: rgba(255, 255, 255, 0.84);
|
||||
--surface-panel-gradient: linear-gradient(160deg, rgba(255, 255, 255, 0.96) 0%, rgba(241, 245, 251, 0.97) 100%);
|
||||
--ambient-top-left: rgba(31, 95, 168, 0.1);
|
||||
--ambient-top-right: rgba(31, 138, 87, 0.06);
|
||||
--ambient-bottom: rgba(181, 134, 58, 0.05);
|
||||
|
||||
/* Background aliases for components */
|
||||
--bg-dark: var(--bg-primary);
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
/* Local font declarations for offline mode */
|
||||
/* Roboto Condensed - variable font, one file covers all weights */
|
||||
|
||||
/* Space Mono - Console font */
|
||||
@font-face {
|
||||
font-family: 'Space Mono';
|
||||
font-family: 'Roboto Condensed';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-weight: 300 700;
|
||||
font-display: swap;
|
||||
src: url('/static/vendor/fonts/SpaceMono-Regular.woff2') format('woff2');
|
||||
src: url('/static/vendor/fonts/RobotoCondensed-Latin.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Space Mono';
|
||||
font-family: 'Roboto Condensed';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-weight: 300 700;
|
||||
font-display: swap;
|
||||
src: url('/static/vendor/fonts/SpaceMono-Bold.woff2') format('woff2');
|
||||
src: url('/static/vendor/fonts/RobotoCondensed-LatinExt.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
border-bottom: 1px solid var(--border-color, #202833);
|
||||
padding: 0 20px;
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
z-index: 1100;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
@@ -434,6 +434,6 @@ a.nav-dashboard-btn:hover {
|
||||
}
|
||||
|
||||
.nav-dashboard-btn .nav-label {
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
z-index: 10000;
|
||||
overflow-y: auto;
|
||||
padding: 40px 20px;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif);
|
||||
}
|
||||
|
||||
.help-modal.active {
|
||||
@@ -26,37 +27,41 @@
|
||||
background: var(--bg-card, var(--bg-secondary, #0f1218));
|
||||
border: 1px solid var(--border-color, #1f2937);
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.help-content h2 {
|
||||
color: var(--accent-cyan, #4a9eff);
|
||||
margin-bottom: 20px;
|
||||
font-size: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 15px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.help-content h3 {
|
||||
color: var(--text-primary, #e8eaed);
|
||||
margin: 25px 0 15px 0;
|
||||
font-size: 14px;
|
||||
margin: 20px 0 10px 0;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
border-bottom: 1px solid var(--border-color, #1f2937);
|
||||
padding-bottom: 8px;
|
||||
padding-bottom: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.help-close {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim, #4b5563);
|
||||
font-size: 24px;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.help-close:hover {
|
||||
@@ -66,43 +71,54 @@
|
||||
.help-modal .icon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 15px 0;
|
||||
gap: 8px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.help-modal .icon-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg-primary, #0a0c10);
|
||||
border: 1px solid var(--border-color, #1f2937);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.help-modal .icon-item .icon {
|
||||
font-size: 18px;
|
||||
width: 30px;
|
||||
text-align: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.help-modal .icon-item .icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.help-modal .icon-item .desc {
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
font-size: 10.5px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.help-modal .tip-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 15px 0;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.help-modal .tip-list li {
|
||||
padding: 8px 0;
|
||||
padding-left: 20px;
|
||||
padding: 5px 0;
|
||||
padding-left: 16px;
|
||||
position: relative;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
font-size: 13px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid var(--border-color, #1f2937);
|
||||
}
|
||||
|
||||
@@ -118,10 +134,15 @@
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.help-modal .tip-list li strong {
|
||||
color: var(--text-primary, #e8eaed);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.help-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--border-color, #1f2937);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
@@ -129,12 +150,13 @@
|
||||
|
||||
.help-tab {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
padding: 8px;
|
||||
background: var(--bg-primary, #0a0c10);
|
||||
border: none;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
transition: all 0.15s ease;
|
||||
@@ -176,9 +198,9 @@
|
||||
/* Ensure code tags are styled */
|
||||
.help-modal code {
|
||||
background: var(--bg-tertiary, #151a23);
|
||||
padding: 2px 6px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif);
|
||||
font-size: 10.5px;
|
||||
color: var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
/* Tactical dark palette */
|
||||
--bg-primary: #0b1118;
|
||||
--bg-secondary: #101823;
|
||||
@@ -706,6 +706,8 @@ header h1 {
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0 20px;
|
||||
position: relative;
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
@@ -1554,7 +1556,7 @@ header h1 .tagline {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
overflow: visible;
|
||||
overflow: hidden;
|
||||
padding: 12px;
|
||||
position: relative;
|
||||
}
|
||||
@@ -1800,6 +1802,14 @@ header h1 .tagline {
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
@keyframes stop-btn-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(239,68,68,0); }
|
||||
50% { opacity: 0.75; box-shadow: 0 0 8px 2px rgba(239,68,68,0.45); }
|
||||
}
|
||||
.stop-btn {
|
||||
animation: stop-btn-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.output-panel {
|
||||
background: var(--bg-primary);
|
||||
display: flex;
|
||||
@@ -2170,6 +2180,10 @@ header h1 .tagline {
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -2180,6 +2194,14 @@ header h1 .tagline {
|
||||
letter-spacing: 1px;
|
||||
transition: all 0.2s ease;
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.control-btn .icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
@@ -3584,6 +3606,7 @@ header h1 .tagline {
|
||||
.wifi-networks-table-wrapper {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.wifi-networks-table {
|
||||
@@ -3692,6 +3715,22 @@ header h1 .tagline {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.app-collection-state-row td {
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-collection-state {
|
||||
color: var(--text-dim);
|
||||
padding: 16px 12px;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-collection-state.is-loading {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* WiFi Radar Panel (CENTER) */
|
||||
.wifi-radar-panel {
|
||||
display: flex;
|
||||
@@ -4058,8 +4097,8 @@ header h1 .tagline {
|
||||
/* Bluetooth Layout Container */
|
||||
.bt-layout-container {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
padding: 15px;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
margin: 0 15px 10px 15px;
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -4073,20 +4112,21 @@ header h1 .tagline {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
overflow-y: auto; /* scroll rather than squash when detail panel + radar exceed available height */
|
||||
}
|
||||
|
||||
.bt-main-area {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.bt-side-panels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 220px;
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -4094,6 +4134,21 @@ header h1 .tagline {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bt-tracker-panel h5 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.bt-tracker-list {
|
||||
font-size: 11px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.bt-radar-panel {
|
||||
@@ -4104,6 +4159,90 @@ header h1 .tagline {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#btRadarControls {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bt-radar-filter-btn,
|
||||
#btRadarPauseBtn {
|
||||
min-width: 84px;
|
||||
padding: 4px 10px;
|
||||
font-size: 10px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bt-radar-filter-btn:hover,
|
||||
#btRadarPauseBtn:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.bt-radar-filter-btn.active,
|
||||
#btRadarPauseBtn.active {
|
||||
color: #0f172a;
|
||||
background: var(--accent-cyan);
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.bt-zone-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.bt-zone-card {
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 8px 6px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.bt-zone-card.immediate {
|
||||
border-color: rgba(34, 197, 94, 0.35);
|
||||
}
|
||||
|
||||
.bt-zone-card.near {
|
||||
border-color: rgba(234, 179, 8, 0.35);
|
||||
}
|
||||
|
||||
.bt-zone-card.far {
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
}
|
||||
|
||||
.bt-zone-value {
|
||||
display: block;
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bt-zone-card.immediate .bt-zone-value {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.bt-zone-card.near .bt-zone-value {
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.bt-zone-card.far .bt-zone-value {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.bt-zone-label {
|
||||
color: var(--text-dim);
|
||||
font-size: 10px;
|
||||
margin-top: 3px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.bt-radar-panel #btProximityRadar {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -4245,6 +4384,70 @@ header h1 .tagline {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.bt-detail-badge.tracker-high {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.bt-detail-badge.tracker-medium {
|
||||
background: rgba(249, 115, 22, 0.2);
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.bt-detail-badge.tracker-low {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.bt-detail-tracker-analysis {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.bt-analysis-header {
|
||||
color: #fca5a5;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.35px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.bt-analysis-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
font-size: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.bt-analysis-label {
|
||||
color: var(--text-dim);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.bt-analysis-section {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.bt-evidence-list {
|
||||
margin: 4px 0 0 0;
|
||||
padding-left: 14px;
|
||||
color: var(--text-primary);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.bt-analysis-warning {
|
||||
margin-top: 8px;
|
||||
color: #fca5a5;
|
||||
font-size: 9px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.bt-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
@@ -4280,6 +4483,8 @@ header h1 .tagline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bt-detail-services {
|
||||
@@ -4435,8 +4640,8 @@ header h1 .tagline {
|
||||
border-left-color: var(--accent-purple) !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 280px;
|
||||
max-width: 320px;
|
||||
min-width: 330px;
|
||||
max-width: 420px;
|
||||
max-height: 100%;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -4448,6 +4653,9 @@ header h1 .tagline {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
padding: 8px 10px 12px;
|
||||
background: var(--bg-primary);
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.bt-device-list .wifi-device-list-header {
|
||||
@@ -4457,6 +4665,10 @@ header h1 .tagline {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 4;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.bt-device-list .wifi-device-list-header h5 {
|
||||
@@ -4466,6 +4678,101 @@ header h1 .tagline {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bt-list-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.bt-summary-item {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 5px 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bt-summary-label {
|
||||
display: block;
|
||||
font-size: 8px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.35px;
|
||||
}
|
||||
|
||||
.bt-summary-value {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bt-list-signal-strip {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.bt-list-signal-title {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.45px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.bt-signal-dist-compact {
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bt-signal-dist-compact .signal-range {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bt-signal-dist-compact .signal-range span:first-child {
|
||||
width: 50px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.bt-signal-dist-compact .signal-range span:last-child {
|
||||
width: 22px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.bt-signal-dist-compact .signal-bar-bg {
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.bt-device-toolbar {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.bt-device-search {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
padding: 7px 8px;
|
||||
}
|
||||
|
||||
.bt-device-search:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Bluetooth Device Filters */
|
||||
.bt-device-filters {
|
||||
display: flex;
|
||||
@@ -4474,6 +4781,10 @@ header h1 .tagline {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-primary);
|
||||
position: sticky;
|
||||
top: 44px;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.bt-filter-btn {
|
||||
@@ -4498,6 +4809,112 @@ header h1 .tagline {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.bt-tracker-item {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: background 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bt-tracker-item:hover {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
.bt-tracker-item:focus-visible {
|
||||
outline: 1px solid var(--accent-cyan);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.bt-tracker-row-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bt-tracker-left,
|
||||
.bt-tracker-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bt-tracker-confidence {
|
||||
font-size: 9px;
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.bt-tracker-confidence-high .bt-tracker-confidence {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.bt-tracker-confidence-medium .bt-tracker-confidence {
|
||||
color: #f97316;
|
||||
background: rgba(249, 115, 22, 0.2);
|
||||
}
|
||||
|
||||
.bt-tracker-confidence-low .bt-tracker-confidence {
|
||||
color: #eab308;
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
}
|
||||
|
||||
.bt-tracker-type {
|
||||
font-size: 11px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bt-tracker-risk {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bt-risk-high {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.bt-risk-medium {
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.bt-risk-low {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.bt-tracker-rssi,
|
||||
.bt-tracker-seen {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.bt-tracker-row-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 3px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bt-tracker-address {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bt-tracker-evidence {
|
||||
margin-top: 3px;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Bluetooth Signal Distribution */
|
||||
.bt-signal-dist {
|
||||
display: flex;
|
||||
@@ -4567,11 +4984,20 @@ header h1 .tagline {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.bt-device-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.bt-device-row:hover {
|
||||
background: rgba(0, 212, 255, 0.05);
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.bt-device-row:focus-visible {
|
||||
outline: 1px solid var(--accent-cyan);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.bt-row-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -4709,6 +5135,50 @@ header h1 .tagline {
|
||||
padding: 4px 4px 0 42px;
|
||||
}
|
||||
|
||||
/* Locate action on Bluetooth device rows (must be in index.css so it styles in scanner mode) */
|
||||
.bt-row-actions .bt-locate-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
padding: 5px 10px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--accent-green, #38c180);
|
||||
background: linear-gradient(180deg, rgba(56, 193, 128, 0.2), rgba(56, 193, 128, 0.12));
|
||||
border: 1px solid rgba(56, 193, 128, 0.42);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.bt-row-actions .bt-locate-btn:hover {
|
||||
background: linear-gradient(180deg, rgba(56, 193, 128, 0.28), rgba(56, 193, 128, 0.18));
|
||||
border-color: rgba(56, 193, 128, 0.72);
|
||||
box-shadow: 0 0 0 1px rgba(56, 193, 128, 0.2), 0 6px 16px rgba(20, 80, 54, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.bt-row-actions .bt-locate-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.bt-row-actions .bt-locate-btn svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-device-filter-state {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Bluetooth Device Modal */
|
||||
.bt-modal-overlay {
|
||||
position: fixed;
|
||||
@@ -4919,14 +5389,31 @@ header h1 .tagline {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bt-layout-container .wifi-visuals {
|
||||
.bt-layout-container .bt-visuals-column {
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.bt-main-area {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bt-side-panels {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bt-tracker-list {
|
||||
max-height: 280px;
|
||||
}
|
||||
|
||||
.bt-device-list {
|
||||
width: 100%;
|
||||
min-width: auto;
|
||||
max-height: 300px;
|
||||
max-width: none;
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.bt-list-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5206,8 +5693,11 @@ body::before {
|
||||
}
|
||||
|
||||
.disclaimer-modal .warning-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 15px;
|
||||
display: block;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto 15px;
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.disclaimer-modal p {
|
||||
@@ -6370,7 +6860,7 @@ body::before {
|
||||
}
|
||||
|
||||
.module-header {
|
||||
font-family: 'Orbitron', 'Space Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
@@ -6548,7 +7038,7 @@ body::before {
|
||||
/* Listening Mode Selector Buttons */
|
||||
.radio-mode-btn {
|
||||
padding: 12px 24px;
|
||||
font-family: 'Orbitron', 'Space Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
@@ -6654,3 +7144,256 @@ body::before {
|
||||
[data-animations="off"] .welcome-logo {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
VISUAL REFRESH OVERRIDES
|
||||
============================================ */
|
||||
|
||||
:root {
|
||||
--visual-surface-soft: linear-gradient(180deg, rgba(18, 28, 40, 0.9) 0%, rgba(10, 16, 24, 0.95) 100%);
|
||||
--visual-surface-panel: linear-gradient(160deg, rgba(20, 33, 48, 0.95) 0%, rgba(11, 18, 27, 0.96) 100%);
|
||||
--visual-edge-cyan: rgba(74, 163, 255, 0.34);
|
||||
--visual-edge-green: rgba(56, 193, 128, 0.28);
|
||||
--visual-glow-soft: 0 14px 30px rgba(0, 0, 0, 0.32);
|
||||
--visual-glow-cyan: 0 0 24px rgba(74, 163, 255, 0.16);
|
||||
--mode-ambient-left: rgba(74, 163, 255, 0.12);
|
||||
--mode-ambient-right: rgba(56, 193, 128, 0.08);
|
||||
--mode-ambient-bottom: rgba(214, 168, 94, 0.05);
|
||||
--top-rail-gutter: 12px;
|
||||
--top-rail-gap: 6px;
|
||||
--top-rail-height: 44px;
|
||||
}
|
||||
|
||||
body {
|
||||
background-image:
|
||||
radial-gradient(1200px 560px at 8% -10%, var(--mode-ambient-left), transparent 60%),
|
||||
radial-gradient(900px 520px at 92% -18%, var(--mode-ambient-right), transparent 60%),
|
||||
radial-gradient(800px 440px at 50% 130%, var(--mode-ambient-bottom), transparent 65%),
|
||||
var(--noise-image),
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: auto, auto, auto, 40px 40px, 48px 48px, 48px 48px;
|
||||
}
|
||||
|
||||
body[data-mode="wifi"],
|
||||
body[data-mode="bluetooth"],
|
||||
body[data-mode="bt_locate"] {
|
||||
--mode-ambient-left: rgba(56, 193, 128, 0.14);
|
||||
--mode-ambient-right: rgba(74, 163, 255, 0.08);
|
||||
}
|
||||
|
||||
body[data-mode="satellite"],
|
||||
body[data-mode="weathersat"],
|
||||
body[data-mode="sstv"],
|
||||
body[data-mode="sstv_general"] {
|
||||
--mode-ambient-left: rgba(74, 163, 255, 0.14);
|
||||
--mode-ambient-right: rgba(143, 123, 214, 0.09);
|
||||
--mode-ambient-bottom: rgba(56, 193, 128, 0.05);
|
||||
}
|
||||
|
||||
body[data-mode="analytics"],
|
||||
body[data-mode="spystations"],
|
||||
body[data-mode="tscm"] {
|
||||
--mode-ambient-left: rgba(214, 168, 94, 0.12);
|
||||
--mode-ambient-right: rgba(74, 163, 255, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="light"] body {
|
||||
--mode-ambient-left: rgba(31, 95, 168, 0.09);
|
||||
--mode-ambient-right: rgba(31, 138, 87, 0.05);
|
||||
--mode-ambient-bottom: rgba(181, 134, 58, 0.04);
|
||||
}
|
||||
|
||||
.mode-nav {
|
||||
background: linear-gradient(180deg, rgba(22, 33, 48, 0.96) 0%, rgba(14, 22, 33, 0.98) 100%);
|
||||
border-bottom-color: rgba(74, 163, 255, 0.24);
|
||||
}
|
||||
|
||||
#mainNav.mode-nav {
|
||||
margin: var(--top-rail-gap) var(--top-rail-gutter) 0;
|
||||
padding: 0 12px;
|
||||
min-height: var(--top-rail-height);
|
||||
height: var(--top-rail-height);
|
||||
border: 1px solid rgba(74, 163, 255, 0.22);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--visual-glow-soft), inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.run-state-strip {
|
||||
margin: 8px var(--top-rail-gutter) 0;
|
||||
border-color: rgba(74, 163, 255, 0.3);
|
||||
background: linear-gradient(180deg, rgba(20, 31, 44, 0.96) 0%, rgba(12, 19, 29, 0.97) 100%);
|
||||
box-shadow: var(--visual-glow-soft), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
min-height: var(--top-rail-height);
|
||||
padding: 6px 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.run-state-strip .run-state-chip {
|
||||
min-height: 22px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.run-state-strip .run-state-btn {
|
||||
min-height: 26px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.run-state-strip .run-state-right {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin: 0 12px;
|
||||
border: 1px solid rgba(74, 163, 255, 0.22);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--visual-glow-soft), inset 0 0 0 1px rgba(255, 255, 255, 0.02);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: var(--visual-surface-soft);
|
||||
border-right-color: rgba(74, 163, 255, 0.22);
|
||||
}
|
||||
|
||||
.section {
|
||||
background: var(--visual-surface-panel);
|
||||
border-color: rgba(74, 163, 255, 0.22);
|
||||
border-radius: 8px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.section:hover {
|
||||
border-color: var(--visual-edge-cyan);
|
||||
box-shadow: var(--visual-glow-cyan), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
background: linear-gradient(180deg, rgba(28, 44, 63, 0.88) 0%, rgba(20, 31, 44, 0.9) 100%);
|
||||
border-bottom-color: rgba(74, 163, 255, 0.2);
|
||||
}
|
||||
|
||||
.section h3::before {
|
||||
background: linear-gradient(180deg, var(--accent-cyan) 0%, var(--accent-green) 100%);
|
||||
}
|
||||
|
||||
.section h3::after {
|
||||
background: rgba(12, 18, 28, 0.9);
|
||||
border: 1px solid rgba(74, 163, 255, 0.24);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
background: rgba(8, 13, 20, 0.72);
|
||||
border-color: rgba(74, 163, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.preset-btn,
|
||||
.control-btn,
|
||||
.clear-btn,
|
||||
.run-btn,
|
||||
.stop-btn {
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.preset-btn,
|
||||
.control-btn,
|
||||
.clear-btn {
|
||||
border-color: rgba(74, 163, 255, 0.24);
|
||||
background: linear-gradient(180deg, rgba(16, 24, 35, 0.88) 0%, rgba(10, 15, 24, 0.9) 100%);
|
||||
}
|
||||
|
||||
.output-panel {
|
||||
background: linear-gradient(180deg, rgba(8, 13, 19, 0.98) 0%, rgba(7, 11, 18, 0.99) 100%);
|
||||
}
|
||||
|
||||
.output-header {
|
||||
background: linear-gradient(180deg, rgba(18, 28, 42, 0.95) 0%, rgba(13, 21, 31, 0.98) 100%);
|
||||
border-bottom-color: rgba(74, 163, 255, 0.22);
|
||||
}
|
||||
|
||||
.output-content {
|
||||
background: linear-gradient(180deg, rgba(8, 13, 19, 0.6) 0%, rgba(8, 13, 19, 0.9) 100%);
|
||||
}
|
||||
|
||||
.stats > div {
|
||||
border-color: rgba(74, 163, 255, 0.2);
|
||||
background: linear-gradient(180deg, rgba(19, 28, 40, 0.8) 0%, rgba(12, 18, 27, 0.82) 100%);
|
||||
}
|
||||
|
||||
.message {
|
||||
border-color: rgba(74, 163, 255, 0.26);
|
||||
border-left-width: 4px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, rgba(21, 31, 44, 0.8) 0%, rgba(15, 23, 33, 0.82) 100%);
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 9;
|
||||
border-top-color: rgba(74, 163, 255, 0.24);
|
||||
background: linear-gradient(180deg, rgba(17, 26, 39, 0.96) 0%, rgba(10, 16, 24, 0.97) 100%);
|
||||
backdrop-filter: blur(7px);
|
||||
}
|
||||
|
||||
.status-indicator,
|
||||
.control-group {
|
||||
border-color: rgba(74, 163, 255, 0.2);
|
||||
background: linear-gradient(180deg, rgba(15, 23, 34, 0.78) 0%, rgba(9, 14, 23, 0.8) 100%);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.status-dot.running {
|
||||
box-shadow: 0 0 0 4px rgba(56, 193, 128, 0.15), 0 0 14px rgba(56, 193, 128, 0.4);
|
||||
}
|
||||
|
||||
.mode-content.active {
|
||||
animation: modePanelEntrance 220ms ease both;
|
||||
}
|
||||
|
||||
@keyframes modePanelEntrance {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="light"] .run-state-strip,
|
||||
[data-theme="light"] .main-content,
|
||||
[data-theme="light"] .section,
|
||||
[data-theme="light"] #mainNav.mode-nav,
|
||||
[data-theme="light"] .output-header,
|
||||
[data-theme="light"] .status-bar,
|
||||
[data-theme="light"] .status-indicator,
|
||||
[data-theme="light"] .control-group {
|
||||
box-shadow: 0 10px 24px rgba(18, 40, 66, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="light"] .section,
|
||||
[data-theme="light"] .stats > div,
|
||||
[data-theme="light"] .message,
|
||||
[data-theme="light"] .preset-btn,
|
||||
[data-theme="light"] .control-btn,
|
||||
[data-theme="light"] .clear-btn {
|
||||
border-color: rgba(31, 95, 168, 0.26);
|
||||
}
|
||||
|
||||
[data-animations="off"] .mode-content.active {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.run-state-strip {
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,23 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btl-hud-export-row {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btl-hud-export-format {
|
||||
min-width: 62px;
|
||||
padding: 3px 6px;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.btl-hud-audio-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -249,24 +266,115 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.btl-map-container {
|
||||
flex: 1;
|
||||
min-height: 250px;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
#btLocateMap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
.btl-map-overlay-controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 450;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 7px 8px;
|
||||
border-radius: 7px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.btl-map-overlay-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btl-map-overlay-toggle input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btl-map-overlay-toggle input[type="checkbox"]:disabled + span {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.btl-map-heat-legend {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 430;
|
||||
min-width: 120px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 7px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.btl-map-heat-label {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.7px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.btl-map-heat-bar {
|
||||
height: 7px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, #2563eb 0%, #16a34a 40%, #f59e0b 70%, #ef4444 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.btl-map-heat-scale {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 3px;
|
||||
font-size: 8px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.btl-map-track-stats {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 430;
|
||||
padding: 5px 8px;
|
||||
border-radius: 7px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.btl-rssi-chart-container {
|
||||
height: 100px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
@@ -426,5 +534,95 @@
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btl-hud-export-row {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btl-map-overlay-controls {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
gap: 3px;
|
||||
padding: 6px 7px;
|
||||
}
|
||||
|
||||
.btl-map-heat-legend {
|
||||
left: 8px;
|
||||
bottom: 8px;
|
||||
}
|
||||
|
||||
.btl-map-track-stats {
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Crosshair sweep animation ───────────────────────────────────── */
|
||||
.btl-crosshair-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
z-index: 1200;
|
||||
--btl-crosshair-x-start: 100%;
|
||||
--btl-crosshair-y-start: 100%;
|
||||
--btl-crosshair-x-end: 50%;
|
||||
--btl-crosshair-y-end: 50%;
|
||||
--btl-crosshair-duration: 1500ms;
|
||||
}
|
||||
|
||||
.btl-crosshair-line {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
background: var(--accent-cyan);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.btl-crosshair-vertical {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
left: 0;
|
||||
transform: translateX(var(--btl-crosshair-x-start));
|
||||
}
|
||||
|
||||
.btl-crosshair-horizontal {
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
top: 0;
|
||||
transform: translateY(var(--btl-crosshair-y-start));
|
||||
}
|
||||
|
||||
.btl-crosshair-overlay.active .btl-crosshair-vertical {
|
||||
animation: btlCrosshairSweepX var(--btl-crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
|
||||
}
|
||||
|
||||
.btl-crosshair-overlay.active .btl-crosshair-horizontal {
|
||||
animation: btlCrosshairSweepY var(--btl-crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes btlCrosshairSweepX {
|
||||
0% { transform: translateX(var(--btl-crosshair-x-start)); opacity: 0; }
|
||||
12% { opacity: 1; }
|
||||
85% { opacity: 1; }
|
||||
100% { transform: translateX(var(--btl-crosshair-x-end)); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes btlCrosshairSweepY {
|
||||
0% { transform: translateY(var(--btl-crosshair-y-start)); opacity: 0; }
|
||||
12% { opacity: 1; }
|
||||
85% { opacity: 1; }
|
||||
100% { transform: translateY(var(--btl-crosshair-y-end)); opacity: 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.btl-crosshair-overlay.active .btl-crosshair-vertical,
|
||||
.btl-crosshair-overlay.active .btl-crosshair-horizontal {
|
||||
animation-duration: 220ms;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,16 +139,67 @@
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.gps-skyview-canvas-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#gpsSkyCanvas {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.gps-skyview-canvas-wrap {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: min(100%, 430px);
|
||||
aspect-ratio: 1 / 1;
|
||||
margin: 0 auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#gpsSkyCanvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
#gpsSkyCanvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.gps-sky-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.gps-sky-label {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.2px;
|
||||
text-shadow: 0 0 6px rgba(0, 0, 0, 0.9);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gps-sky-label-cardinal {
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.gps-sky-label-sat {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gps-sky-label-sat.unused {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.gps-sky-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
/* Position info panel */
|
||||
.gps-position-panel {
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
/* Space Weather Mode Styles */
|
||||
|
||||
/* Main container */
|
||||
.sw-visuals-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Header metrics strip */
|
||||
.sw-header-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sw-header-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.sw-header-stat + .sw-header-stat {
|
||||
border-left: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sw-header-value {
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sw-header-label {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sw-header-value.accent-cyan { color: var(--accent-cyan); }
|
||||
.sw-header-value.accent-green { color: #00ff88; }
|
||||
.sw-header-value.accent-yellow { color: #ffcc00; }
|
||||
.sw-header-value.accent-orange { color: #ff8800; }
|
||||
.sw-header-value.accent-red { color: #ff3366; }
|
||||
|
||||
/* Refresh controls in strip */
|
||||
.sw-strip-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.sw-refresh-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.sw-refresh-btn:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.sw-last-update {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
}
|
||||
|
||||
/* NOAA G/S/R Scale cards */
|
||||
.sw-scales-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sw-scale-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sw-scale-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sw-scale-value {
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sw-scale-desc {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Scale severity colors */
|
||||
.sw-scale-0 { color: #00ff88; border-color: #00ff8833; }
|
||||
.sw-scale-1 { color: #88ff00; border-color: #88ff0033; }
|
||||
.sw-scale-2 { color: #ffcc00; border-color: #ffcc0033; }
|
||||
.sw-scale-3 { color: #ff8800; border-color: #ff880033; }
|
||||
.sw-scale-4 { color: #ff4400; border-color: #ff440033; }
|
||||
.sw-scale-5 { color: #ff0044; border-color: #ff004433; }
|
||||
|
||||
/* HF Band conditions grid */
|
||||
.sw-band-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sw-band-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sw-band-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto repeat(2, 1fr);
|
||||
gap: 4px 8px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
}
|
||||
|
||||
.sw-band-header {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sw-band-name {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sw-band-cond {
|
||||
text-align: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sw-band-good { color: #00ff88; background: #00ff8815; }
|
||||
.sw-band-fair { color: #ffcc00; background: #ffcc0015; }
|
||||
.sw-band-poor { color: #ff3366; background: #ff336615; }
|
||||
|
||||
/* 2-column dashboard grid */
|
||||
.sw-dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Chart containers */
|
||||
.sw-chart-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sw-chart-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sw-chart-wrap {
|
||||
position: relative;
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
.sw-chart-wrap canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Flare probability table */
|
||||
.sw-prob-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
}
|
||||
|
||||
.sw-prob-table th {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
text-align: left;
|
||||
padding: 4px 6px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sw-prob-table td {
|
||||
padding: 4px 6px;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Solar image gallery */
|
||||
.sw-image-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sw-image-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sw-image-tab {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-dim);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sw-image-tab:hover {
|
||||
border-color: var(--text-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sw-image-tab.active {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
background: var(--accent-cyan)10;
|
||||
}
|
||||
|
||||
.sw-image-frame {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sw-image-frame img {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* D-RAP frequency selector */
|
||||
.sw-drap-freqs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sw-drap-freq-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-dim);
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sw-drap-freq-btn:hover {
|
||||
border-color: var(--text-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sw-drap-freq-btn.active {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Alerts list */
|
||||
.sw-alerts-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sw-alert-item {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
}
|
||||
|
||||
.sw-alert-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sw-alert-type {
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.sw-alert-time {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sw-alert-msg {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Active regions table */
|
||||
.sw-regions-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sw-regions-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
}
|
||||
|
||||
.sw-regions-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
text-align: left;
|
||||
padding: 4px 6px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.sw-regions-table td {
|
||||
padding: 4px 6px;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Empty / loading states */
|
||||
.sw-empty {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
}
|
||||
|
||||
.sw-loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sw-loading::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-top-color: var(--accent-cyan);
|
||||
border-radius: 50%;
|
||||
animation: sw-spin 0.8s linear infinite;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes sw-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Full-width card */
|
||||
.sw-full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.sw-dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sw-scales-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sw-header-strip {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sw-header-stat {
|
||||
padding: 4px 8px;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.sw-header-value {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-tertiary, #1a1f2e);
|
||||
border-radius: 4px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary, #1a1f2e);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
@@ -113,7 +113,7 @@
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-dim, #666);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
@@ -153,7 +153,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary, #999);
|
||||
text-transform: uppercase;
|
||||
@@ -168,7 +168,7 @@
|
||||
}
|
||||
|
||||
.subghz-trigger-grid label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
text-transform: uppercase;
|
||||
@@ -182,7 +182,7 @@
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary, #0d1117);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
|
||||
.subghz-trigger-help {
|
||||
margin: 0;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
line-height: 1.4;
|
||||
@@ -207,7 +207,7 @@
|
||||
background: var(--bg-tertiary, #1a1f2e);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
@@ -369,7 +369,7 @@
|
||||
background: var(--bg-tertiary, #1a1f2e);
|
||||
border: 1px solid var(--border-color, #2a3040);
|
||||
border-radius: 4px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 11px;
|
||||
min-width: 0;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
|
||||
@@ -416,7 +416,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
@@ -446,7 +446,7 @@
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-color, #2a3040);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.35px;
|
||||
color: var(--text-dim, #666);
|
||||
@@ -512,7 +512,7 @@
|
||||
color: var(--text-dim, #666);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.subghz-capture-actions button:hover {
|
||||
@@ -554,7 +554,7 @@
|
||||
border-radius: 4px;
|
||||
color: var(--accent-red, #ff4444);
|
||||
font-size: 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
@@ -591,7 +591,7 @@
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
min-height: 200px;
|
||||
@@ -695,12 +695,12 @@
|
||||
.subghz-tx-modal .tx-freq {
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
font-weight: 600;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.subghz-tx-modal .tx-duration {
|
||||
color: var(--text-dim, #666);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.subghz-tx-segment-box {
|
||||
@@ -742,7 +742,7 @@
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary, #0d1117);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -755,7 +755,7 @@
|
||||
margin-bottom: 0 !important;
|
||||
font-size: 11px !important;
|
||||
color: var(--accent-cyan, #00d4ff) !important;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.subghz-tx-burst-assist {
|
||||
@@ -768,7 +768,7 @@
|
||||
}
|
||||
|
||||
.subghz-tx-burst-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
text-transform: uppercase;
|
||||
@@ -805,7 +805,7 @@
|
||||
|
||||
.subghz-tx-burst-range {
|
||||
margin: 0 0 8px 0;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 10px;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
@@ -839,7 +839,7 @@
|
||||
padding: 6px;
|
||||
border: 1px dashed var(--border-color, #2a3040);
|
||||
border-radius: 4px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
line-height: 1.4;
|
||||
@@ -854,7 +854,7 @@
|
||||
border: 1px solid var(--border-color, #2a3040);
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary, #999);
|
||||
}
|
||||
@@ -865,7 +865,7 @@
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: #00d4ff;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -884,7 +884,7 @@
|
||||
.subghz-tx-modal-actions button {
|
||||
padding: 8px 20px;
|
||||
border-radius: 4px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
border: 1px solid;
|
||||
@@ -926,7 +926,7 @@
|
||||
color: var(--text-dim, #666);
|
||||
font-size: 12px;
|
||||
padding: 24px 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.subghz-captures-list-main .subghz-empty {
|
||||
@@ -943,7 +943,7 @@
|
||||
border: 1px solid #2a3040;
|
||||
border-radius: 4px;
|
||||
padding: 5px 9px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 11px;
|
||||
z-index: 9999;
|
||||
display: none;
|
||||
@@ -970,7 +970,7 @@
|
||||
min-width: 180px;
|
||||
padding: 4px 0;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.6);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -1029,7 +1029,7 @@
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||
@@ -1068,7 +1068,7 @@
|
||||
content: 'No peaks detected';
|
||||
color: var(--text-dim, #666);
|
||||
font-size: 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
padding: 6px 0;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1082,7 +1082,7 @@
|
||||
border: 1px solid var(--border-color, #2a3040);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 10px;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
@@ -1108,7 +1108,7 @@
|
||||
border: 1px solid var(--border-color, #2a3040);
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
@@ -1192,7 +1192,7 @@
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: #22c55e;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
@@ -1211,7 +1211,7 @@
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border-color, #2a3040);
|
||||
border-radius: 3px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary, #999);
|
||||
letter-spacing: 0.3px;
|
||||
@@ -1263,7 +1263,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@@ -1300,7 +1300,7 @@
|
||||
border-radius: 999px;
|
||||
margin-left: auto;
|
||||
margin-right: 8px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
@@ -1365,7 +1365,7 @@
|
||||
padding: 6px 12px;
|
||||
overflow-y: auto;
|
||||
max-height: 114px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 10px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -1402,7 +1402,7 @@
|
||||
}
|
||||
|
||||
.subghz-hub-header-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
@@ -1410,7 +1410,7 @@
|
||||
}
|
||||
|
||||
.subghz-hub-header-sub {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #666);
|
||||
margin-top: 2px;
|
||||
@@ -1472,14 +1472,14 @@
|
||||
}
|
||||
|
||||
.subghz-hub-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.subghz-hub-desc {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
@@ -1526,7 +1526,7 @@
|
||||
}
|
||||
|
||||
.subghz-saved-selection-count {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 10px;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
margin-right: 4px;
|
||||
@@ -1538,7 +1538,7 @@
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #999);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
@@ -1550,7 +1550,7 @@
|
||||
}
|
||||
|
||||
.subghz-op-panel-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
text-transform: uppercase;
|
||||
@@ -1620,7 +1620,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-red, #ff4444);
|
||||
@@ -1654,14 +1654,14 @@
|
||||
}
|
||||
|
||||
.subghz-rx-info-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.subghz-rx-info-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
@@ -1688,7 +1688,7 @@
|
||||
border: 1px solid var(--border-color, #2a3040);
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.subghz-rx-hint-label {
|
||||
@@ -1722,7 +1722,7 @@
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border-color, #2a3040);
|
||||
border-radius: 999px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
@@ -1741,7 +1741,7 @@
|
||||
}
|
||||
|
||||
.subghz-rx-level-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
letter-spacing: 0.5px;
|
||||
@@ -1772,7 +1772,7 @@
|
||||
}
|
||||
|
||||
.subghz-rx-scope-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
letter-spacing: 0.5px;
|
||||
@@ -1832,7 +1832,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
letter-spacing: 0.4px;
|
||||
@@ -1854,7 +1854,7 @@
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #999);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
@@ -1938,7 +1938,7 @@
|
||||
}
|
||||
|
||||
.subghz-tx-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-red, #ff4444);
|
||||
@@ -1958,14 +1958,14 @@
|
||||
}
|
||||
|
||||
.subghz-tx-info-label {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.subghz-tx-info-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
@@ -1998,7 +1998,7 @@
|
||||
}
|
||||
|
||||
.subghz-sweep-peaks-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
text-transform: uppercase;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/* VDL2 Mode Styles */
|
||||
|
||||
/* VDL2 Status Indicator */
|
||||
.vdl2-status-dot.listening {
|
||||
background: var(--accent-cyan) !important;
|
||||
animation: vdl2-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.vdl2-status-dot.receiving {
|
||||
background: var(--accent-green) !important;
|
||||
}
|
||||
.vdl2-status-dot.error {
|
||||
background: var(--accent-red) !important;
|
||||
}
|
||||
@keyframes vdl2-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
|
||||
50% { opacity: 0.6; box-shadow: 0 0 6px 3px rgba(74, 158, 255, 0.3); }
|
||||
}
|
||||
|
||||
/* VDL2 message animation */
|
||||
.vdl2-msg {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
animation: vdl2FadeIn 0.3s ease;
|
||||
}
|
||||
.vdl2-msg:hover {
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
}
|
||||
@keyframes vdl2FadeIn {
|
||||
from { opacity: 0; transform: translateY(-3px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@@ -49,7 +49,7 @@
|
||||
.wxsat-strip-status-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #999);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.wxsat-strip-btn {
|
||||
@@ -59,7 +59,7 @@
|
||||
background: transparent;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 11px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
@@ -92,7 +92,7 @@
|
||||
|
||||
.wxsat-strip-value {
|
||||
font-size: 13px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
color: var(--text-dim, #666);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
@@ -146,7 +146,7 @@
|
||||
border-radius: 3px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 11px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.wxsat-loc-input:focus {
|
||||
@@ -225,7 +225,7 @@
|
||||
.wxsat-cd-value {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -248,13 +248,13 @@
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.wxsat-countdown-detail {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
/* ===== Timeline ===== */
|
||||
@@ -314,7 +314,7 @@
|
||||
justify-content: space-between;
|
||||
font-size: 8px;
|
||||
color: var(--text-dim, #666);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
/* ===== Pass Predictions Panel ===== */
|
||||
@@ -349,7 +349,7 @@
|
||||
.wxsat-passes-count {
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.wxsat-passes-list {
|
||||
@@ -387,7 +387,7 @@
|
||||
background: rgba(255, 187, 0, 0.15);
|
||||
color: #ffbb00;
|
||||
margin-left: 6px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
@@ -409,7 +409,7 @@
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.wxsat-pass-mode.apt {
|
||||
@@ -428,7 +428,7 @@
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #666);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.wxsat-pass-detail-label {
|
||||
@@ -499,7 +499,7 @@
|
||||
.wxsat-panel-subtitle {
|
||||
font-size: 10px;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
#wxsatPolarCanvas {
|
||||
@@ -509,10 +509,95 @@
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.wxsat-ground-map {
|
||||
height: 200px;
|
||||
background: var(--bg-primary, #0d1117);
|
||||
}
|
||||
.wxsat-ground-map {
|
||||
position: relative;
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, #061329 0%, #050d1a 54%, #061325 100%);
|
||||
}
|
||||
|
||||
.wxsat-ground-map .leaflet-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.leaflet-container.map-theme-cyber .leaflet-overlay-pane path.wxsat-pass-track {
|
||||
filter: drop-shadow(0 0 5px rgba(91, 240, 255, 0.35));
|
||||
}
|
||||
|
||||
.leaflet-container.map-theme-cyber .leaflet-overlay-pane path.wxsat-pass-track.lrpt {
|
||||
filter: drop-shadow(0 0 6px rgba(0, 255, 190, 0.35));
|
||||
}
|
||||
|
||||
.wxsat-crosshair-icon {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.wxsat-crosshair-marker {
|
||||
position: relative;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.wxsat-crosshair-h,
|
||||
.wxsat-crosshair-v,
|
||||
.wxsat-crosshair-ring,
|
||||
.wxsat-crosshair-dot {
|
||||
position: absolute;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wxsat-crosshair-h {
|
||||
top: 50%;
|
||||
left: 2px;
|
||||
right: 2px;
|
||||
height: 1px;
|
||||
background: rgba(255, 93, 93, 0.95);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.wxsat-crosshair-v {
|
||||
left: 50%;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
width: 1px;
|
||||
background: rgba(255, 93, 93, 0.95);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.wxsat-crosshair-ring {
|
||||
inset: 6px;
|
||||
border: 1.5px solid rgba(255, 93, 93, 0.95);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 10px rgba(255, 93, 93, 0.55);
|
||||
}
|
||||
|
||||
.wxsat-crosshair-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
border-radius: 50%;
|
||||
background: #ffa0a0;
|
||||
box-shadow: 0 0 6px rgba(255, 100, 100, 0.65);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.wxsat-map-tooltip {
|
||||
background: rgba(5, 15, 32, 0.92);
|
||||
border: 1px solid rgba(102, 229, 255, 0.65);
|
||||
border-radius: 4px;
|
||||
color: #8fe8ff;
|
||||
box-shadow: 0 0 12px rgba(0, 210, 255, 0.24);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.25px;
|
||||
}
|
||||
|
||||
.wxsat-map-tooltip.leaflet-tooltip-top:before {
|
||||
border-top-color: rgba(102, 229, 255, 0.65);
|
||||
}
|
||||
|
||||
/* ===== Image Gallery Panel ===== */
|
||||
.wxsat-gallery-panel {
|
||||
@@ -547,7 +632,7 @@
|
||||
.wxsat-gallery-count {
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.wxsat-gallery-grid {
|
||||
@@ -636,7 +721,7 @@
|
||||
.wxsat-image-product {
|
||||
font-size: 10px;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.wxsat-image-timestamp {
|
||||
@@ -649,7 +734,7 @@
|
||||
.wxsat-date-header {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 11px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
color: var(--text-dim, #666);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
@@ -708,7 +793,7 @@
|
||||
.wxsat-capture-message {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #999);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -719,7 +804,7 @@
|
||||
.wxsat-capture-elapsed {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #666);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -785,7 +870,7 @@
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary, #999);
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -941,7 +1026,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
}
|
||||
|
||||
.wxsat-phase-step {
|
||||
@@ -1012,7 +1097,7 @@
|
||||
max-height: 160px;
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-primary, #0d1117);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||
font-size: 10px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -428,7 +428,7 @@
|
||||
|
||||
/* Visual panels should be scrollable, not clipped */
|
||||
.wifi-visuals,
|
||||
.bt-visuals {
|
||||
.bt-visuals-column {
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
margin-bottom: 15px;
|
||||
@@ -444,7 +444,7 @@
|
||||
|
||||
/* Visual panels should stack in single column on mobile when visible */
|
||||
.wifi-visuals,
|
||||
.bt-visuals {
|
||||
.bt-visuals-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
@@ -465,6 +465,34 @@
|
||||
.wifi-visual-panel {
|
||||
grid-column: auto !important;
|
||||
}
|
||||
|
||||
.bt-main-area {
|
||||
flex-direction: column !important;
|
||||
min-height: auto !important;
|
||||
}
|
||||
|
||||
.bt-side-panels {
|
||||
width: 100% !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.bt-detail-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||
}
|
||||
|
||||
.bt-row-secondary {
|
||||
padding-left: 0 !important;
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
.bt-row-actions {
|
||||
padding-left: 0 !important;
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
|
||||
.bt-list-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== MOBILE MAP FIXES ============== */
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
||||
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--bg-dark: #0b1118;
|
||||
--bg-panel: #101823;
|
||||
--bg-card: #151f2b;
|
||||
|
||||
@@ -479,6 +479,54 @@
|
||||
filter: sepia(0.35) hue-rotate(185deg) saturate(1.75) brightness(1.06) contrast(1.05);
|
||||
}
|
||||
|
||||
/* Global Leaflet map theme: cyber overlay */
|
||||
.leaflet-container.map-theme-cyber {
|
||||
position: relative;
|
||||
background: #020813;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.leaflet-container.map-theme-cyber .leaflet-tile-pane {
|
||||
filter: sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Hard global fallback: enforce cyber tint on all Leaflet tile images */
|
||||
html.map-cyber-enabled .leaflet-container .leaflet-tile {
|
||||
filter: sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08) !important;
|
||||
}
|
||||
|
||||
/* Hard global fallback: cyber glow + grid overlay */
|
||||
html.map-cyber-enabled .leaflet-container {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
html.map-cyber-enabled .leaflet-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 620;
|
||||
background:
|
||||
radial-gradient(95% 78% at 50% 44%, rgba(18, 170, 255, 0.17), rgba(18, 170, 255, 0) 64%),
|
||||
linear-gradient(180deg, rgba(24, 118, 255, 0.045), rgba(24, 118, 255, 0));
|
||||
}
|
||||
|
||||
html.map-cyber-enabled .leaflet-container::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 621;
|
||||
opacity: 0.42;
|
||||
mix-blend-mode: screen;
|
||||
background-image:
|
||||
linear-gradient(rgba(78, 188, 255, 0.14) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(78, 188, 255, 0.14) 1px, transparent 1px);
|
||||
background-size: 52px 52px, 52px 52px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 960px) {
|
||||
.settings-tabs {
|
||||
|
||||
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 919 B |
|
After Width: | Height: | Size: 5.9 KiB |
|
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 |
|
After Width: | Height: | Size: 93 KiB |
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Activity Timeline Component
|
||||
* Reusable, configuration-driven timeline visualization for time-based metadata
|
||||
* Supports multiple modes: TSCM, Listening Post, Bluetooth, WiFi, Monitoring
|
||||
* Supports multiple modes: TSCM, RF Receiver, Bluetooth, WiFi, Monitoring
|
||||
*/
|
||||
|
||||
const ActivityTimeline = (function() {
|
||||
@@ -176,7 +176,7 @@ const ActivityTimeline = (function() {
|
||||
*/
|
||||
function categorizeById(id, mode) {
|
||||
// RF frequency categorization
|
||||
if (mode === 'rf' || mode === 'tscm' || mode === 'listening-post') {
|
||||
if (mode === 'rf' || mode === 'tscm' || mode === 'waterfall') {
|
||||
const f = parseFloat(id);
|
||||
if (!isNaN(f)) {
|
||||
if (f >= 2400 && f <= 2500) return '2.4 GHz wireless band';
|
||||
|
||||
@@ -33,10 +33,7 @@ const ProximityRadar = (function() {
|
||||
let activeFilter = null;
|
||||
let onDeviceClick = null;
|
||||
let selectedDeviceKey = null;
|
||||
let isHovered = false;
|
||||
let renderPending = false;
|
||||
let renderTimer = null;
|
||||
let interactionLockUntil = 0; // timestamp: suppress renders briefly after click
|
||||
|
||||
/**
|
||||
* Initialize the radar component
|
||||
@@ -128,28 +125,10 @@ const ProximityRadar = (function() {
|
||||
if (!deviceEl) return;
|
||||
const deviceKey = deviceEl.getAttribute('data-device-key');
|
||||
if (onDeviceClick && deviceKey) {
|
||||
// Lock out re-renders briefly so the DOM stays stable after click
|
||||
interactionLockUntil = Date.now() + 500;
|
||||
onDeviceClick(deviceKey);
|
||||
}
|
||||
});
|
||||
|
||||
devicesGroup.addEventListener('mouseenter', (e) => {
|
||||
if (e.target.closest('.radar-device')) {
|
||||
isHovered = true;
|
||||
}
|
||||
}, true); // capture phase so we catch enter on child elements
|
||||
|
||||
devicesGroup.addEventListener('mouseleave', (e) => {
|
||||
if (e.target.closest('.radar-device')) {
|
||||
isHovered = false;
|
||||
if (renderPending) {
|
||||
renderPending = false;
|
||||
renderDevices();
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Add sweep animation
|
||||
animateSweep();
|
||||
}
|
||||
@@ -191,17 +170,10 @@ const ProximityRadar = (function() {
|
||||
function updateDevices(deviceList) {
|
||||
if (isPaused) return;
|
||||
|
||||
// Update device map
|
||||
deviceList.forEach(device => {
|
||||
devices.set(device.device_key, device);
|
||||
});
|
||||
|
||||
// Defer render while user is hovering or interacting to prevent DOM rebuild flicker
|
||||
if (isHovered || Date.now() < interactionLockUntil) {
|
||||
renderPending = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce rapid updates (e.g. per-device SSE events)
|
||||
if (renderTimer) clearTimeout(renderTimer);
|
||||
renderTimer = setTimeout(() => {
|
||||
@@ -211,7 +183,9 @@ const ProximityRadar = (function() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render device dots on the radar
|
||||
* Render device dots on the radar using in-place DOM updates.
|
||||
* Elements are never destroyed and recreated — only their attributes and
|
||||
* transforms are mutated — so hover state is never disturbed by a render.
|
||||
*/
|
||||
function renderDevices() {
|
||||
const devicesGroup = svg.querySelector('.radar-devices');
|
||||
@@ -219,6 +193,7 @@ const ProximityRadar = (function() {
|
||||
|
||||
const center = CONFIG.size / 2;
|
||||
const maxRadius = center - CONFIG.padding;
|
||||
const ns = 'http://www.w3.org/2000/svg';
|
||||
|
||||
// Filter devices
|
||||
let visibleDevices = Array.from(devices.values());
|
||||
@@ -234,69 +209,195 @@ const ProximityRadar = (function() {
|
||||
visibleDevices = visibleDevices.filter(d => !d.in_baseline);
|
||||
}
|
||||
|
||||
// Build SVG for each device
|
||||
const dots = visibleDevices.map(device => {
|
||||
// Calculate position
|
||||
const { x, y, radius } = calculateDevicePosition(device, center, maxRadius);
|
||||
const visibleKeys = new Set(visibleDevices.map(d => d.device_key));
|
||||
|
||||
// Calculate dot size based on confidence
|
||||
// Remove elements for devices no longer in the visible set
|
||||
devicesGroup.querySelectorAll('.radar-device-wrapper').forEach(el => {
|
||||
if (!visibleKeys.has(el.getAttribute('data-device-key'))) {
|
||||
el.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Sort weakest signal first so strongest renders on top (SVG z-order)
|
||||
visibleDevices.sort((a, b) => (a.rssi_current || -100) - (b.rssi_current || -100));
|
||||
|
||||
// Compute all positions upfront so we can spread overlapping dots
|
||||
const posMap = new Map();
|
||||
visibleDevices.forEach(device => {
|
||||
posMap.set(device.device_key, calculateDevicePosition(device, center, maxRadius));
|
||||
});
|
||||
|
||||
// Spread dots that land too close together within the same band.
|
||||
// minGapPx = diameter of largest possible hit area + 2px breathing room.
|
||||
const maxHitArea = CONFIG.dotMaxSize + 4;
|
||||
spreadOverlappingDots(Array.from(posMap.values()), center, maxHitArea * 2 + 2);
|
||||
|
||||
visibleDevices.forEach(device => {
|
||||
const { x, y } = posMap.get(device.device_key);
|
||||
const confidence = device.distance_confidence || 0.5;
|
||||
const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence;
|
||||
|
||||
// Get color based on proximity band
|
||||
const color = getBandColor(device.proximity_band);
|
||||
|
||||
// Check if newly seen (pulse animation)
|
||||
const isNew = device.age_seconds < 5;
|
||||
const pulseClass = isNew ? 'radar-dot-pulse' : '';
|
||||
const isSelected = selectedDeviceKey && device.device_key === selectedDeviceKey;
|
||||
const isSelected = !!(selectedDeviceKey && device.device_key === selectedDeviceKey);
|
||||
const hitAreaSize = dotSize + 4;
|
||||
const key = device.device_key;
|
||||
|
||||
// Hit area size (prevents hover flicker when scaling)
|
||||
const hitAreaSize = Math.max(dotSize * 2, 15);
|
||||
const existing = devicesGroup.querySelector(
|
||||
`.radar-device-wrapper[data-device-key="${CSS.escape(key)}"]`
|
||||
);
|
||||
|
||||
return `
|
||||
<g transform="translate(${x}, ${y})">
|
||||
<g class="radar-device ${pulseClass}${isSelected ? ' selected' : ''}" data-device-key="${escapeAttr(device.device_key)}"
|
||||
style="cursor: pointer;">
|
||||
<!-- Invisible hit area to prevent hover flicker -->
|
||||
<circle class="radar-device-hitarea" r="${hitAreaSize}" fill="transparent" />
|
||||
${isSelected ? `<circle class="radar-select-ring" r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
|
||||
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
|
||||
</circle>` : ''}
|
||||
<circle r="${dotSize}" fill="${color}"
|
||||
fill-opacity="${isSelected ? 1 : 0.4 + confidence * 0.5}"
|
||||
stroke="${isSelected ? '#00d4ff' : color}" stroke-width="${isSelected ? 2 : 1}" />
|
||||
${device.is_new && !isSelected ? `<circle r="${dotSize + 3}" fill="none" stroke="#3b82f6" stroke-width="1" stroke-dasharray="2,2" />` : ''}
|
||||
<title>${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)</title>
|
||||
</g>
|
||||
</g>
|
||||
`;
|
||||
}).join('');
|
||||
if (existing) {
|
||||
// ── In-place update: mutate attributes, never recreate ──
|
||||
existing.setAttribute('transform', `translate(${x}, ${y})`);
|
||||
|
||||
devicesGroup.innerHTML = dots;
|
||||
const innerG = existing.querySelector('.radar-device');
|
||||
if (innerG) {
|
||||
innerG.className.baseVal =
|
||||
`radar-device${isNew ? ' radar-dot-pulse' : ''}${isSelected ? ' selected' : ''}`;
|
||||
|
||||
const hitArea = innerG.querySelector('.radar-device-hitarea');
|
||||
if (hitArea) hitArea.setAttribute('r', hitAreaSize);
|
||||
|
||||
const dot = innerG.querySelector('.radar-dot');
|
||||
if (dot) {
|
||||
dot.setAttribute('r', dotSize);
|
||||
dot.setAttribute('fill', color);
|
||||
dot.setAttribute('fill-opacity', isSelected ? 1 : 0.4 + confidence * 0.5);
|
||||
dot.setAttribute('stroke', isSelected ? '#00d4ff' : color);
|
||||
dot.setAttribute('stroke-width', isSelected ? 2 : 1);
|
||||
}
|
||||
|
||||
const title = innerG.querySelector('title');
|
||||
if (title) {
|
||||
title.textContent =
|
||||
`${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)`;
|
||||
}
|
||||
|
||||
// Selection ring: add if newly selected, remove if deselected
|
||||
let ring = innerG.querySelector('.radar-select-ring');
|
||||
if (isSelected && !ring) {
|
||||
ring = buildSelectRing(ns, dotSize);
|
||||
const hitAreaEl = innerG.querySelector('.radar-device-hitarea');
|
||||
innerG.insertBefore(ring, hitAreaEl ? hitAreaEl.nextSibling : innerG.firstChild);
|
||||
} else if (!isSelected && ring) {
|
||||
ring.remove();
|
||||
}
|
||||
|
||||
// New-device indicator ring
|
||||
let newRing = innerG.querySelector('.radar-new-ring');
|
||||
if (device.is_new && !isSelected) {
|
||||
if (!newRing) {
|
||||
newRing = document.createElementNS(ns, 'circle');
|
||||
newRing.classList.add('radar-new-ring');
|
||||
newRing.setAttribute('fill', 'none');
|
||||
newRing.setAttribute('stroke', '#3b82f6');
|
||||
newRing.setAttribute('stroke-width', '1');
|
||||
newRing.setAttribute('stroke-dasharray', '2,2');
|
||||
innerG.appendChild(newRing);
|
||||
}
|
||||
newRing.setAttribute('r', dotSize + 3);
|
||||
} else if (newRing) {
|
||||
newRing.remove();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── Create new element ──
|
||||
const wrapperG = document.createElementNS(ns, 'g');
|
||||
wrapperG.classList.add('radar-device-wrapper');
|
||||
wrapperG.setAttribute('data-device-key', key);
|
||||
wrapperG.setAttribute('transform', `translate(${x}, ${y})`);
|
||||
|
||||
const innerG = document.createElementNS(ns, 'g');
|
||||
innerG.classList.add('radar-device');
|
||||
if (isNew) innerG.classList.add('radar-dot-pulse');
|
||||
if (isSelected) innerG.classList.add('selected');
|
||||
innerG.setAttribute('data-device-key', escapeAttr(key));
|
||||
innerG.style.cursor = 'pointer';
|
||||
|
||||
const hitArea = document.createElementNS(ns, 'circle');
|
||||
hitArea.classList.add('radar-device-hitarea');
|
||||
hitArea.setAttribute('r', hitAreaSize);
|
||||
hitArea.setAttribute('fill', 'transparent');
|
||||
innerG.appendChild(hitArea);
|
||||
|
||||
if (isSelected) {
|
||||
innerG.appendChild(buildSelectRing(ns, dotSize));
|
||||
}
|
||||
|
||||
const dot = document.createElementNS(ns, 'circle');
|
||||
dot.classList.add('radar-dot');
|
||||
dot.setAttribute('r', dotSize);
|
||||
dot.setAttribute('fill', color);
|
||||
dot.setAttribute('fill-opacity', isSelected ? 1 : 0.4 + confidence * 0.5);
|
||||
dot.setAttribute('stroke', isSelected ? '#00d4ff' : color);
|
||||
dot.setAttribute('stroke-width', isSelected ? 2 : 1);
|
||||
innerG.appendChild(dot);
|
||||
|
||||
if (device.is_new && !isSelected) {
|
||||
const newRing = document.createElementNS(ns, 'circle');
|
||||
newRing.classList.add('radar-new-ring');
|
||||
newRing.setAttribute('r', dotSize + 3);
|
||||
newRing.setAttribute('fill', 'none');
|
||||
newRing.setAttribute('stroke', '#3b82f6');
|
||||
newRing.setAttribute('stroke-width', '1');
|
||||
newRing.setAttribute('stroke-dasharray', '2,2');
|
||||
innerG.appendChild(newRing);
|
||||
}
|
||||
|
||||
const title = document.createElementNS(ns, 'title');
|
||||
title.textContent =
|
||||
`${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)`;
|
||||
innerG.appendChild(title);
|
||||
|
||||
wrapperG.appendChild(innerG);
|
||||
devicesGroup.appendChild(wrapperG);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an animated SVG selection ring element
|
||||
*/
|
||||
function buildSelectRing(ns, dotSize) {
|
||||
const ring = document.createElementNS(ns, 'circle');
|
||||
ring.classList.add('radar-select-ring');
|
||||
ring.setAttribute('r', dotSize + 8);
|
||||
ring.setAttribute('fill', 'none');
|
||||
ring.setAttribute('stroke', '#00d4ff');
|
||||
ring.setAttribute('stroke-width', '2');
|
||||
ring.setAttribute('stroke-opacity', '0.8');
|
||||
|
||||
const animR = document.createElementNS(ns, 'animate');
|
||||
animR.setAttribute('attributeName', 'r');
|
||||
animR.setAttribute('values', `${dotSize + 6};${dotSize + 10};${dotSize + 6}`);
|
||||
animR.setAttribute('dur', '1.5s');
|
||||
animR.setAttribute('repeatCount', 'indefinite');
|
||||
ring.appendChild(animR);
|
||||
|
||||
const animO = document.createElementNS(ns, 'animate');
|
||||
animO.setAttribute('attributeName', 'stroke-opacity');
|
||||
animO.setAttribute('values', '0.8;0.4;0.8');
|
||||
animO.setAttribute('dur', '1.5s');
|
||||
animO.setAttribute('repeatCount', 'indefinite');
|
||||
ring.appendChild(animO);
|
||||
|
||||
return ring;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate device position on radar
|
||||
*/
|
||||
function calculateDevicePosition(device, center, maxRadius) {
|
||||
// Calculate radius based on proximity band/distance
|
||||
// Position is band-only — the band is computed server-side from rssi_ema
|
||||
// (already smoothed), so it changes infrequently and never jitters.
|
||||
// Using raw estimated_distance_m caused constant micro-movement as RSSI
|
||||
// fluctuated on every update cycle.
|
||||
let radiusRatio;
|
||||
const band = device.proximity_band || 'unknown';
|
||||
|
||||
if (device.estimated_distance_m != null) {
|
||||
// Use actual distance (log scale)
|
||||
const maxDistance = 15;
|
||||
radiusRatio = Math.min(1, Math.log10(device.estimated_distance_m + 1) / Math.log10(maxDistance + 1));
|
||||
} else {
|
||||
// Use band-based positioning
|
||||
switch (band) {
|
||||
case 'immediate': radiusRatio = 0.15; break;
|
||||
case 'near': radiusRatio = 0.4; break;
|
||||
case 'far': radiusRatio = 0.7; break;
|
||||
default: radiusRatio = 0.9; break;
|
||||
}
|
||||
switch (device.proximity_band || 'unknown') {
|
||||
case 'immediate': radiusRatio = 0.15; break;
|
||||
case 'near': radiusRatio = 0.40; break;
|
||||
case 'far': radiusRatio = 0.70; break;
|
||||
default: radiusRatio = 0.90; break;
|
||||
}
|
||||
|
||||
// Calculate angle based on device key hash (stable positioning)
|
||||
@@ -306,7 +407,53 @@ const ProximityRadar = (function() {
|
||||
const x = center + Math.sin(angle) * radius;
|
||||
const y = center - Math.cos(angle) * radius;
|
||||
|
||||
return { x, y, radius };
|
||||
return { x, y, angle, radius };
|
||||
}
|
||||
|
||||
/**
|
||||
* Spread dots within the same band that land too close together.
|
||||
* Groups entries by radius, sorts by angle, then nudges neighbours
|
||||
* apart until the arc gap between any two dots is at least minGapPx.
|
||||
* Positions are updated in-place on the entry objects.
|
||||
*/
|
||||
function spreadOverlappingDots(entries, center, minGapPx) {
|
||||
const groups = new Map();
|
||||
entries.forEach(e => {
|
||||
const key = Math.round(e.radius);
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(e);
|
||||
});
|
||||
|
||||
groups.forEach((group, r) => {
|
||||
if (group.length < 2 || r < 1) return;
|
||||
const minSep = minGapPx / r; // radians
|
||||
|
||||
group.sort((a, b) => a.angle - b.angle);
|
||||
|
||||
// Iterative push-apart (up to 8 passes)
|
||||
for (let iter = 0; iter < 8; iter++) {
|
||||
let moved = false;
|
||||
for (let i = 0; i < group.length; i++) {
|
||||
const j = (i + 1) % group.length;
|
||||
let gap = group[j].angle - group[i].angle;
|
||||
if (gap < 0) gap += 2 * Math.PI;
|
||||
if (gap < minSep) {
|
||||
const push = (minSep - gap) / 2;
|
||||
group[i].angle -= push;
|
||||
group[j].angle += push;
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
if (!moved) break;
|
||||
}
|
||||
|
||||
// Normalise angles back to [0, 2π) and recompute x/y
|
||||
group.forEach(e => {
|
||||
e.angle = ((e.angle % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
|
||||
e.x = center + Math.sin(e.angle) * r;
|
||||
e.y = center - Math.cos(e.angle) * r;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -289,23 +289,10 @@ const SignalGuess = (function() {
|
||||
regions: ['GLOBAL']
|
||||
},
|
||||
|
||||
// LoRaWAN
|
||||
{
|
||||
label: 'LoRaWAN / LoRa Device',
|
||||
tags: ['iot', 'lora', 'lpwan', 'telemetry'],
|
||||
description: 'LoRa long-range IoT device',
|
||||
frequencyRanges: [[863000000, 870000000], [902000000, 928000000]],
|
||||
modulationHints: ['LoRa', 'CSS', 'FSK'],
|
||||
bandwidthRange: [125000, 500000],
|
||||
baseScore: 11,
|
||||
isBurstType: true,
|
||||
regions: ['UK/EU', 'US']
|
||||
},
|
||||
|
||||
// Key Fob
|
||||
{
|
||||
label: 'Remote Control / Key Fob',
|
||||
tags: ['remote', 'keyfob', 'automotive', 'burst', 'ism'],
|
||||
// Key Fob
|
||||
{
|
||||
label: 'Remote Control / Key Fob',
|
||||
tags: ['remote', 'keyfob', 'automotive', 'burst', 'ism'],
|
||||
description: 'Wireless remote control or vehicle key fob',
|
||||
frequencyRanges: [[314900000, 315100000], [433050000, 434790000], [867000000, 869000000]],
|
||||
modulationHints: ['OOK', 'ASK', 'FSK', 'rolling'],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* RF Signal Timeline Adapter
|
||||
* Normalizes RF signal data for the Activity Timeline component
|
||||
* Used by: Listening Post, TSCM
|
||||
*/
|
||||
/**
|
||||
* RF Signal Timeline Adapter
|
||||
* Normalizes RF signal data for the Activity Timeline component
|
||||
* Used by: Spectrum Waterfall, TSCM
|
||||
*/
|
||||
|
||||
const RFTimelineAdapter = (function() {
|
||||
'use strict';
|
||||
@@ -157,16 +157,16 @@ const RFTimelineAdapter = (function() {
|
||||
return signals.map(normalizer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create timeline configuration for Listening Post mode
|
||||
*/
|
||||
function getListeningPostConfig() {
|
||||
return {
|
||||
title: 'Signal Activity',
|
||||
mode: 'listening-post',
|
||||
visualMode: 'enriched',
|
||||
collapsed: false,
|
||||
showAnnotations: true,
|
||||
/**
|
||||
* Create timeline configuration for spectrum waterfall mode.
|
||||
*/
|
||||
function getWaterfallConfig() {
|
||||
return {
|
||||
title: 'Spectrum Activity',
|
||||
mode: 'waterfall',
|
||||
visualMode: 'enriched',
|
||||
collapsed: false,
|
||||
showAnnotations: true,
|
||||
showLegend: true,
|
||||
defaultWindow: '15m',
|
||||
availableWindows: ['5m', '15m', '30m', '1h'],
|
||||
@@ -184,9 +184,14 @@ const RFTimelineAdapter = (function() {
|
||||
}
|
||||
],
|
||||
maxItems: 50,
|
||||
maxDisplayedLanes: 12
|
||||
};
|
||||
}
|
||||
maxDisplayedLanes: 12
|
||||
};
|
||||
}
|
||||
|
||||
// Backward compatibility alias for legacy callers.
|
||||
function getListeningPostConfig() {
|
||||
return getWaterfallConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create timeline configuration for TSCM mode
|
||||
@@ -224,8 +229,9 @@ const RFTimelineAdapter = (function() {
|
||||
categorizeFrequency: categorizeFrequency,
|
||||
|
||||
// Configuration presets
|
||||
getListeningPostConfig: getListeningPostConfig,
|
||||
getTscmConfig: getTscmConfig,
|
||||
getWaterfallConfig: getWaterfallConfig,
|
||||
getListeningPostConfig: getListeningPostConfig,
|
||||
getTscmConfig: getTscmConfig,
|
||||
|
||||
// Constants
|
||||
RSSI_THRESHOLDS: RSSI_THRESHOLDS,
|
||||
|
||||
@@ -423,7 +423,7 @@ async function syncAgentModeStates(agentId) {
|
||||
});
|
||||
|
||||
// Also check modes that might need to be marked as stopped
|
||||
const allModes = ['sensor', 'pager', 'adsb', 'wifi', 'bluetooth', 'ais', 'dsc', 'acars', 'aprs', 'rtlamr', 'tscm', 'satellite', 'listening_post'];
|
||||
const allModes = ['sensor', 'pager', 'adsb', 'wifi', 'bluetooth', 'ais', 'dsc', 'acars', 'vdl2', 'aprs', 'rtlamr', 'tscm', 'satellite', 'listening_post'];
|
||||
allModes.forEach(mode => {
|
||||
if (!agentRunningModes.includes(mode)) {
|
||||
syncModeUI(mode, false, agentId);
|
||||
@@ -485,7 +485,7 @@ async function syncLocalModeStates() {
|
||||
*/
|
||||
function showAgentModeWarnings(runningModes, modesDetail = {}) {
|
||||
// SDR modes that can't run simultaneously on same device
|
||||
const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc'];
|
||||
const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc'];
|
||||
const runningSdrModes = runningModes.filter(m => sdrModes.includes(m));
|
||||
|
||||
let warning = document.getElementById('agentModeWarning');
|
||||
@@ -621,7 +621,7 @@ function checkAgentModeConflict(modeToStart, deviceToUse = null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc'];
|
||||
const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc'];
|
||||
|
||||
// If we're trying to start an SDR mode
|
||||
if (sdrModes.includes(modeToStart)) {
|
||||
@@ -704,6 +704,7 @@ function syncModeUI(mode, isRunning, agentId = null) {
|
||||
'wifi': 'setWiFiRunning',
|
||||
'bluetooth': 'setBluetoothRunning',
|
||||
'acars': 'setAcarsRunning',
|
||||
'vdl2': 'setVdl2Running',
|
||||
'listening_post': 'setListeningPostRunning'
|
||||
};
|
||||
|
||||
@@ -865,12 +866,12 @@ function connectAgentStream(mode, onMessage) {
|
||||
}
|
||||
|
||||
let streamUrl;
|
||||
if (currentAgent === 'local') {
|
||||
streamUrl = `/${mode}/stream`;
|
||||
} else {
|
||||
// For remote agents, proxy SSE through controller
|
||||
streamUrl = `/controller/agents/${currentAgent}/${mode}/stream`;
|
||||
}
|
||||
if (currentAgent === 'local') {
|
||||
streamUrl = `/${mode}/stream`;
|
||||
} else {
|
||||
// For remote agents, proxy SSE through controller
|
||||
streamUrl = `/controller/agents/${currentAgent}/${mode}/stream`;
|
||||
}
|
||||
|
||||
agentEventSource = new EventSource(streamUrl);
|
||||
|
||||
@@ -878,7 +879,7 @@ function connectAgentStream(mode, onMessage) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
onMessage(data);
|
||||
onMessage(data);
|
||||
} catch (e) {
|
||||
console.error('Error parsing SSE message:', e);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
const AlertCenter = (function() {
|
||||
'use strict';
|
||||
|
||||
const TRACKER_RULE_NAME = 'Tracker Detected';
|
||||
|
||||
let alerts = [];
|
||||
let rules = [];
|
||||
let eventSource = null;
|
||||
|
||||
const TRACKER_RULE_NAME = 'Tracker Detected';
|
||||
let reconnectTimer = null;
|
||||
|
||||
function init() {
|
||||
loadRules();
|
||||
@@ -17,6 +18,7 @@ const AlertCenter = (function() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
|
||||
eventSource = new EventSource('/alerts/stream');
|
||||
eventSource.onmessage = function(e) {
|
||||
try {
|
||||
@@ -27,21 +29,26 @@ const AlertCenter = (function() {
|
||||
console.error('[Alerts] SSE parse error', err);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = function() {
|
||||
console.warn('[Alerts] SSE connection error');
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(connect, 2500);
|
||||
};
|
||||
}
|
||||
|
||||
function handleAlert(alert) {
|
||||
alerts.unshift(alert);
|
||||
alerts = alerts.slice(0, 50);
|
||||
alerts = alerts.slice(0, 60);
|
||||
updateFeedUI();
|
||||
|
||||
if (typeof showNotification === 'function') {
|
||||
const severity = (alert.severity || '').toLowerCase();
|
||||
if (['high', 'critical'].includes(severity)) {
|
||||
showNotification(alert.title || 'Alert', alert.message || 'Alert triggered');
|
||||
}
|
||||
const severity = String(alert.severity || '').toLowerCase();
|
||||
if (typeof showNotification === 'function' && ['high', 'critical'].includes(severity)) {
|
||||
showNotification(alert.title || 'Alert', alert.message || 'Alert triggered');
|
||||
}
|
||||
|
||||
if (typeof showAppToast === 'function' && ['high', 'critical'].includes(severity)) {
|
||||
showAppToast(alert.title || 'Alert', alert.message || 'Alert triggered', 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +63,7 @@ const AlertCenter = (function() {
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = alerts.map(alert => {
|
||||
list.innerHTML = alerts.map((alert) => {
|
||||
const title = escapeHtml(alert.title || 'Alert');
|
||||
const message = escapeHtml(alert.message || '');
|
||||
const severity = escapeHtml(alert.severity || 'medium');
|
||||
@@ -74,27 +81,218 @@ const AlertCenter = (function() {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderRulesUI() {
|
||||
const list = document.getElementById('alertsRulesList');
|
||||
if (!list) return;
|
||||
|
||||
if (!rules.length) {
|
||||
list.innerHTML = '<div class="settings-feed-empty">No rules yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = rules.map((rule) => {
|
||||
const enabled = Boolean(rule.enabled);
|
||||
const mode = rule.mode || 'all';
|
||||
const eventType = rule.event_type || 'any';
|
||||
const severity = (rule.severity || 'medium').toUpperCase();
|
||||
const match = formatMatch(rule.match);
|
||||
const statusText = enabled ? 'ENABLED' : 'DISABLED';
|
||||
|
||||
return `
|
||||
<div class="settings-feed-item" style="border-left: 2px solid ${enabled ? 'var(--accent-green)' : 'var(--text-dim)'};">
|
||||
<div class="settings-feed-title" style="display:flex; gap:8px; align-items:center; justify-content:space-between;">
|
||||
<span>${escapeHtml(rule.name || 'Rule')}</span>
|
||||
<span style="color: var(--text-dim); font-size: 10px;">${statusText}</span>
|
||||
</div>
|
||||
<div class="settings-feed-meta">Mode: ${escapeHtml(mode)} | Event: ${escapeHtml(eventType)} | Severity: ${escapeHtml(severity)}</div>
|
||||
<div class="settings-feed-meta">Match: ${escapeHtml(match)}</div>
|
||||
<div style="display:flex; gap:8px; margin-top: 8px;">
|
||||
<button class="preset-btn" style="font-size: 10px; padding: 3px 8px;" onclick="AlertCenter.editRule(${Number(rule.id)})">Edit</button>
|
||||
<button class="preset-btn" style="font-size: 10px; padding: 3px 8px;" onclick="AlertCenter.toggleRule(${Number(rule.id)}, ${enabled ? 'false' : 'true'})">${enabled ? 'Disable' : 'Enable'}</button>
|
||||
<button class="preset-btn" style="font-size: 10px; padding: 3px 8px; border-color: var(--accent-red); color: var(--accent-red);" onclick="AlertCenter.deleteRule(${Number(rule.id)})">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function formatMatch(match) {
|
||||
if (!match || typeof match !== 'object' || !Object.keys(match).length) {
|
||||
return 'none';
|
||||
}
|
||||
const [k, v] = Object.entries(match)[0];
|
||||
return `${k}=${v}`;
|
||||
}
|
||||
|
||||
function loadFeed() {
|
||||
fetch('/alerts/events?limit=20')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
fetch('/alerts/events?limit=30')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.status === 'success') {
|
||||
alerts = data.events || [];
|
||||
updateFeedUI();
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[Alerts] Load feed failed', err));
|
||||
.catch((err) => console.error('[Alerts] Load feed failed', err));
|
||||
}
|
||||
|
||||
function loadRules() {
|
||||
fetch('/alerts/rules?all=1')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
return fetch('/alerts/rules?all=1')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.status === 'success') {
|
||||
rules = data.rules || [];
|
||||
renderRulesUI();
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[Alerts] Load rules failed', err));
|
||||
.catch((err) => {
|
||||
console.error('[Alerts] Load rules failed', err);
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Alert Rules', err, { onRetry: loadRules });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function saveRule() {
|
||||
const editingId = getEditingRuleId();
|
||||
const payload = buildRulePayload();
|
||||
|
||||
if (!payload.name) {
|
||||
payload.name = payload.mode ? `${payload.mode} alert` : 'Alert Rule';
|
||||
}
|
||||
|
||||
const url = editingId ? `/alerts/rules/${editingId}` : '/alerts/rules';
|
||||
const method = editingId ? 'PATCH' : 'POST';
|
||||
|
||||
fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.status !== 'success') {
|
||||
throw new Error(data.message || 'Failed to save rule');
|
||||
}
|
||||
clearRuleForm();
|
||||
return loadRules();
|
||||
})
|
||||
.then(() => {
|
||||
if (typeof showAppToast === 'function') {
|
||||
showAppToast('Alerts', editingId ? 'Rule updated' : 'Rule created', 'info');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Save Alert Rule', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildRulePayload() {
|
||||
const nameEl = document.getElementById('alertsRuleName');
|
||||
const modeEl = document.getElementById('alertsRuleMode');
|
||||
const eventTypeEl = document.getElementById('alertsRuleEventType');
|
||||
const keyEl = document.getElementById('alertsRuleMatchKey');
|
||||
const valueEl = document.getElementById('alertsRuleMatchValue');
|
||||
const severityEl = document.getElementById('alertsRuleSeverity');
|
||||
|
||||
const match = {};
|
||||
const key = keyEl ? String(keyEl.value || '').trim() : '';
|
||||
const value = valueEl ? String(valueEl.value || '').trim() : '';
|
||||
if (key && value) {
|
||||
match[key] = value;
|
||||
}
|
||||
|
||||
return {
|
||||
name: nameEl ? String(nameEl.value || '').trim() : 'Alert Rule',
|
||||
mode: modeEl ? String(modeEl.value || '').trim() || null : null,
|
||||
event_type: eventTypeEl ? String(eventTypeEl.value || '').trim() || null : null,
|
||||
match,
|
||||
severity: severityEl ? String(severityEl.value || 'medium') : 'medium',
|
||||
enabled: true,
|
||||
notify: { webhook: true },
|
||||
};
|
||||
}
|
||||
|
||||
function clearRuleForm() {
|
||||
setField('alertsRuleName', '');
|
||||
setField('alertsRuleMode', '');
|
||||
setField('alertsRuleEventType', '');
|
||||
setField('alertsRuleMatchKey', '');
|
||||
setField('alertsRuleMatchValue', '');
|
||||
setField('alertsRuleSeverity', 'medium');
|
||||
setField('alertsRuleEditingId', '');
|
||||
}
|
||||
|
||||
function editRule(ruleId) {
|
||||
const rule = rules.find((r) => Number(r.id) === Number(ruleId));
|
||||
if (!rule) return;
|
||||
|
||||
const matchEntries = Object.entries(rule.match || {});
|
||||
const firstMatch = matchEntries.length ? matchEntries[0] : ['', ''];
|
||||
|
||||
setField('alertsRuleName', rule.name || '');
|
||||
setField('alertsRuleMode', rule.mode || '');
|
||||
setField('alertsRuleEventType', rule.event_type || '');
|
||||
setField('alertsRuleMatchKey', firstMatch[0] || '');
|
||||
setField('alertsRuleMatchValue', firstMatch[1] == null ? '' : String(firstMatch[1]));
|
||||
setField('alertsRuleSeverity', rule.severity || 'medium');
|
||||
setField('alertsRuleEditingId', String(rule.id));
|
||||
}
|
||||
|
||||
function toggleRule(ruleId, enabled) {
|
||||
fetch(`/alerts/rules/${ruleId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: Boolean(enabled) }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.status !== 'success') {
|
||||
throw new Error(data.message || 'Failed to update rule');
|
||||
}
|
||||
return loadRules();
|
||||
})
|
||||
.catch((err) => {
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Toggle Alert Rule', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deleteRule(ruleId) {
|
||||
if (!confirm('Delete this alert rule?')) return;
|
||||
|
||||
fetch(`/alerts/rules/${ruleId}`, { method: 'DELETE' })
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.status !== 'success') {
|
||||
throw new Error(data.message || 'Failed to delete rule');
|
||||
}
|
||||
if (Number(getEditingRuleId()) === Number(ruleId)) {
|
||||
clearRuleForm();
|
||||
}
|
||||
return loadRules();
|
||||
})
|
||||
.catch((err) => {
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Delete Alert Rule', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getEditingRuleId() {
|
||||
const el = document.getElementById('alertsRuleEditingId');
|
||||
if (!el || !el.value) return null;
|
||||
const parsed = Number(el.value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function setField(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.value = value;
|
||||
}
|
||||
|
||||
function enableTrackerAlerts() {
|
||||
@@ -106,17 +304,18 @@ const AlertCenter = (function() {
|
||||
}
|
||||
|
||||
function ensureTrackerRule(enabled) {
|
||||
loadRules();
|
||||
setTimeout(() => {
|
||||
const existing = rules.find(r => r.name === TRACKER_RULE_NAME);
|
||||
loadRules().then(() => {
|
||||
const existing = rules.find((r) => r.name === TRACKER_RULE_NAME);
|
||||
if (existing) {
|
||||
fetch(`/alerts/rules/${existing.id}`, {
|
||||
return fetch(`/alerts/rules/${existing.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled })
|
||||
body: JSON.stringify({ enabled }),
|
||||
}).then(() => loadRules());
|
||||
} else if (enabled) {
|
||||
fetch('/alerts/rules', {
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
return fetch('/alerts/rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -126,44 +325,49 @@ const AlertCenter = (function() {
|
||||
match: { is_tracker: true },
|
||||
severity: 'high',
|
||||
enabled: true,
|
||||
notify: { webhook: true }
|
||||
})
|
||||
notify: { webhook: true },
|
||||
}),
|
||||
}).then(() => loadRules());
|
||||
}
|
||||
}, 150);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
function addBluetoothWatchlist(address, name) {
|
||||
if (!address) return;
|
||||
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
|
||||
if (existing) {
|
||||
return;
|
||||
}
|
||||
const upper = String(address).toUpperCase();
|
||||
const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper);
|
||||
if (existing) return;
|
||||
|
||||
fetch('/alerts/rules', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name ? `Watchlist ${name}` : `Watchlist ${address}`,
|
||||
name: name ? `Watchlist ${name}` : `Watchlist ${upper}`,
|
||||
mode: 'bluetooth',
|
||||
event_type: 'device_update',
|
||||
match: { address: address },
|
||||
match: { address: upper },
|
||||
severity: 'medium',
|
||||
enabled: true,
|
||||
notify: { webhook: true }
|
||||
})
|
||||
notify: { webhook: true },
|
||||
}),
|
||||
}).then(() => loadRules());
|
||||
}
|
||||
|
||||
function removeBluetoothWatchlist(address) {
|
||||
if (!address) return;
|
||||
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
|
||||
const upper = String(address).toUpperCase();
|
||||
const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper);
|
||||
if (!existing) return;
|
||||
|
||||
fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' })
|
||||
.then(() => loadRules());
|
||||
}
|
||||
|
||||
function isWatchlisted(address) {
|
||||
return rules.some(r => r.mode === 'bluetooth' && r.match && r.match.address === address && r.enabled);
|
||||
if (!address) return false;
|
||||
const upper = String(address).toUpperCase();
|
||||
return rules.some((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper && r.enabled);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
@@ -179,6 +383,12 @@ const AlertCenter = (function() {
|
||||
return {
|
||||
init,
|
||||
loadFeed,
|
||||
loadRules,
|
||||
saveRule,
|
||||
clearRuleForm,
|
||||
editRule,
|
||||
toggleRule,
|
||||
deleteRule,
|
||||
enableTrackerAlerts,
|
||||
disableTrackerAlerts,
|
||||
addBluetoothWatchlist,
|
||||
|
||||
@@ -36,12 +36,12 @@ let observerLocation = (function() {
|
||||
return ObserverLocation.getForModule('observerLocation');
|
||||
}
|
||||
const saved = localStorage.getItem('observerLocation');
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.lat && parsed.lon) return parsed;
|
||||
} catch (e) {}
|
||||
}
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.lat !== undefined && parsed.lat !== null && parsed.lon !== undefined && parsed.lon !== null) return parsed;
|
||||
} catch (e) {}
|
||||
}
|
||||
return { lat: 51.5074, lon: -0.1278 };
|
||||
})();
|
||||
|
||||
@@ -98,7 +98,7 @@ function switchMode(mode) {
|
||||
const modeMap = {
|
||||
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
|
||||
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
|
||||
'listening': 'listening', 'meshtastic': 'meshtastic'
|
||||
'meshtastic': 'meshtastic'
|
||||
};
|
||||
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
||||
const label = btn.querySelector('.nav-label');
|
||||
@@ -114,7 +114,6 @@ function switchMode(mode) {
|
||||
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
|
||||
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
|
||||
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
|
||||
document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
|
||||
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
||||
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
||||
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
|
||||
@@ -143,7 +142,6 @@ function switchMode(mode) {
|
||||
'satellite': 'SATELLITE',
|
||||
'wifi': 'WIFI',
|
||||
'bluetooth': 'BLUETOOTH',
|
||||
'listening': 'LISTENING POST',
|
||||
'tscm': 'TSCM',
|
||||
'aprs': 'APRS',
|
||||
'meshtastic': 'MESHTASTIC'
|
||||
@@ -166,7 +164,6 @@ function switchMode(mode) {
|
||||
const showRadar = document.getElementById('adsbEnableMap')?.checked;
|
||||
document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
|
||||
document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
|
||||
document.getElementById('listeningPostVisuals').style.display = mode === 'listening' ? 'grid' : 'none';
|
||||
|
||||
// Update output panel title based on mode
|
||||
const titles = {
|
||||
@@ -176,7 +173,6 @@ function switchMode(mode) {
|
||||
'satellite': 'Satellite Monitor',
|
||||
'wifi': 'WiFi Scanner',
|
||||
'bluetooth': 'Bluetooth Scanner',
|
||||
'listening': 'Listening Post',
|
||||
'meshtastic': 'Meshtastic Mesh Monitor'
|
||||
};
|
||||
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
|
||||
@@ -184,7 +180,7 @@ function switchMode(mode) {
|
||||
// Show/hide Device Intelligence for modes that use it
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||
if (mode === 'satellite' || mode === 'aircraft' || mode === 'listening') {
|
||||
if (mode === 'satellite' || mode === 'aircraft') {
|
||||
document.getElementById('reconPanel').style.display = 'none';
|
||||
if (reconBtn) reconBtn.style.display = 'none';
|
||||
if (intelBtn) intelBtn.style.display = 'none';
|
||||
@@ -198,7 +194,7 @@ function switchMode(mode) {
|
||||
|
||||
// Show RTL-SDR device section for modes that use it
|
||||
document.getElementById('rtlDeviceSection').style.display =
|
||||
(mode === 'pager' || mode === 'sensor' || mode === 'aircraft' || mode === 'listening') ? 'block' : 'none';
|
||||
(mode === 'pager' || mode === 'sensor' || mode === 'aircraft') ? 'block' : 'none';
|
||||
|
||||
// Toggle mode-specific tool status displays
|
||||
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
|
||||
@@ -207,7 +203,7 @@ function switchMode(mode) {
|
||||
|
||||
// Hide waterfall and output console for modes with their own visualizations
|
||||
document.querySelector('.waterfall-container').style.display =
|
||||
(mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||
document.getElementById('output').style.display =
|
||||
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||
document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex';
|
||||
@@ -226,11 +222,6 @@ function switchMode(mode) {
|
||||
} else if (mode === 'satellite') {
|
||||
if (typeof initPolarPlot === 'function') initPolarPlot();
|
||||
if (typeof initSatelliteList === 'function') initSatelliteList();
|
||||
} else if (mode === 'listening') {
|
||||
if (typeof checkScannerTools === 'function') checkScannerTools();
|
||||
if (typeof checkAudioTools === 'function') checkAudioTools();
|
||||
if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect();
|
||||
if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect();
|
||||
} else if (mode === 'meshtastic') {
|
||||
if (typeof Meshtastic !== 'undefined' && Meshtastic.init) Meshtastic.init();
|
||||
}
|
||||
@@ -373,7 +364,7 @@ function showInfo(text) {
|
||||
|
||||
const infoEl = document.createElement('div');
|
||||
infoEl.className = 'info-msg';
|
||||
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "Space Mono", monospace; font-size: 11px; color: #888; word-break: break-all;';
|
||||
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "Roboto Condensed", "Arial Narrow", sans-serif; font-size: 11px; color: #888; word-break: break-all;';
|
||||
infoEl.textContent = text;
|
||||
output.insertBefore(infoEl, output.firstChild);
|
||||
}
|
||||
@@ -387,7 +378,7 @@ function showError(text) {
|
||||
|
||||
const errorEl = document.createElement('div');
|
||||
errorEl.className = 'error-msg';
|
||||
errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "Space Mono", monospace; font-size: 11px; color: #ff6688; word-break: break-all;';
|
||||
errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "Roboto Condensed", "Arial Narrow", sans-serif; font-size: 11px; color: #ff6688; word-break: break-all;';
|
||||
errorEl.textContent = '⚠ ' + text;
|
||||
output.insertBefore(errorEl, output.firstChild);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,378 @@
|
||||
const CommandPalette = (function() {
|
||||
'use strict';
|
||||
|
||||
let overlayEl = null;
|
||||
let inputEl = null;
|
||||
let listEl = null;
|
||||
let isOpen = false;
|
||||
let activeIndex = 0;
|
||||
let filteredItems = [];
|
||||
|
||||
const fallbackModeCommands = [
|
||||
{ mode: 'pager', label: 'Pager' },
|
||||
{ mode: 'sensor', label: '433MHz Sensors' },
|
||||
{ mode: 'rtlamr', label: 'Meters' },
|
||||
{ mode: 'subghz', label: 'SubGHz' },
|
||||
{ mode: 'waterfall', label: 'Spectrum Waterfall' },
|
||||
{ mode: 'aprs', label: 'APRS' },
|
||||
{ mode: 'wifi', label: 'WiFi Scanner' },
|
||||
{ mode: 'bluetooth', label: 'Bluetooth Scanner' },
|
||||
{ mode: 'bt_locate', label: 'BT Locate' },
|
||||
{ mode: 'satellite', label: 'Satellite' },
|
||||
{ mode: 'sstv', label: 'ISS SSTV' },
|
||||
{ mode: 'weathersat', label: 'Weather Sat' },
|
||||
{ mode: 'sstv_general', label: 'HF SSTV' },
|
||||
{ mode: 'gps', label: 'GPS' },
|
||||
{ mode: 'meshtastic', label: 'Meshtastic' },
|
||||
{ mode: 'websdr', label: 'WebSDR' },
|
||||
{ mode: 'spaceweather', label: 'Space Weather' },
|
||||
];
|
||||
|
||||
function getModeCommands() {
|
||||
const commands = [];
|
||||
const seenModes = new Set();
|
||||
|
||||
const catalog = window.interceptModeCatalog;
|
||||
if (catalog && typeof catalog === 'object') {
|
||||
for (const [mode, meta] of Object.entries(catalog)) {
|
||||
if (!mode || seenModes.has(mode)) continue;
|
||||
const label = String((meta && meta.label) || mode).trim();
|
||||
commands.push({ mode, label });
|
||||
seenModes.add(mode);
|
||||
}
|
||||
if (commands.length > 0) return commands;
|
||||
}
|
||||
|
||||
const navNodes = document.querySelectorAll('.mode-nav-btn[data-mode], .mobile-nav-btn[data-mode]');
|
||||
navNodes.forEach((node) => {
|
||||
if (node.tagName === 'A') {
|
||||
const href = String(node.getAttribute('href') || '');
|
||||
if (href.includes('/dashboard')) return;
|
||||
}
|
||||
const mode = String(node.dataset.mode || '').trim();
|
||||
if (!mode || seenModes.has(mode)) return;
|
||||
const label = String(node.dataset.modeLabel || node.textContent || mode).trim();
|
||||
commands.push({ mode, label });
|
||||
seenModes.add(mode);
|
||||
});
|
||||
if (commands.length > 0) return commands;
|
||||
|
||||
return fallbackModeCommands.slice();
|
||||
}
|
||||
|
||||
function init() {
|
||||
buildDOM();
|
||||
registerHotkeys();
|
||||
renderItems('');
|
||||
}
|
||||
|
||||
function buildDOM() {
|
||||
overlayEl = document.createElement('div');
|
||||
overlayEl.className = 'command-palette-overlay';
|
||||
overlayEl.id = 'commandPaletteOverlay';
|
||||
overlayEl.addEventListener('click', (event) => {
|
||||
if (event.target === overlayEl) close();
|
||||
});
|
||||
|
||||
const palette = document.createElement('div');
|
||||
palette.className = 'command-palette';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'command-palette-header';
|
||||
|
||||
inputEl = document.createElement('input');
|
||||
inputEl.className = 'command-palette-input';
|
||||
inputEl.type = 'text';
|
||||
inputEl.autocomplete = 'off';
|
||||
inputEl.placeholder = 'Search commands and modes...';
|
||||
inputEl.setAttribute('aria-label', 'Command Palette Search');
|
||||
inputEl.addEventListener('input', () => {
|
||||
renderItems(inputEl.value || '');
|
||||
});
|
||||
inputEl.addEventListener('keydown', onInputKeyDown);
|
||||
|
||||
const hint = document.createElement('span');
|
||||
hint.className = 'command-palette-hint';
|
||||
hint.textContent = 'Esc close';
|
||||
|
||||
header.appendChild(inputEl);
|
||||
header.appendChild(hint);
|
||||
|
||||
listEl = document.createElement('div');
|
||||
listEl.className = 'command-palette-list';
|
||||
listEl.id = 'commandPaletteList';
|
||||
|
||||
palette.appendChild(header);
|
||||
palette.appendChild(listEl);
|
||||
overlayEl.appendChild(palette);
|
||||
document.body.appendChild(overlayEl);
|
||||
}
|
||||
|
||||
function registerHotkeys() {
|
||||
document.addEventListener('keydown', (event) => {
|
||||
const cmdK = (event.key.toLowerCase() === 'k') && (event.ctrlKey || event.metaKey);
|
||||
if (cmdK) {
|
||||
event.preventDefault();
|
||||
if (isOpen) {
|
||||
close();
|
||||
} else {
|
||||
open();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOpen) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onInputKeyDown(event) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
activeIndex = Math.min(activeIndex + 1, Math.max(filteredItems.length - 1, 0));
|
||||
renderSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
activeIndex = Math.max(activeIndex - 1, 0);
|
||||
renderSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
const item = filteredItems[activeIndex];
|
||||
if (item && typeof item.run === 'function') {
|
||||
item.run();
|
||||
}
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function getCommands() {
|
||||
const commands = [
|
||||
{
|
||||
title: 'Open Settings',
|
||||
description: 'Open global settings panel',
|
||||
keyword: 'settings configure tools',
|
||||
shortcut: 'S',
|
||||
run: () => {
|
||||
if (typeof showSettings === 'function') showSettings();
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Settings: Alerts',
|
||||
description: 'Open alert rules and feed',
|
||||
keyword: 'settings alerts rule',
|
||||
run: () => openSettingsTab('alerts')
|
||||
},
|
||||
{
|
||||
title: 'Settings: Recording',
|
||||
description: 'Open recording manager',
|
||||
keyword: 'settings recording replay',
|
||||
run: () => openSettingsTab('recording')
|
||||
},
|
||||
{
|
||||
title: 'Settings: Location',
|
||||
description: 'Configure observer location',
|
||||
keyword: 'settings location gps lat lon',
|
||||
run: () => openSettingsTab('location')
|
||||
},
|
||||
{
|
||||
title: 'View Aircraft Dashboard',
|
||||
description: 'Open dedicated ADS-B dashboard page',
|
||||
keyword: 'aircraft adsb dashboard',
|
||||
run: () => {
|
||||
if (window.InterceptNavPerf && typeof window.InterceptNavPerf.markStart === 'function') {
|
||||
window.InterceptNavPerf.markStart({
|
||||
targetPath: '/adsb/dashboard',
|
||||
trigger: 'command-palette',
|
||||
sourceMode: (typeof currentMode === 'string' && currentMode) ? currentMode : null,
|
||||
activeScans: (typeof getActiveScanSummary === 'function') ? getActiveScanSummary() : null,
|
||||
});
|
||||
}
|
||||
if (typeof stopActiveLocalScansForNavigation === 'function') {
|
||||
stopActiveLocalScansForNavigation();
|
||||
}
|
||||
window.location.href = '/adsb/dashboard';
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'View Vessel Dashboard',
|
||||
description: 'Open dedicated AIS dashboard page',
|
||||
keyword: 'vessel ais dashboard',
|
||||
run: () => {
|
||||
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',
|
||||
description: 'Stop all decoders and scans',
|
||||
keyword: 'kill stop processes emergency',
|
||||
run: () => {
|
||||
if (typeof killAll === 'function') {
|
||||
killAll();
|
||||
} else if (typeof fetch === 'function') {
|
||||
fetch('/killall', { method: 'POST' });
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Toggle Sidebar Width',
|
||||
description: 'Collapse or expand the left sidebar',
|
||||
keyword: 'sidebar collapse layout',
|
||||
run: () => {
|
||||
if (typeof toggleMainSidebarCollapse === 'function') {
|
||||
toggleMainSidebarCollapse();
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
for (const modeEntry of getModeCommands()) {
|
||||
commands.push({
|
||||
title: `Switch Mode: ${modeEntry.label}`,
|
||||
description: 'Navigate directly to mode',
|
||||
keyword: `mode ${modeEntry.mode} ${modeEntry.label.toLowerCase()}`,
|
||||
run: () => goToMode(modeEntry.mode),
|
||||
});
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
function renderItems(query) {
|
||||
const q = String(query || '').trim().toLowerCase();
|
||||
const allItems = getCommands();
|
||||
|
||||
filteredItems = allItems.filter((item) => {
|
||||
if (!q) return true;
|
||||
const haystack = `${item.title} ${item.description || ''} ${item.keyword || ''}`.toLowerCase();
|
||||
return haystack.includes(q);
|
||||
}).slice(0, 80);
|
||||
|
||||
activeIndex = 0;
|
||||
|
||||
listEl.innerHTML = '';
|
||||
if (filteredItems.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'command-palette-empty';
|
||||
empty.textContent = 'No matching commands';
|
||||
listEl.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
filteredItems.forEach((item, idx) => {
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'command-palette-item';
|
||||
row.dataset.index = String(idx);
|
||||
row.addEventListener('click', () => {
|
||||
item.run();
|
||||
close();
|
||||
});
|
||||
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'meta';
|
||||
|
||||
const title = document.createElement('span');
|
||||
title.className = 'title';
|
||||
title.textContent = item.title;
|
||||
meta.appendChild(title);
|
||||
|
||||
const desc = document.createElement('span');
|
||||
desc.className = 'desc';
|
||||
desc.textContent = item.description || '';
|
||||
meta.appendChild(desc);
|
||||
|
||||
row.appendChild(meta);
|
||||
|
||||
if (item.shortcut) {
|
||||
const kbd = document.createElement('span');
|
||||
kbd.className = 'kbd';
|
||||
kbd.textContent = item.shortcut;
|
||||
row.appendChild(kbd);
|
||||
}
|
||||
|
||||
listEl.appendChild(row);
|
||||
});
|
||||
|
||||
renderSelection();
|
||||
}
|
||||
|
||||
function renderSelection() {
|
||||
const rows = listEl.querySelectorAll('.command-palette-item');
|
||||
rows.forEach((row) => {
|
||||
const idx = Number(row.dataset.index || 0);
|
||||
row.classList.toggle('active', idx === activeIndex);
|
||||
});
|
||||
|
||||
const activeRow = listEl.querySelector(`.command-palette-item[data-index="${activeIndex}"]`);
|
||||
if (activeRow) {
|
||||
activeRow.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
function goToMode(mode) {
|
||||
const welcome = document.getElementById('welcomePage');
|
||||
if (welcome && getComputedStyle(welcome).display !== 'none') {
|
||||
welcome.style.display = 'none';
|
||||
}
|
||||
|
||||
if (typeof switchMode === 'function') {
|
||||
switchMode(mode, { updateUrl: true });
|
||||
}
|
||||
}
|
||||
|
||||
function openSettingsTab(tab) {
|
||||
if (typeof showSettings === 'function') {
|
||||
showSettings();
|
||||
}
|
||||
if (typeof switchSettingsTab === 'function') {
|
||||
switchSettingsTab(tab);
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!overlayEl) return;
|
||||
isOpen = true;
|
||||
overlayEl.classList.add('open');
|
||||
renderItems('');
|
||||
inputEl.value = '';
|
||||
requestAnimationFrame(() => {
|
||||
inputEl.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!overlayEl) return;
|
||||
isOpen = false;
|
||||
overlayEl.classList.remove('open');
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
open,
|
||||
close,
|
||||
};
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
CommandPalette.init();
|
||||
});
|
||||
@@ -0,0 +1,376 @@
|
||||
const FirstRunSetup = (function() {
|
||||
'use strict';
|
||||
|
||||
const COMPLETE_KEY = 'intercept.setup.complete.v1';
|
||||
const DEFAULT_MODE_KEY = 'intercept.default_mode';
|
||||
|
||||
let overlayEl = null;
|
||||
let depsStatusEl = null;
|
||||
let locationStatusEl = null;
|
||||
let notifyStatusEl = null;
|
||||
let modeStatusEl = null;
|
||||
let modeSelectEl = null;
|
||||
|
||||
let dependencyReady = null;
|
||||
|
||||
function init() {
|
||||
buildDOM();
|
||||
maybeShow();
|
||||
}
|
||||
|
||||
function maybeShow() {
|
||||
if (localStorage.getItem(COMPLETE_KEY) === 'true') return;
|
||||
|
||||
if (localStorage.getItem('disclaimerAccepted') === 'true') {
|
||||
open();
|
||||
refreshStatuses();
|
||||
return;
|
||||
}
|
||||
|
||||
let attempts = 0;
|
||||
const waitTimer = setInterval(() => {
|
||||
attempts += 1;
|
||||
if (localStorage.getItem(COMPLETE_KEY) === 'true') {
|
||||
clearInterval(waitTimer);
|
||||
return;
|
||||
}
|
||||
if (localStorage.getItem('disclaimerAccepted') === 'true') {
|
||||
clearInterval(waitTimer);
|
||||
open();
|
||||
refreshStatuses();
|
||||
}
|
||||
if (attempts > 30) {
|
||||
clearInterval(waitTimer);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function buildDOM() {
|
||||
overlayEl = document.createElement('div');
|
||||
overlayEl.id = 'firstRunSetupOverlay';
|
||||
overlayEl.className = 'setup-overlay';
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'setup-modal';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'setup-header';
|
||||
|
||||
const headingWrap = document.createElement('div');
|
||||
const title = document.createElement('h2');
|
||||
title.className = 'setup-title';
|
||||
title.textContent = 'Quick Setup';
|
||||
headingWrap.appendChild(title);
|
||||
|
||||
const subtitle = document.createElement('p');
|
||||
subtitle.className = 'setup-subtitle';
|
||||
subtitle.textContent = 'Complete these checks once so all modes work reliably.';
|
||||
headingWrap.appendChild(subtitle);
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'setup-close';
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.setAttribute('aria-label', 'Close setup assistant');
|
||||
closeBtn.addEventListener('click', close);
|
||||
|
||||
header.appendChild(headingWrap);
|
||||
header.appendChild(closeBtn);
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'setup-content';
|
||||
|
||||
const depsStep = createStep(
|
||||
'Dependencies',
|
||||
'Verify required tools are installed for enabled modes.',
|
||||
(statusEl, actionsEl) => {
|
||||
depsStatusEl = statusEl;
|
||||
|
||||
const checkBtn = buildButton('Recheck', () => checkDependencies());
|
||||
const openToolsBtn = buildButton('Open Tools', () => {
|
||||
if (typeof showSettings === 'function') showSettings();
|
||||
if (typeof switchSettingsTab === 'function') switchSettingsTab('tools');
|
||||
});
|
||||
actionsEl.appendChild(checkBtn);
|
||||
actionsEl.appendChild(openToolsBtn);
|
||||
}
|
||||
);
|
||||
|
||||
const locationStep = createStep(
|
||||
'Observer Location',
|
||||
'Set latitude/longitude for pass prediction and mapping features.',
|
||||
(statusEl, actionsEl) => {
|
||||
locationStatusEl = statusEl;
|
||||
actionsEl.appendChild(buildButton('Open Location', () => {
|
||||
if (typeof showSettings === 'function') showSettings();
|
||||
if (typeof switchSettingsTab === 'function') switchSettingsTab('location');
|
||||
}));
|
||||
actionsEl.appendChild(buildButton('Recheck', refreshStatuses));
|
||||
}
|
||||
);
|
||||
|
||||
const notifyStep = createStep(
|
||||
'Desktop Alerts',
|
||||
'Allow notifications so high-priority alerts are visible when the tab is hidden.',
|
||||
(statusEl, actionsEl) => {
|
||||
notifyStatusEl = statusEl;
|
||||
actionsEl.appendChild(buildButton('Enable Notifications', requestNotifications));
|
||||
}
|
||||
);
|
||||
|
||||
const modeStep = createStep(
|
||||
'Default Start Mode',
|
||||
'Choose which mode should be selected by default.',
|
||||
(statusEl, actionsEl) => {
|
||||
modeStatusEl = statusEl;
|
||||
|
||||
modeSelectEl = document.createElement('select');
|
||||
modeSelectEl.className = 'setup-btn';
|
||||
const modes = [
|
||||
['pager', 'Pager'],
|
||||
['sensor', '433MHz'],
|
||||
['rtlamr', 'Meters'],
|
||||
['waterfall', 'Waterfall'],
|
||||
['wifi', 'WiFi'],
|
||||
['bluetooth', 'Bluetooth'],
|
||||
['bt_locate', 'BT Locate'],
|
||||
['aprs', 'APRS'],
|
||||
['satellite', 'Satellite'],
|
||||
['sstv', 'ISS SSTV'],
|
||||
['weathersat', 'Weather Sat'],
|
||||
['sstv_general', 'HF SSTV'],
|
||||
];
|
||||
for (const [value, label] of modes) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = value;
|
||||
opt.textContent = label;
|
||||
modeSelectEl.appendChild(opt);
|
||||
}
|
||||
|
||||
const savedDefaultMode = localStorage.getItem(DEFAULT_MODE_KEY);
|
||||
if (savedDefaultMode) {
|
||||
const normalizedMode = savedDefaultMode === 'listening' ? 'waterfall' : savedDefaultMode;
|
||||
modeSelectEl.value = normalizedMode;
|
||||
if (normalizedMode !== savedDefaultMode) {
|
||||
localStorage.setItem(DEFAULT_MODE_KEY, normalizedMode);
|
||||
}
|
||||
}
|
||||
|
||||
actionsEl.appendChild(modeSelectEl);
|
||||
actionsEl.appendChild(buildButton('Save', () => {
|
||||
const selected = modeSelectEl.value || 'pager';
|
||||
localStorage.setItem(DEFAULT_MODE_KEY, selected);
|
||||
refreshStatuses();
|
||||
if (typeof showAppToast === 'function') {
|
||||
showAppToast('Default Mode Saved', `New sessions will default to ${selected}.`, 'info');
|
||||
}
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
content.appendChild(depsStep);
|
||||
content.appendChild(locationStep);
|
||||
content.appendChild(notifyStep);
|
||||
content.appendChild(modeStep);
|
||||
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'setup-footer';
|
||||
|
||||
const note = document.createElement('span');
|
||||
note.className = 'setup-footer-note';
|
||||
note.textContent = 'You can reopen these options anytime in Settings.';
|
||||
|
||||
const footerActions = document.createElement('div');
|
||||
footerActions.style.display = 'inline-flex';
|
||||
footerActions.style.gap = '8px';
|
||||
|
||||
const laterBtn = buildButton('Remind Me Later', close);
|
||||
const completeBtn = buildButton('Mark Setup Complete', completeSetup, true);
|
||||
completeBtn.id = 'setupCompleteBtn';
|
||||
|
||||
footerActions.appendChild(laterBtn);
|
||||
footerActions.appendChild(completeBtn);
|
||||
|
||||
footer.appendChild(note);
|
||||
footer.appendChild(footerActions);
|
||||
|
||||
modal.appendChild(header);
|
||||
modal.appendChild(content);
|
||||
modal.appendChild(footer);
|
||||
|
||||
overlayEl.appendChild(modal);
|
||||
document.body.appendChild(overlayEl);
|
||||
}
|
||||
|
||||
function createStep(title, description, initActions) {
|
||||
const root = document.createElement('div');
|
||||
root.className = 'setup-step';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'setup-step-header';
|
||||
|
||||
const titleEl = document.createElement('span');
|
||||
titleEl.className = 'setup-step-title';
|
||||
titleEl.textContent = title;
|
||||
|
||||
const statusEl = document.createElement('span');
|
||||
statusEl.className = 'setup-step-status';
|
||||
statusEl.textContent = 'Pending';
|
||||
|
||||
header.appendChild(titleEl);
|
||||
header.appendChild(statusEl);
|
||||
|
||||
const descEl = document.createElement('p');
|
||||
descEl.className = 'setup-step-desc';
|
||||
descEl.textContent = description;
|
||||
|
||||
const actionsEl = document.createElement('div');
|
||||
actionsEl.className = 'setup-step-actions';
|
||||
|
||||
if (typeof initActions === 'function') {
|
||||
initActions(statusEl, actionsEl);
|
||||
}
|
||||
|
||||
root.appendChild(header);
|
||||
root.appendChild(descEl);
|
||||
root.appendChild(actionsEl);
|
||||
return root;
|
||||
}
|
||||
|
||||
function buildButton(label, onClick, primary) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = `setup-btn${primary ? ' primary' : ''}`;
|
||||
btn.textContent = label;
|
||||
btn.addEventListener('click', onClick);
|
||||
return btn;
|
||||
}
|
||||
|
||||
async function checkDependencies() {
|
||||
if (depsStatusEl) depsStatusEl.textContent = 'Checking...';
|
||||
try {
|
||||
const response = await fetch('/dependencies');
|
||||
const data = await response.json();
|
||||
if (data.status !== 'success') {
|
||||
dependencyReady = false;
|
||||
} else {
|
||||
const modes = Object.values(data.modes || {});
|
||||
dependencyReady = modes.every((modeInfo) => Boolean(modeInfo.ready));
|
||||
}
|
||||
} catch (err) {
|
||||
dependencyReady = false;
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Dependency Check', err, {
|
||||
onRetry: checkDependencies,
|
||||
});
|
||||
}
|
||||
}
|
||||
refreshStatuses();
|
||||
}
|
||||
|
||||
function refreshStatuses() {
|
||||
const hasLocation = hasValidLocation();
|
||||
const notifications = notificationStatus();
|
||||
const hasDefaultMode = Boolean(localStorage.getItem(DEFAULT_MODE_KEY));
|
||||
|
||||
setStatus(locationStatusEl, hasLocation, hasLocation ? 'Configured' : 'Not set');
|
||||
setStatus(notifyStatusEl, notifications.ready, notifications.label);
|
||||
setStatus(modeStatusEl, hasDefaultMode, hasDefaultMode ? localStorage.getItem(DEFAULT_MODE_KEY) : 'Not set');
|
||||
|
||||
if (dependencyReady === null) {
|
||||
checkDependencies();
|
||||
return;
|
||||
}
|
||||
setStatus(depsStatusEl, dependencyReady, dependencyReady ? 'Ready' : 'Missing tools');
|
||||
|
||||
const doneCount = Number(dependencyReady) + Number(hasLocation) + Number(notifications.ready) + Number(hasDefaultMode);
|
||||
const completeBtn = document.getElementById('setupCompleteBtn');
|
||||
if (completeBtn) {
|
||||
completeBtn.textContent = doneCount >= 3 ? 'Mark Setup Complete' : 'Complete Anyway';
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(el, done, label) {
|
||||
if (!el) return;
|
||||
el.classList.toggle('done', Boolean(done));
|
||||
el.textContent = String(label || (done ? 'Done' : 'Pending'));
|
||||
}
|
||||
|
||||
function hasValidLocation() {
|
||||
const rawLat = localStorage.getItem('observerLat');
|
||||
const rawLon = localStorage.getItem('observerLon');
|
||||
|
||||
if (rawLat === null || rawLon === null || rawLat === '' || rawLon === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lat = Number(rawLat);
|
||||
const lon = Number(rawLon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
||||
|
||||
return lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180;
|
||||
}
|
||||
|
||||
function notificationStatus() {
|
||||
if (!('Notification' in window)) {
|
||||
return { ready: true, label: 'Unsupported (optional)' };
|
||||
}
|
||||
|
||||
if (Notification.permission === 'granted') {
|
||||
return { ready: true, label: 'Enabled' };
|
||||
}
|
||||
|
||||
if (Notification.permission === 'denied') {
|
||||
return { ready: false, label: 'Blocked in browser' };
|
||||
}
|
||||
|
||||
return { ready: false, label: 'Permission needed' };
|
||||
}
|
||||
|
||||
async function requestNotifications() {
|
||||
if (!('Notification' in window)) {
|
||||
refreshStatuses();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Notification.requestPermission();
|
||||
} catch (err) {
|
||||
if (typeof reportActionableError === 'function') {
|
||||
reportActionableError('Notifications', err);
|
||||
}
|
||||
}
|
||||
refreshStatuses();
|
||||
}
|
||||
|
||||
function completeSetup() {
|
||||
localStorage.setItem(COMPLETE_KEY, 'true');
|
||||
close();
|
||||
|
||||
if (typeof showAppToast === 'function') {
|
||||
showAppToast('Setup Complete', 'You can revisit these options in Settings.', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!overlayEl) return;
|
||||
overlayEl.classList.add('open');
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!overlayEl) return;
|
||||
overlayEl.classList.remove('open');
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
open,
|
||||
close,
|
||||
refreshStatuses,
|
||||
completeSetup,
|
||||
};
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
FirstRunSetup.init();
|
||||
});
|
||||
@@ -18,6 +18,18 @@
|
||||
if (menuLink) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
try {
|
||||
const target = new URL(menuLink.href, window.location.href);
|
||||
if (window.InterceptNavPerf && typeof window.InterceptNavPerf.markStart === 'function') {
|
||||
window.InterceptNavPerf.markStart({
|
||||
targetPath: target.pathname,
|
||||
trigger: 'global-nav',
|
||||
sourceMode: document.body?.getAttribute('data-mode') || null,
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore malformed link targets.
|
||||
}
|
||||
window.location.href = menuLink.href;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -96,7 +96,10 @@ const RecordingUI = (function() {
|
||||
<div class="settings-feed-item">
|
||||
<div class="settings-feed-title">
|
||||
<span>${escapeHtml(rec.mode)}${rec.label ? ` • ${escapeHtml(rec.label)}` : ''}</span>
|
||||
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.download('${rec.id}')">Download</button>
|
||||
<div style="display:flex; gap:6px;">
|
||||
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.openReplay('${rec.id}')">Replay</button>
|
||||
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.download('${rec.id}')">Download</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-feed-meta">${new Date(rec.started_at).toLocaleString()}${rec.stopped_at ? ` → ${new Date(rec.stopped_at).toLocaleString()}` : ''}</div>
|
||||
<div class="settings-feed-meta">Events: ${rec.event_count || 0} • ${(rec.size_bytes || 0) / 1024.0 > 0 ? (rec.size_bytes / 1024).toFixed(1) + ' KB' : '0 KB'}</div>
|
||||
@@ -109,6 +112,11 @@ const RecordingUI = (function() {
|
||||
window.open(`/recordings/${sessionId}/download`, '_blank');
|
||||
}
|
||||
|
||||
function openReplay(sessionId) {
|
||||
if (!sessionId) return;
|
||||
window.open(`/recordings/${sessionId}/download`, '_blank');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
@@ -126,6 +134,7 @@ const RecordingUI = (function() {
|
||||
stop,
|
||||
stopById,
|
||||
download,
|
||||
openReplay,
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
const RunState = (function() {
|
||||
'use strict';
|
||||
|
||||
const REFRESH_MS = 5000;
|
||||
const CHIP_MODES = ['pager', 'sensor', 'wifi', 'bluetooth', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'dsc', 'subghz'];
|
||||
const MODE_ALIASES = {
|
||||
bt: 'bluetooth',
|
||||
bt_locate: 'bluetooth',
|
||||
btlocate: 'bluetooth',
|
||||
aircraft: 'adsb',
|
||||
};
|
||||
|
||||
const modeLabels = {
|
||||
pager: 'Pager',
|
||||
sensor: '433',
|
||||
wifi: 'WiFi',
|
||||
bluetooth: 'BT',
|
||||
adsb: 'ADS-B',
|
||||
ais: 'AIS',
|
||||
acars: 'ACARS',
|
||||
vdl2: 'VDL2',
|
||||
aprs: 'APRS',
|
||||
dsc: 'DSC',
|
||||
subghz: 'SubGHz',
|
||||
};
|
||||
|
||||
let refreshTimer = null;
|
||||
let activeMode = null;
|
||||
let lastHealth = null;
|
||||
let lastErrorToastAt = 0;
|
||||
|
||||
function init() {
|
||||
const root = document.getElementById('runStateStrip');
|
||||
if (!root) return;
|
||||
|
||||
wireActions();
|
||||
wrapModeSwitch();
|
||||
activeMode = inferCurrentMode();
|
||||
renderHealth(null);
|
||||
refresh();
|
||||
|
||||
if (!refreshTimer) {
|
||||
refreshTimer = window.setInterval(refresh, REFRESH_MS);
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function wireActions() {
|
||||
const refreshBtn = document.getElementById('runStateRefreshBtn');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => refresh());
|
||||
}
|
||||
|
||||
const settingsBtn = document.getElementById('runStateSettingsBtn');
|
||||
if (settingsBtn) {
|
||||
settingsBtn.addEventListener('click', () => {
|
||||
if (typeof showSettings === 'function') {
|
||||
showSettings();
|
||||
if (typeof switchSettingsTab === 'function') {
|
||||
switchSettingsTab('tools');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function wrapModeSwitch() {
|
||||
if (typeof window.switchMode !== 'function') return;
|
||||
if (window.switchMode.__runStateWrapped) return;
|
||||
|
||||
const original = window.switchMode;
|
||||
const wrapped = function(mode) {
|
||||
if (mode) {
|
||||
activeMode = normalizeMode(String(mode));
|
||||
}
|
||||
const result = original.apply(this, arguments);
|
||||
markActiveChip();
|
||||
return result;
|
||||
};
|
||||
wrapped.__runStateWrapped = true;
|
||||
window.switchMode = wrapped;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const response = await fetch('/health');
|
||||
const data = await response.json();
|
||||
lastHealth = data;
|
||||
renderHealth(data);
|
||||
} catch (err) {
|
||||
renderHealth(null, err);
|
||||
const now = Date.now();
|
||||
if (typeof reportActionableError === 'function' && (now - lastErrorToastAt) > 30000) {
|
||||
lastErrorToastAt = now;
|
||||
reportActionableError('Run State', err, { persistent: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderHealth(data, err) {
|
||||
const chipsContainer = document.getElementById('runStateChips');
|
||||
const summaryEl = document.getElementById('runStateSummary');
|
||||
if (!chipsContainer || !summaryEl) return;
|
||||
|
||||
chipsContainer.innerHTML = '';
|
||||
|
||||
if (!data || data.status !== 'healthy') {
|
||||
const offline = buildChip('API', false);
|
||||
offline.classList.add('active');
|
||||
chipsContainer.appendChild(offline);
|
||||
summaryEl.textContent = err ? `Health unavailable: ${extractMessage(err)}` : 'Health unavailable';
|
||||
return;
|
||||
}
|
||||
|
||||
const processes = normalizeProcesses(data.processes || {});
|
||||
for (const mode of CHIP_MODES) {
|
||||
const isRunning = Boolean(processes[mode]);
|
||||
chipsContainer.appendChild(buildChip(modeLabels[mode] || mode.toUpperCase(), isRunning, mode));
|
||||
}
|
||||
|
||||
const counts = data.data || {};
|
||||
summaryEl.textContent = `Aircraft ${counts.aircraft_count || 0} | Vessels ${counts.vessel_count || 0} | WiFi ${counts.wifi_networks_count || 0} | BT ${counts.bt_devices_count || 0}`;
|
||||
markActiveChip();
|
||||
}
|
||||
|
||||
function buildChip(label, running, mode) {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = `run-state-chip${running ? ' running' : ''}`;
|
||||
if (mode) {
|
||||
chip.dataset.mode = mode;
|
||||
}
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'dot';
|
||||
chip.appendChild(dot);
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.textContent = label;
|
||||
chip.appendChild(text);
|
||||
|
||||
return chip;
|
||||
}
|
||||
|
||||
function markActiveChip() {
|
||||
if (!activeMode) {
|
||||
activeMode = inferCurrentMode();
|
||||
}
|
||||
|
||||
document.querySelectorAll('#runStateChips .run-state-chip').forEach((chip) => {
|
||||
chip.classList.remove('active');
|
||||
if (chip.dataset.mode && chip.dataset.mode === normalizeMode(activeMode)) {
|
||||
chip.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function inferCurrentMode() {
|
||||
const modeParam = new URLSearchParams(window.location.search).get('mode');
|
||||
if (modeParam) return normalizeMode(modeParam);
|
||||
|
||||
if (typeof window.currentMode === 'string' && window.currentMode) {
|
||||
return normalizeMode(window.currentMode);
|
||||
}
|
||||
|
||||
const indicator = document.getElementById('activeModeIndicator');
|
||||
if (!indicator) return 'pager';
|
||||
|
||||
const text = indicator.textContent || '';
|
||||
const normalized = text.toLowerCase();
|
||||
if (normalized.includes('wifi')) return 'wifi';
|
||||
if (normalized.includes('bluetooth')) return 'bluetooth';
|
||||
if (normalized.includes('bt locate')) return 'bluetooth';
|
||||
if (normalized.includes('ads-b')) return 'adsb';
|
||||
if (normalized.includes('ais')) return 'ais';
|
||||
if (normalized.includes('acars')) return 'acars';
|
||||
if (normalized.includes('vdl2')) return 'vdl2';
|
||||
if (normalized.includes('aprs')) return 'aprs';
|
||||
if (normalized.includes('dsc')) return 'dsc';
|
||||
if (normalized.includes('subghz')) return 'subghz';
|
||||
if (normalized.includes('433')) return 'sensor';
|
||||
return 'pager';
|
||||
}
|
||||
|
||||
function normalizeMode(mode) {
|
||||
const value = String(mode || '').trim().toLowerCase();
|
||||
if (!value) return 'pager';
|
||||
return MODE_ALIASES[value] || value;
|
||||
}
|
||||
|
||||
function normalizeProcesses(raw) {
|
||||
const processes = Object.assign({}, raw || {});
|
||||
processes.bluetooth = Boolean(
|
||||
processes.bluetooth ||
|
||||
processes.bt ||
|
||||
processes.bt_scan ||
|
||||
processes.btlocate ||
|
||||
processes.bt_locate
|
||||
);
|
||||
processes.wifi = Boolean(
|
||||
processes.wifi ||
|
||||
processes.wifi_scan ||
|
||||
processes.wlan
|
||||
);
|
||||
return processes;
|
||||
}
|
||||
|
||||
function extractMessage(err) {
|
||||
if (!err) return 'Unknown error';
|
||||
if (typeof err === 'string') return err;
|
||||
if (err.message) return err.message;
|
||||
return String(err);
|
||||
}
|
||||
|
||||
function getLastHealth() {
|
||||
return lastHealth;
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
refresh,
|
||||
destroy,
|
||||
getLastHealth,
|
||||
};
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
RunState.init();
|
||||
});
|
||||
@@ -6,8 +6,8 @@ const Settings = {
|
||||
// Default settings
|
||||
defaults: {
|
||||
'offline.enabled': false,
|
||||
'offline.assets_source': 'cdn',
|
||||
'offline.fonts_source': 'cdn',
|
||||
'offline.assets_source': 'local',
|
||||
'offline.fonts_source': 'local',
|
||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||
'offline.tile_server_url': ''
|
||||
},
|
||||
@@ -22,15 +22,16 @@ const Settings = {
|
||||
cartodb_dark: {
|
||||
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
subdomains: 'abcd'
|
||||
subdomains: 'abcd',
|
||||
mapTheme: 'cyber',
|
||||
options: {}
|
||||
},
|
||||
cartodb_dark_cyan: {
|
||||
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
subdomains: 'abcd',
|
||||
options: {
|
||||
className: 'tile-layer-cyan'
|
||||
}
|
||||
mapTheme: 'cyber',
|
||||
options: {}
|
||||
},
|
||||
cartodb_light: {
|
||||
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png',
|
||||
@@ -50,26 +51,144 @@ const Settings = {
|
||||
// Current settings cache
|
||||
_cache: {},
|
||||
|
||||
// Init guard to prevent concurrent fetch races across pages/modes
|
||||
_initialized: false,
|
||||
_initPromise: null,
|
||||
_themeObserver: null,
|
||||
_themeObserverStarted: false,
|
||||
_themeObserverRaf: null,
|
||||
|
||||
/**
|
||||
* Check if a tile provider key is valid.
|
||||
* @param {string} provider
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isKnownTileProvider(provider) {
|
||||
if (typeof provider !== 'string') return false;
|
||||
const key = provider.trim();
|
||||
return key === 'custom' || Object.prototype.hasOwnProperty.call(this.tileProviders, key);
|
||||
},
|
||||
|
||||
/**
|
||||
* Normalize tile provider values from storage/UI.
|
||||
* @param {string} provider
|
||||
* @returns {string}
|
||||
*/
|
||||
_normalizeTileProvider(provider) {
|
||||
if (typeof provider !== 'string') return this.defaults['offline.tile_provider'];
|
||||
const key = provider.trim();
|
||||
if (this._isKnownTileProvider(key)) return key;
|
||||
return this.defaults['offline.tile_provider'];
|
||||
},
|
||||
|
||||
/**
|
||||
* Persist and retrieve preferred map theme behavior for dark Carto tiles.
|
||||
* Helps keep Cyber style enabled even if server-side tile provider drifts.
|
||||
*/
|
||||
_getMapThemePreference() {
|
||||
if (typeof localStorage === 'undefined') return 'cyber';
|
||||
const pref = localStorage.getItem('intercept_map_theme_pref');
|
||||
if (pref === 'none' || pref === 'cyber') return pref;
|
||||
return 'cyber';
|
||||
},
|
||||
|
||||
_setMapThemePreference(pref) {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
if (pref !== 'none' && pref !== 'cyber') return;
|
||||
localStorage.setItem('intercept_map_theme_pref', pref);
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle root class used for hard global Leaflet theming.
|
||||
* @param {Object} [config]
|
||||
*/
|
||||
_syncRootMapThemeClass(config) {
|
||||
if (typeof document === 'undefined' || !document.documentElement) return;
|
||||
const resolvedConfig = config || this.getTileConfig();
|
||||
const themeClass = this._getMapThemeClass(resolvedConfig);
|
||||
document.documentElement.classList.toggle('map-cyber-enabled', themeClass === 'map-theme-cyber');
|
||||
},
|
||||
|
||||
/**
|
||||
* Prefer localStorage tile settings when available to avoid stale server values.
|
||||
*/
|
||||
_applyLocalTileOverrides() {
|
||||
const stored = localStorage.getItem('intercept_settings');
|
||||
if (!stored) return;
|
||||
|
||||
try {
|
||||
const local = JSON.parse(stored) || {};
|
||||
const localProvider = this._normalizeTileProvider(local['offline.tile_provider']);
|
||||
if (localProvider) {
|
||||
this._cache['offline.tile_provider'] = localProvider;
|
||||
}
|
||||
if (typeof local['offline.tile_server_url'] === 'string') {
|
||||
this._cache['offline.tile_server_url'] = local['offline.tile_server_url'];
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore malformed local settings and keep current cache.
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize settings - load from server/localStorage
|
||||
*/
|
||||
async init() {
|
||||
try {
|
||||
const response = await fetch('/offline/settings');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this._cache = { ...this.defaults, ...data.settings };
|
||||
} else {
|
||||
// Fall back to localStorage
|
||||
this._loadFromLocalStorage();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load settings from server, using localStorage:', e);
|
||||
this._loadFromLocalStorage();
|
||||
async init(options = {}) {
|
||||
const force = Boolean(options && options.force);
|
||||
|
||||
if (!force && this._initialized) {
|
||||
return this._cache;
|
||||
}
|
||||
|
||||
this._updateUI();
|
||||
return this._cache;
|
||||
if (!force && this._initPromise) {
|
||||
return this._initPromise;
|
||||
}
|
||||
|
||||
this._initPromise = (async () => {
|
||||
try {
|
||||
const response = await fetch('/offline/settings');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this._cache = { ...this.defaults, ...data.settings };
|
||||
} else {
|
||||
// Fall back to localStorage
|
||||
this._loadFromLocalStorage();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load settings from server, using localStorage:', e);
|
||||
this._loadFromLocalStorage();
|
||||
}
|
||||
|
||||
this._applyLocalTileOverrides();
|
||||
this._cache['offline.tile_provider'] = this._normalizeTileProvider(this._cache['offline.tile_provider']);
|
||||
|
||||
// If dark Carto was restored by stale server settings but user prefers Cyber,
|
||||
// keep the visible provider aligned with Cyber selection.
|
||||
if (this._cache['offline.tile_provider'] === 'cartodb_dark' && this._getMapThemePreference() === 'cyber') {
|
||||
this._cache['offline.tile_provider'] = 'cartodb_dark_cyan';
|
||||
}
|
||||
this._updateUI();
|
||||
|
||||
// Re-apply map theme to already-registered maps in case init happened after map creation.
|
||||
const allMaps = this._collectMaps();
|
||||
if (allMaps.length > 0) {
|
||||
const config = this.getTileConfig();
|
||||
allMaps.forEach((map) => this._applyMapTheme(map, config));
|
||||
}
|
||||
const activeConfig = this.getTileConfig();
|
||||
this._syncRootMapThemeClass(activeConfig);
|
||||
this._applyThemeToAllContainers(activeConfig);
|
||||
this._ensureThemeObserver();
|
||||
|
||||
this._initialized = true;
|
||||
return this._cache;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await this._initPromise;
|
||||
} finally {
|
||||
this._initPromise = null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -99,11 +218,14 @@ const Settings = {
|
||||
|
||||
// Save to server
|
||||
try {
|
||||
await fetch('/offline/settings', {
|
||||
const response = await fetch('/offline/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, value })
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Save failed (${response.status})`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to save setting to server:', e);
|
||||
}
|
||||
@@ -152,6 +274,16 @@ const Settings = {
|
||||
* Set tile provider
|
||||
*/
|
||||
async setTileProvider(provider) {
|
||||
provider = this._normalizeTileProvider(provider);
|
||||
|
||||
if (provider === 'cartodb_dark_cyan') {
|
||||
this._setMapThemePreference('cyber');
|
||||
} else if (provider === 'cartodb_dark') {
|
||||
this._setMapThemePreference('none');
|
||||
} else {
|
||||
this._setMapThemePreference('none');
|
||||
}
|
||||
|
||||
await this._save('offline.tile_provider', provider);
|
||||
|
||||
// Show/hide custom URL input
|
||||
@@ -160,10 +292,11 @@ const Settings = {
|
||||
customRow.style.display = provider === 'custom' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// If not custom and we have a map, update tiles immediately
|
||||
if (provider !== 'custom') {
|
||||
this._updateMapTiles();
|
||||
}
|
||||
// Update tiles immediately for all providers.
|
||||
this._updateMapTiles();
|
||||
const activeConfig = this.getTileConfig();
|
||||
this._syncRootMapThemeClass(activeConfig);
|
||||
this._applyThemeToAllContainers(activeConfig);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -178,7 +311,7 @@ const Settings = {
|
||||
* Get current tile configuration
|
||||
*/
|
||||
getTileConfig() {
|
||||
const provider = this.get('offline.tile_provider');
|
||||
const provider = this._normalizeTileProvider(this.get('offline.tile_provider'));
|
||||
|
||||
if (provider === 'custom') {
|
||||
const customUrl = this.get('offline.tile_server_url');
|
||||
@@ -189,7 +322,172 @@ const Settings = {
|
||||
};
|
||||
}
|
||||
|
||||
return this.tileProviders[provider] || this.tileProviders.cartodb_dark;
|
||||
const config = this.tileProviders[provider] || this.tileProviders.cartodb_dark;
|
||||
|
||||
// Robust fallback: if dark Carto is active and Cyber is preferred,
|
||||
// keep Cyber theme enabled even when provider temporarily reverts.
|
||||
if (provider === 'cartodb_dark' && this._getMapThemePreference() === 'cyber') {
|
||||
return { ...config, mapTheme: 'cyber' };
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve map theme class from tile config.
|
||||
* @param {Object} config
|
||||
* @returns {string|null}
|
||||
*/
|
||||
_getMapThemeClass(config) {
|
||||
if (!config || !config.mapTheme) return null;
|
||||
if (config.mapTheme === 'cyber') return 'map-theme-cyber';
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply or clear map theme styles for a Leaflet container.
|
||||
* @param {HTMLElement} container
|
||||
* @param {Object} [config]
|
||||
*/
|
||||
_applyThemeToContainer(container, config) {
|
||||
if (!container || !container.classList) return;
|
||||
const tilePane = container.querySelector('.leaflet-tile-pane');
|
||||
|
||||
container.querySelectorAll('.intercept-map-theme-overlay').forEach((el) => el.remove());
|
||||
|
||||
if (tilePane && tilePane.style) {
|
||||
tilePane.style.filter = '';
|
||||
tilePane.style.opacity = '';
|
||||
tilePane.style.willChange = '';
|
||||
}
|
||||
if (container.style) {
|
||||
container.style.background = '';
|
||||
}
|
||||
|
||||
container.classList.remove('map-theme-cyber');
|
||||
|
||||
const resolvedConfig = config || this.getTileConfig();
|
||||
const themeClass = this._getMapThemeClass(resolvedConfig);
|
||||
if (!themeClass) return;
|
||||
|
||||
container.classList.add(themeClass);
|
||||
|
||||
if (themeClass === 'map-theme-cyber') {
|
||||
if (container.style) {
|
||||
container.style.background = '#020813';
|
||||
}
|
||||
if (tilePane && tilePane.style) {
|
||||
tilePane.style.filter = 'sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08)';
|
||||
tilePane.style.opacity = '1';
|
||||
tilePane.style.willChange = 'filter';
|
||||
}
|
||||
}
|
||||
|
||||
// Map overlays are rendered via CSS pseudo elements on
|
||||
// `html.map-*-enabled .leaflet-container` for consistent stacking.
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply/remove map theme class on a Leaflet map container.
|
||||
* @param {L.Map} map
|
||||
* @param {Object} [config]
|
||||
*/
|
||||
_applyMapTheme(map, config) {
|
||||
if (!map || typeof map.getContainer !== 'function') return;
|
||||
const container = map.getContainer();
|
||||
this._applyThemeToContainer(container, config);
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply current map theme to all rendered Leaflet containers.
|
||||
* Covers maps that were not explicitly registered with Settings.
|
||||
* @param {Object} [config]
|
||||
*/
|
||||
_applyThemeToAllContainers(config) {
|
||||
if (typeof document === 'undefined') return;
|
||||
const containers = document.querySelectorAll('.leaflet-container');
|
||||
if (!containers.length) return;
|
||||
|
||||
const resolvedConfig = config || this.getTileConfig();
|
||||
this._syncRootMapThemeClass(resolvedConfig);
|
||||
containers.forEach((container) => this._applyThemeToContainer(container, resolvedConfig));
|
||||
},
|
||||
|
||||
/**
|
||||
* Watch the DOM for new Leaflet maps and apply current theme automatically.
|
||||
*/
|
||||
_ensureThemeObserver() {
|
||||
if (this._themeObserverStarted || typeof MutationObserver === 'undefined') return;
|
||||
if (typeof document === 'undefined' || !document.body) return;
|
||||
|
||||
const scheduleApply = () => {
|
||||
if (this._themeObserverRaf && typeof cancelAnimationFrame === 'function') {
|
||||
cancelAnimationFrame(this._themeObserverRaf);
|
||||
}
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
this._themeObserverRaf = requestAnimationFrame(() => {
|
||||
this._themeObserverRaf = null;
|
||||
this._applyThemeToAllContainers(this.getTileConfig());
|
||||
});
|
||||
} else {
|
||||
this._applyThemeToAllContainers(this.getTileConfig());
|
||||
}
|
||||
};
|
||||
|
||||
this._themeObserver = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (!mutation.addedNodes || mutation.addedNodes.length === 0) continue;
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (!(node instanceof Element)) continue;
|
||||
if (node.classList.contains('leaflet-container') || node.querySelector('.leaflet-container')) {
|
||||
scheduleApply();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this._themeObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
this._themeObserverStarted = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Collect all known map instances.
|
||||
* @returns {L.Map[]}
|
||||
*/
|
||||
_collectMaps() {
|
||||
const windowMaps = [
|
||||
window.map,
|
||||
window.leafletMap,
|
||||
window.aprsMap,
|
||||
window.radarMap,
|
||||
window.vesselMap,
|
||||
window.groundMap,
|
||||
window.groundTrackMap,
|
||||
window.meshMap,
|
||||
window.issMap
|
||||
].filter(m => m && typeof m.eachLayer === 'function');
|
||||
|
||||
return [...new Set([...this._registeredMaps, ...windowMaps])];
|
||||
},
|
||||
|
||||
/**
|
||||
* Keep map theme stable if map internals or layers are refreshed.
|
||||
* @param {L.Map} map - Leaflet map instance
|
||||
*/
|
||||
_attachMapThemeHooks(map) {
|
||||
if (!map || typeof map.on !== 'function' || map._interceptThemeHookBound) return;
|
||||
|
||||
const reapplyTheme = () => this._applyMapTheme(map);
|
||||
const hookEvents = ['layeradd', 'layerremove', 'zoomend', 'resize', 'load'];
|
||||
hookEvents.forEach((eventName) => map.on(eventName, reapplyTheme));
|
||||
|
||||
map._interceptThemeHookBound = true;
|
||||
map._interceptThemeHookHandler = reapplyTheme;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -200,6 +498,18 @@ const Settings = {
|
||||
if (map && typeof map.eachLayer === 'function' && !this._registeredMaps.includes(map)) {
|
||||
this._registeredMaps.push(map);
|
||||
}
|
||||
this._ensureThemeObserver();
|
||||
this._attachMapThemeHooks(map);
|
||||
this._applyMapTheme(map);
|
||||
this._applyThemeToAllContainers(this.getTileConfig());
|
||||
|
||||
// Some maps create tile DOM asynchronously; re-apply after first paint.
|
||||
if (typeof window !== 'undefined' && typeof window.setTimeout === 'function') {
|
||||
window.setTimeout(() => {
|
||||
this._applyMapTheme(map);
|
||||
this._applyThemeToAllContainers(this.getTileConfig());
|
||||
}, 120);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -211,6 +521,15 @@ const Settings = {
|
||||
if (idx > -1) {
|
||||
this._registeredMaps.splice(idx, 1);
|
||||
}
|
||||
|
||||
if (map && map._interceptThemeHookBound && typeof map.off === 'function') {
|
||||
const handler = map._interceptThemeHookHandler;
|
||||
['layeradd', 'layerremove', 'zoomend', 'resize', 'load'].forEach((eventName) => {
|
||||
map.off(eventName, handler);
|
||||
});
|
||||
delete map._interceptThemeHookBound;
|
||||
delete map._interceptThemeHookHandler;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -323,31 +642,29 @@ const Settings = {
|
||||
if (customRow) {
|
||||
customRow.style.display = this.get('offline.tile_provider') === 'custom' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Theme select
|
||||
const themeSelect = document.getElementById('themeSelect');
|
||||
if (themeSelect) {
|
||||
themeSelect.value = localStorage.getItem('intercept-theme') || 'dark';
|
||||
}
|
||||
|
||||
// Animations toggle
|
||||
const animationsEnabled = document.getElementById('animationsEnabled');
|
||||
if (animationsEnabled) {
|
||||
animationsEnabled.checked = localStorage.getItem('intercept-animations') !== 'off';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update map tiles on all known maps
|
||||
*/
|
||||
_updateMapTiles() {
|
||||
// Combine registered maps with common window map variables
|
||||
const windowMaps = [
|
||||
window.map,
|
||||
window.leafletMap,
|
||||
window.aprsMap,
|
||||
window.radarMap,
|
||||
window.vesselMap,
|
||||
window.groundMap,
|
||||
window.groundTrackMap,
|
||||
window.meshMap,
|
||||
window.issMap
|
||||
].filter(m => m && typeof m.eachLayer === 'function');
|
||||
|
||||
// Combine with registered maps, removing duplicates
|
||||
const allMaps = [...new Set([...this._registeredMaps, ...windowMaps])];
|
||||
|
||||
const allMaps = this._collectMaps();
|
||||
if (allMaps.length === 0) return;
|
||||
|
||||
const config = this.getTileConfig();
|
||||
this._syncRootMapThemeClass(config);
|
||||
|
||||
allMaps.forEach(map => {
|
||||
// Remove existing tile layers
|
||||
@@ -368,7 +685,10 @@ const Settings = {
|
||||
}
|
||||
|
||||
L.tileLayer(config.url, options).addTo(map);
|
||||
this._applyMapTheme(map, config);
|
||||
});
|
||||
|
||||
this._applyThemeToAllContainers(config);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -423,10 +743,16 @@ const Settings = {
|
||||
};
|
||||
|
||||
// Settings modal functions
|
||||
let lastSettingsFocusEl = null;
|
||||
|
||||
function showSettings() {
|
||||
const modal = document.getElementById('settingsModal');
|
||||
if (modal) {
|
||||
lastSettingsFocusEl = document.activeElement;
|
||||
modal.classList.add('active');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
const content = modal.querySelector('.settings-content');
|
||||
if (content) content.focus();
|
||||
Settings.init().then(() => {
|
||||
Settings.checkAssets();
|
||||
});
|
||||
@@ -437,18 +763,27 @@ function hideSettings() {
|
||||
const modal = document.getElementById('settingsModal');
|
||||
if (modal) {
|
||||
modal.classList.remove('active');
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
if (lastSettingsFocusEl && typeof lastSettingsFocusEl.focus === 'function') {
|
||||
lastSettingsFocusEl.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function switchSettingsTab(tabName) {
|
||||
// Update tab buttons
|
||||
document.querySelectorAll('.settings-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.tab === tabName);
|
||||
const isActive = tab.dataset.tab === tabName;
|
||||
tab.classList.toggle('active', isActive);
|
||||
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
||||
});
|
||||
|
||||
// Update sections
|
||||
document.querySelectorAll('.settings-section').forEach(section => {
|
||||
section.classList.toggle('active', section.id === `settings-${tabName}`);
|
||||
const isActive = section.id === `settings-${tabName}`;
|
||||
section.classList.toggle('active', isActive);
|
||||
section.hidden = !isActive;
|
||||
section.setAttribute('role', 'tabpanel');
|
||||
});
|
||||
|
||||
// Load tools/dependencies when that tab is selected
|
||||
@@ -545,11 +880,6 @@ function loadSettingsTools() {
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize settings on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
Settings.init();
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Location Settings Functions
|
||||
// =============================================================================
|
||||
@@ -582,7 +912,7 @@ function loadObserverLocation() {
|
||||
}
|
||||
|
||||
// Sync dashboard-specific location keys for backward compatibility
|
||||
if (lat && lon) {
|
||||
if (lat !== undefined && lat !== null && lat !== '' && lon !== undefined && lon !== null && lon !== '') {
|
||||
const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) });
|
||||
if (!localStorage.getItem('observerLocation')) {
|
||||
localStorage.setItem('observerLocation', locationObj);
|
||||
@@ -833,11 +1163,11 @@ function renderUpdateStatus(data) {
|
||||
<div style="display: grid; gap: 8px; font-size: 12px;">
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: var(--text-dim);">Current Version</span>
|
||||
<span style="font-family: 'Space Mono', monospace; color: var(--text-primary);">v${data.current_version}</span>
|
||||
<span style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; color: var(--text-primary);">v${data.current_version}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: var(--text-dim);">Latest Version</span>
|
||||
<span style="font-family: 'Space Mono', monospace; color: ${data.update_available ? 'var(--accent-green)' : 'var(--text-primary)'};">v${data.latest_version}</span>
|
||||
<span style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; color: ${data.update_available ? 'var(--accent-green)' : 'var(--text-primary)'};">v${data.latest_version}</span>
|
||||
</div>
|
||||
${data.last_check ? `
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
@@ -907,12 +1237,17 @@ const _originalSwitchSettingsTab = typeof switchSettingsTab !== 'undefined' ? sw
|
||||
function switchSettingsTab(tabName) {
|
||||
// Update tab buttons
|
||||
document.querySelectorAll('.settings-tab').forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.tab === tabName);
|
||||
const isActive = tab.dataset.tab === tabName;
|
||||
tab.classList.toggle('active', isActive);
|
||||
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
||||
});
|
||||
|
||||
// Update sections
|
||||
document.querySelectorAll('.settings-section').forEach(section => {
|
||||
section.classList.toggle('active', section.id === `settings-${tabName}`);
|
||||
const isActive = section.id === `settings-${tabName}`;
|
||||
section.classList.toggle('active', isActive);
|
||||
section.hidden = !isActive;
|
||||
section.setAttribute('role', 'tabpanel');
|
||||
});
|
||||
|
||||
// Load content based on tab
|
||||
@@ -923,6 +1258,7 @@ function switchSettingsTab(tabName) {
|
||||
} else if (tabName === 'location') {
|
||||
loadObserverLocation();
|
||||
} else if (tabName === 'alerts') {
|
||||
loadVoiceAlertConfig();
|
||||
if (typeof AlertCenter !== 'undefined') {
|
||||
AlertCenter.loadFeed();
|
||||
}
|
||||
@@ -935,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
|
||||
*/
|
||||
@@ -983,3 +1374,45 @@ function toggleApiKeyVisibility() {
|
||||
if (!input) return;
|
||||
input.type = input.type === 'password' ? 'text' : 'password';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set theme preference from the Display settings tab
|
||||
*/
|
||||
function setThemePreference(value) {
|
||||
document.documentElement.setAttribute('data-theme', value);
|
||||
localStorage.setItem('intercept-theme', value);
|
||||
|
||||
const btn = document.getElementById('themeToggle');
|
||||
if (btn) {
|
||||
btn.textContent = value === 'light' ? '🌙' : '☀️';
|
||||
}
|
||||
|
||||
fetch('/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ theme: value })
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set animations preference from the Display settings tab
|
||||
*/
|
||||
function setAnimationsEnabled(enabled) {
|
||||
if (enabled) {
|
||||
document.documentElement.removeAttribute('data-animations');
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-animations', 'off');
|
||||
}
|
||||
localStorage.setItem('intercept-animations', enabled ? 'on' : 'off');
|
||||
}
|
||||
|
||||
if (!window._settingsEscapeHandlerBound) {
|
||||
window._settingsEscapeHandlerBound = true;
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
const modal = document.getElementById('settingsModal');
|
||||
if (modal && modal.classList.contains('active')) {
|
||||
hideSettings();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
const AppFeedback = (function() {
|
||||
'use strict';
|
||||
|
||||
let stackEl = null;
|
||||
let nextToastId = 1;
|
||||
|
||||
function init() {
|
||||
ensureStack();
|
||||
installGlobalHandlers();
|
||||
}
|
||||
|
||||
function ensureStack() {
|
||||
if (stackEl && document.body.contains(stackEl)) return stackEl;
|
||||
|
||||
stackEl = document.getElementById('appToastStack');
|
||||
if (!stackEl) {
|
||||
stackEl = document.createElement('div');
|
||||
stackEl.id = 'appToastStack';
|
||||
stackEl.className = 'app-toast-stack';
|
||||
document.body.appendChild(stackEl);
|
||||
}
|
||||
return stackEl;
|
||||
}
|
||||
|
||||
function toast(options) {
|
||||
const opts = options || {};
|
||||
const type = normalizeType(opts.type);
|
||||
const id = nextToastId++;
|
||||
const durationMs = Number.isFinite(opts.durationMs) ? opts.durationMs : 6500;
|
||||
|
||||
const root = document.createElement('div');
|
||||
root.className = `app-toast ${type}`;
|
||||
root.dataset.toastId = String(id);
|
||||
|
||||
const titleEl = document.createElement('div');
|
||||
titleEl.className = 'app-toast-title';
|
||||
titleEl.textContent = String(opts.title || defaultTitle(type));
|
||||
root.appendChild(titleEl);
|
||||
|
||||
const msgEl = document.createElement('div');
|
||||
msgEl.className = 'app-toast-msg';
|
||||
msgEl.textContent = String(opts.message || '');
|
||||
root.appendChild(msgEl);
|
||||
|
||||
const actions = Array.isArray(opts.actions) ? opts.actions.filter(Boolean).slice(0, 3) : [];
|
||||
if (actions.length > 0) {
|
||||
const actionsEl = document.createElement('div');
|
||||
actionsEl.className = 'app-toast-actions';
|
||||
for (const action of actions) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = String(action.label || 'Action');
|
||||
btn.addEventListener('click', () => {
|
||||
try {
|
||||
if (typeof action.onClick === 'function') {
|
||||
action.onClick();
|
||||
}
|
||||
} finally {
|
||||
removeToast(id);
|
||||
}
|
||||
});
|
||||
actionsEl.appendChild(btn);
|
||||
}
|
||||
root.appendChild(actionsEl);
|
||||
}
|
||||
|
||||
ensureStack().appendChild(root);
|
||||
|
||||
if (durationMs > 0) {
|
||||
window.setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, durationMs);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function removeToast(id) {
|
||||
if (!stackEl) return;
|
||||
const toastEl = stackEl.querySelector(`[data-toast-id="${id}"]`);
|
||||
if (!toastEl) return;
|
||||
toastEl.remove();
|
||||
}
|
||||
|
||||
function reportError(context, error, options) {
|
||||
const opts = options || {};
|
||||
const message = extractMessage(error);
|
||||
const actions = [];
|
||||
|
||||
if (isSettingsError(message)) {
|
||||
actions.push({
|
||||
label: 'Open Settings',
|
||||
onClick: () => {
|
||||
if (typeof showSettings === 'function') {
|
||||
showSettings();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (isNetworkError(message)) {
|
||||
actions.push({
|
||||
label: 'Retry',
|
||||
onClick: () => {
|
||||
if (typeof opts.onRetry === 'function') {
|
||||
opts.onRetry();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof opts.extraAction === 'function' && opts.extraActionLabel) {
|
||||
actions.push({
|
||||
label: String(opts.extraActionLabel),
|
||||
onClick: opts.extraAction,
|
||||
});
|
||||
}
|
||||
|
||||
return toast({
|
||||
type: 'error',
|
||||
title: context || 'Action Failed',
|
||||
message,
|
||||
actions,
|
||||
durationMs: opts.persistent ? 0 : 8500,
|
||||
});
|
||||
}
|
||||
|
||||
function installGlobalHandlers() {
|
||||
window.addEventListener('error', (event) => {
|
||||
const target = event && event.target;
|
||||
if (target && (target.tagName === 'IMG' || target.tagName === 'SCRIPT')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = extractMessage(event && event.error) || String(event.message || 'Unknown error');
|
||||
if (shouldIgnore(message)) return;
|
||||
toast({
|
||||
type: 'warning',
|
||||
title: 'Unhandled Error',
|
||||
message,
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const message = extractMessage(event && event.reason);
|
||||
if (shouldIgnore(message)) return;
|
||||
toast({
|
||||
type: 'warning',
|
||||
title: 'Promise Rejection',
|
||||
message,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeType(type) {
|
||||
const t = String(type || 'info').toLowerCase();
|
||||
if (t === 'error' || t === 'warning') return t;
|
||||
return 'info';
|
||||
}
|
||||
|
||||
function defaultTitle(type) {
|
||||
if (type === 'error') return 'Error';
|
||||
if (type === 'warning') return 'Warning';
|
||||
return 'Notice';
|
||||
}
|
||||
|
||||
function extractMessage(error) {
|
||||
if (!error) return 'Unknown error';
|
||||
if (typeof error === 'string') return error;
|
||||
if (error instanceof Error) return error.message || error.name;
|
||||
if (typeof error.message === 'string') return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function shouldIgnore(message) {
|
||||
const text = String(message || '').toLowerCase();
|
||||
return text.includes('script error') || text.includes('resizeobserver loop limit exceeded');
|
||||
}
|
||||
|
||||
function renderCollectionState(container, options) {
|
||||
if (!container) return null;
|
||||
const opts = options || {};
|
||||
const type = String(opts.type || 'empty').toLowerCase();
|
||||
const message = String(opts.message || (type === 'loading' ? 'Loading...' : 'No data available'));
|
||||
const className = opts.className || `app-collection-state is-${type}`;
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
if (container.tagName === 'TBODY') {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'app-collection-state-row';
|
||||
const cell = document.createElement('td');
|
||||
const columns = Number.isFinite(opts.columns) ? opts.columns : 1;
|
||||
cell.colSpan = Math.max(1, columns);
|
||||
const state = document.createElement('div');
|
||||
state.className = className;
|
||||
state.textContent = message;
|
||||
cell.appendChild(state);
|
||||
row.appendChild(cell);
|
||||
container.appendChild(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
const state = document.createElement('div');
|
||||
state.className = className;
|
||||
state.textContent = message;
|
||||
container.appendChild(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function isNetworkError(message) {
|
||||
const text = String(message || '').toLowerCase();
|
||||
return text.includes('networkerror') || text.includes('failed to fetch') || text.includes('timeout');
|
||||
}
|
||||
|
||||
function isSettingsError(message) {
|
||||
const text = String(message || '').toLowerCase();
|
||||
return text.includes('permission') || text.includes('denied') || text.includes('dependency') || text.includes('tool');
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
toast,
|
||||
reportError,
|
||||
removeToast,
|
||||
renderCollectionState,
|
||||
};
|
||||
})();
|
||||
|
||||
window.showAppToast = function(title, message, type) {
|
||||
return AppFeedback.toast({
|
||||
title,
|
||||
message,
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
window.reportActionableError = function(context, error, options) {
|
||||
return AppFeedback.reportError(context, error, options);
|
||||
};
|
||||
|
||||
window.renderCollectionState = function(container, options) {
|
||||
return AppFeedback.renderCollectionState(container, options);
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
AppFeedback.init();
|
||||
});
|
||||
@@ -78,13 +78,14 @@ const Updater = {
|
||||
* Show update toast notification
|
||||
* @param {Object} data - Update data from server
|
||||
*/
|
||||
showUpdateToast(data) {
|
||||
// Remove existing toast if present
|
||||
this.hideToast();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'update-toast';
|
||||
toast.innerHTML = `
|
||||
showUpdateToast(data) {
|
||||
// Remove existing toast if present
|
||||
this.hideToast();
|
||||
const latestVersion = this._escape(data.latest_version || '');
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'update-toast';
|
||||
toast.innerHTML = `
|
||||
<div class="update-toast-indicator"></div>
|
||||
<div class="update-toast-content">
|
||||
<div class="update-toast-header">
|
||||
@@ -97,11 +98,11 @@ const Updater = {
|
||||
</span>
|
||||
<span class="update-toast-title">Update Available</span>
|
||||
<button class="update-toast-close" onclick="Updater.dismissUpdate()">×</button>
|
||||
</div>
|
||||
<div class="update-toast-body">
|
||||
Version <strong>${data.latest_version}</strong> is ready
|
||||
</div>
|
||||
<div class="update-toast-actions">
|
||||
</div>
|
||||
<div class="update-toast-body">
|
||||
Version <strong>${latestVersion}</strong> is ready
|
||||
</div>
|
||||
<div class="update-toast-actions">
|
||||
<button class="update-toast-btn update-toast-btn-primary" onclick="Updater.showUpdateModal()">
|
||||
View Details
|
||||
</button>
|
||||
@@ -172,14 +173,17 @@ const Updater = {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove existing modal if present
|
||||
this.hideModal();
|
||||
|
||||
const data = this._updateData;
|
||||
const releaseNotes = this._formatReleaseNotes(data.release_notes || 'No release notes available.');
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'update-modal-overlay';
|
||||
// Remove existing modal if present
|
||||
this.hideModal();
|
||||
|
||||
const data = this._updateData;
|
||||
const releaseNotes = this._formatReleaseNotes(data.release_notes || 'No release notes available.');
|
||||
const safeCurrentVersion = this._escape(data.current_version || '');
|
||||
const safeLatestVersion = this._escape(data.latest_version || '');
|
||||
const safeReleaseUrl = this._safeUrl(data.release_url || '');
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'update-modal-overlay';
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal) this.hideModal();
|
||||
};
|
||||
@@ -201,21 +205,21 @@ const Updater = {
|
||||
</div>
|
||||
<div class="update-modal-body">
|
||||
<div class="update-version-info">
|
||||
<div class="update-version-current">
|
||||
<span class="update-version-label">Current</span>
|
||||
<span class="update-version-value">v${data.current_version}</span>
|
||||
</div>
|
||||
<div class="update-version-current">
|
||||
<span class="update-version-label">Current</span>
|
||||
<span class="update-version-value">v${safeCurrentVersion}</span>
|
||||
</div>
|
||||
<div class="update-version-arrow">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
<polyline points="12 5 19 12 12 19"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="update-version-latest">
|
||||
<span class="update-version-label">Latest</span>
|
||||
<span class="update-version-value update-version-new">v${data.latest_version}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="update-version-latest">
|
||||
<span class="update-version-label">Latest</span>
|
||||
<span class="update-version-value update-version-new">v${safeLatestVersion}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="update-section">
|
||||
<div class="update-section-title">Release Notes</div>
|
||||
@@ -249,11 +253,11 @@ const Updater = {
|
||||
</div>
|
||||
|
||||
<div class="update-result" id="updateResult" style="display: none;"></div>
|
||||
</div>
|
||||
<div class="update-modal-footer">
|
||||
<a href="${data.release_url || '#'}" target="_blank" class="update-modal-link" ${!data.release_url ? 'style="display:none"' : ''}>
|
||||
View on GitHub
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
|
||||
</div>
|
||||
<div class="update-modal-footer">
|
||||
<a href="${safeReleaseUrl || '#'}" target="_blank" class="update-modal-link" ${!safeReleaseUrl ? 'style="display:none"' : ''}>
|
||||
View on GitHub
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
<polyline points="15 3 21 3 21 9"/>
|
||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||
@@ -357,14 +361,16 @@ const Updater = {
|
||||
/**
|
||||
* Show update result
|
||||
*/
|
||||
_showResult(resultEl, success, data, isManual = false) {
|
||||
if (!resultEl) return;
|
||||
|
||||
resultEl.style.display = 'block';
|
||||
|
||||
if (success) {
|
||||
if (data.updated) {
|
||||
let message = '<strong>Update successful!</strong><br>Please restart the application to complete the update.';
|
||||
_showResult(resultEl, success, data, isManual = false) {
|
||||
if (!resultEl) return;
|
||||
|
||||
resultEl.style.display = 'block';
|
||||
const safeMessage = this._escape(data.message || data.error || 'An error occurred during the update.');
|
||||
const safeDetails = data.details ? this._escape(String(data.details).substring(0, 200)) : '';
|
||||
|
||||
if (success) {
|
||||
if (data.updated) {
|
||||
let message = '<strong>Update successful!</strong><br>Please restart the application to complete the update.';
|
||||
|
||||
if (data.requirements_changed) {
|
||||
message += '<br><br><strong>Dependencies changed!</strong> Run:<br><code>pip install -r requirements.txt</code>';
|
||||
@@ -380,22 +386,22 @@ const Updater = {
|
||||
</div>
|
||||
<div class="update-result-text">${message}</div>
|
||||
`;
|
||||
} else {
|
||||
resultEl.className = 'update-result update-result-info';
|
||||
resultEl.innerHTML = `
|
||||
} else {
|
||||
resultEl.className = 'update-result update-result-info';
|
||||
resultEl.innerHTML = `
|
||||
<div class="update-result-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="update-result-text">${data.message || 'Already up to date.'}</div>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
if (isManual) {
|
||||
resultEl.className = 'update-result update-result-warning';
|
||||
</div>
|
||||
<div class="update-result-text">${this._escape(data.message || 'Already up to date.')}</div>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
if (isManual) {
|
||||
resultEl.className = 'update-result update-result-warning';
|
||||
resultEl.innerHTML = `
|
||||
<div class="update-result-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -403,14 +409,14 @@ const Updater = {
|
||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="update-result-text">
|
||||
<strong>Manual update required</strong><br>
|
||||
${data.message || 'Please download the latest release from GitHub.'}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
resultEl.className = 'update-result update-result-error';
|
||||
</div>
|
||||
<div class="update-result-text">
|
||||
<strong>Manual update required</strong><br>
|
||||
${safeMessage || 'Please download the latest release from GitHub.'}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
resultEl.className = 'update-result update-result-error';
|
||||
resultEl.innerHTML = `
|
||||
<div class="update-result-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -418,16 +424,16 @@ const Updater = {
|
||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="update-result-text">
|
||||
<strong>Update failed</strong><br>
|
||||
${data.message || data.error || 'An error occurred during the update.'}
|
||||
${data.details ? '<br><code style="font-size: 10px; margin-top: 8px; display: block;">' + data.details.substring(0, 200) + '</code>' : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
},
|
||||
</div>
|
||||
<div class="update-result-text">
|
||||
<strong>Update failed</strong><br>
|
||||
${safeMessage}
|
||||
${safeDetails ? '<br><code style="font-size: 10px; margin-top: 8px; display: block;">' + safeDetails + '</code>' : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Format release notes (basic markdown to HTML)
|
||||
@@ -461,11 +467,33 @@ const Updater = {
|
||||
// Line breaks
|
||||
.replace(/\n/g, '<br>');
|
||||
|
||||
// Wrap list items
|
||||
html = html.replace(/(<li>.*<\/li>)+/g, '<ul>$&</ul>');
|
||||
|
||||
return '<p>' + html + '</p>';
|
||||
},
|
||||
// Wrap list items
|
||||
html = html.replace(/(<li>.*<\/li>)+/g, '<ul>$&</ul>');
|
||||
|
||||
return '<p>' + html + '</p>';
|
||||
},
|
||||
|
||||
_escape(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
|
||||
_safeUrl(url) {
|
||||
if (!url) return '';
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
return parsed.href;
|
||||
}
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Manual trigger for settings panel
|
||||
|
||||
@@ -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;
|
||||