mirror of
https://github.com/smittix/intercept.git
synced 2026-06-13 08:13:32 -07:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -2,6 +2,56 @@
|
|||||||
|
|
||||||
All notable changes to iNTERCEPT will be documented in this file.
|
All notable changes to iNTERCEPT will be documented in this file.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- **RF Heatmap** - Geographic signal density visualization with Leaflet heatmap overlay
|
||||||
|
- **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
|
## [2.21.0] - 2026-02-20
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+11
-24
@@ -57,7 +57,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
soapysdr-module-airspy \
|
soapysdr-module-airspy \
|
||||||
airspy \
|
airspy \
|
||||||
limesuite \
|
limesuite \
|
||||||
hackrf \
|
|
||||||
# Utilities
|
# Utilities
|
||||||
curl \
|
curl \
|
||||||
procps \
|
procps \
|
||||||
@@ -94,7 +93,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libpulse-dev \
|
libpulse-dev \
|
||||||
libfftw3-dev \
|
libfftw3-dev \
|
||||||
liblapack-dev \
|
liblapack-dev \
|
||||||
libcodec2-dev \
|
|
||||||
libglib2.0-dev \
|
libglib2.0-dev \
|
||||||
libxml2-dev \
|
libxml2-dev \
|
||||||
# Build dump1090
|
# Build dump1090
|
||||||
@@ -191,6 +189,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
fi \
|
fi \
|
||||||
&& cd /tmp \
|
&& cd /tmp \
|
||||||
&& rm -rf /tmp/SatDump \
|
&& rm -rf /tmp/SatDump \
|
||||||
|
# Build hackrf CLI tools from source — avoids libhackrf0 version conflict
|
||||||
|
# between the 'hackrf' apt package and soapysdr-module-hackrf's newer libhackrf0
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/greatscottgadgets/hackrf.git \
|
||||||
|
&& cd hackrf/host \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make \
|
||||||
|
&& make install \
|
||||||
|
&& ldconfig \
|
||||||
|
&& rm -rf /tmp/hackrf \
|
||||||
# Build rtlamr (utility meter decoder - requires Go)
|
# Build rtlamr (utility meter decoder - requires Go)
|
||||||
&& cd /tmp \
|
&& cd /tmp \
|
||||||
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
|
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
|
||||||
@@ -199,27 +208,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& go install github.com/bemasher/rtlamr@latest \
|
&& go install github.com/bemasher/rtlamr@latest \
|
||||||
&& cp /tmp/gopath/bin/rtlamr /usr/bin/rtlamr \
|
&& cp /tmp/gopath/bin/rtlamr /usr/bin/rtlamr \
|
||||||
&& rm -rf /usr/local/go /tmp/gopath \
|
&& 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
|
# Cleanup build tools to reduce image size
|
||||||
# libgtk-3-dev is explicitly removed; runtime GTK libs remain for slowrx
|
# libgtk-3-dev is explicitly removed; runtime GTK libs remain for slowrx
|
||||||
&& apt-get remove -y \
|
&& apt-get remove -y \
|
||||||
@@ -247,7 +235,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libpulse-dev \
|
libpulse-dev \
|
||||||
libfftw3-dev \
|
libfftw3-dev \
|
||||||
liblapack-dev \
|
liblapack-dev \
|
||||||
libcodec2-dev \
|
|
||||||
&& apt-get autoremove -y \
|
&& apt-get autoremove -y \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="Python 3.9+">
|
<img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="Python 3.9+">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green.svg" alt="MIT License">
|
<img src="https://img.shields.io/badge/license-Apache--2.0-green.svg" alt="Apache 2.0 License">
|
||||||
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg" alt="Platform">
|
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg" alt="Platform">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Support the developer of this open-source project
|
|||||||
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF)
|
- **HF SSTV** - Terrestrial SSTV on shortwave frequencies (80m-10m, VHF, UHF)
|
||||||
- **APRS** - Amateur packet radio position reports and telemetry via direwolf
|
- **APRS** - Amateur packet radio position reports and telemetry via direwolf
|
||||||
- **Satellite Tracking** - Pass prediction with polar plot and ground track map
|
- **Satellite Tracking** - Pass prediction with polar plot and ground track map
|
||||||
- **Utility Meters** - Electric, gas, and water meter reading via rtl_amr
|
- **Utility Meters** - Electric, gas, and water meter reading via rtlamr
|
||||||
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
- **ADS-B History** - Persistent aircraft history with reporting dashboard (Postgres optional)
|
||||||
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
||||||
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
|
||||||
@@ -57,8 +57,6 @@ Support the developer of this open-source project
|
|||||||
|
|
||||||
## Installation / Debian / Ubuntu / MacOS
|
## Installation / Debian / Ubuntu / MacOS
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
**1. Clone and run:**
|
**1. Clone and run:**
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/smittix/intercept.git
|
git clone https://github.com/smittix/intercept.git
|
||||||
@@ -150,7 +148,7 @@ Set these as environment variables for either local installs or Docker:
|
|||||||
```bash
|
```bash
|
||||||
INTERCEPT_ADSB_AUTO_START=true \
|
INTERCEPT_ADSB_AUTO_START=true \
|
||||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||||
python app.py
|
sudo -E venv/bin/python intercept.py
|
||||||
```
|
```
|
||||||
|
|
||||||
**Docker example (.env)**
|
**Docker example (.env)**
|
||||||
@@ -172,7 +170,7 @@ Then open **/adsb/history** for the reporting dashboard.
|
|||||||
|
|
||||||
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
|
After starting, open **http://localhost:5050** in your browser. The username and password is <b>admin</b>:<b>admin</b>
|
||||||
|
|
||||||
The credentials can be change in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py
|
The credentials can be changed in the ADMIN_USERNAME & ADMIN_PASSWORD variables in config.py
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -245,7 +243,7 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
|||||||
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
|
[AIS-catcher](https://github.com/jvde-github/AIS-catcher) |
|
||||||
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
||||||
[direwolf](https://github.com/wb2osz/direwolf) |
|
[direwolf](https://github.com/wb2osz/direwolf) |
|
||||||
[rtl_amr](https://github.com/bemasher/rtlamr) |
|
[rtlamr](https://github.com/bemasher/rtlamr) |
|
||||||
[dumpvdl2](https://github.com/szpajder/dumpvdl2) |
|
[dumpvdl2](https://github.com/szpajder/dumpvdl2) |
|
||||||
[aircrack-ng](https://www.aircrack-ng.org/) |
|
[aircrack-ng](https://www.aircrack-ng.org/) |
|
||||||
[Leaflet.js](https://leafletjs.com/) |
|
[Leaflet.js](https://leafletjs.com/) |
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import subprocess
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session
|
from flask import Flask, render_template, jsonify, send_file, Response, request,redirect, url_for, flash, session, send_from_directory
|
||||||
from werkzeug.security import check_password_hash
|
from werkzeug.security import check_password_hash
|
||||||
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
||||||
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
||||||
@@ -100,11 +100,24 @@ def add_security_headers(response):
|
|||||||
def inject_offline_settings():
|
def inject_offline_settings():
|
||||||
"""Inject offline settings into all templates."""
|
"""Inject offline settings into all templates."""
|
||||||
from utils.database import get_setting
|
from utils.database import get_setting
|
||||||
|
|
||||||
|
# Privacy-first defaults: keep dashboard assets/fonts local to avoid
|
||||||
|
# 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 {
|
return {
|
||||||
'offline_settings': {
|
'offline_settings': {
|
||||||
'enabled': get_setting('offline.enabled', False),
|
'enabled': get_setting('offline.enabled', False),
|
||||||
'assets_source': get_setting('offline.assets_source', 'cdn'),
|
'assets_source': assets_source,
|
||||||
'fonts_source': get_setting('offline.fonts_source', 'cdn'),
|
'fonts_source': fonts_source,
|
||||||
'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'),
|
'tile_provider': get_setting('offline.tile_provider', 'cartodb_dark_cyan'),
|
||||||
'tile_server_url': get_setting('offline.tile_server_url', '')
|
'tile_server_url': get_setting('offline.tile_server_url', '')
|
||||||
}
|
}
|
||||||
@@ -177,12 +190,6 @@ dsc_rtl_process = None
|
|||||||
dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
dsc_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
dsc_lock = threading.Lock()
|
dsc_lock = threading.Lock()
|
||||||
|
|
||||||
# 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 (Technical Surveillance Countermeasures)
|
||||||
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
tscm_lock = threading.Lock()
|
tscm_lock = threading.Lock()
|
||||||
@@ -389,6 +396,18 @@ def favicon() -> Response:
|
|||||||
return send_file('favicon.svg', mimetype='image/svg+xml')
|
return send_file('favicon.svg', mimetype='image/svg+xml')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/sw.js')
|
||||||
|
def service_worker() -> Response:
|
||||||
|
resp = send_from_directory('static', 'sw.js', mimetype='application/javascript')
|
||||||
|
resp.headers['Service-Worker-Allowed'] = '/'
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/manifest.json')
|
||||||
|
def pwa_manifest() -> Response:
|
||||||
|
return send_from_directory('static', 'manifest.json', mimetype='application/manifest+json')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/devices')
|
@app.route('/devices')
|
||||||
def get_devices() -> Response:
|
def get_devices() -> Response:
|
||||||
"""Get all detected SDR devices with hardware type info."""
|
"""Get all detected SDR devices with hardware type info."""
|
||||||
@@ -661,16 +680,6 @@ def _get_subghz_active() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _get_dmr_active() -> bool:
|
|
||||||
"""Check if Digital Voice decoder has an active process."""
|
|
||||||
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)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _get_bluetooth_health() -> tuple[bool, int]:
|
def _get_bluetooth_health() -> tuple[bool, int]:
|
||||||
"""Return Bluetooth active state and best-effort device count."""
|
"""Return Bluetooth active state and best-effort device count."""
|
||||||
legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False)
|
legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False)
|
||||||
@@ -746,7 +755,6 @@ def health_check() -> Response:
|
|||||||
'wifi': wifi_active,
|
'wifi': wifi_active,
|
||||||
'bluetooth': bt_active,
|
'bluetooth': bt_active,
|
||||||
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
'dsc': dsc_process is not None and (dsc_process.poll() is None if dsc_process else False),
|
||||||
'dmr': _get_dmr_active(),
|
|
||||||
'subghz': _get_subghz_active(),
|
'subghz': _get_subghz_active(),
|
||||||
},
|
},
|
||||||
'data': {
|
'data': {
|
||||||
@@ -766,7 +774,6 @@ def kill_all() -> Response:
|
|||||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||||
global vdl2_process
|
global vdl2_process
|
||||||
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_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
|
# Import adsb and ais modules to reset their state
|
||||||
from routes import adsb as adsb_module
|
from routes import adsb as adsb_module
|
||||||
@@ -778,7 +785,7 @@ def kill_all() -> Response:
|
|||||||
'rtl_fm', 'multimon-ng', 'rtl_433',
|
'rtl_fm', 'multimon-ng', 'rtl_433',
|
||||||
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
||||||
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
|
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
|
||||||
'hcitool', 'bluetoothctl', 'satdump', 'dsd',
|
'hcitool', 'bluetoothctl', 'satdump',
|
||||||
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
||||||
'hackrf_transfer', 'hackrf_sweep'
|
'hackrf_transfer', 'hackrf_sweep'
|
||||||
]
|
]
|
||||||
@@ -828,11 +835,6 @@ def kill_all() -> Response:
|
|||||||
dsc_process = None
|
dsc_process = None
|
||||||
dsc_rtl_process = None
|
dsc_rtl_process = None
|
||||||
|
|
||||||
# Reset DMR state
|
|
||||||
with dmr_lock:
|
|
||||||
dmr_process = None
|
|
||||||
dmr_rtl_process = None
|
|
||||||
|
|
||||||
# Reset Bluetooth state (legacy)
|
# Reset Bluetooth state (legacy)
|
||||||
with bt_lock:
|
with bt_lock:
|
||||||
if bt_process:
|
if bt_process:
|
||||||
|
|||||||
@@ -7,10 +7,38 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "2.21.0"
|
VERSION = "2.22.0"
|
||||||
|
|
||||||
# Changelog - latest release notes (shown on welcome screen)
|
# Changelog - latest release notes (shown on welcome screen)
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "2.22.0",
|
||||||
|
"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",
|
||||||
|
"RF Heatmap for geographic signal density visualization",
|
||||||
|
"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",
|
"version": "2.21.0",
|
||||||
"date": "February 2026",
|
"date": "February 2026",
|
||||||
@@ -90,7 +118,6 @@ CHANGELOG = [
|
|||||||
"Pure Python SSTV decoder replacing broken slowrx dependency",
|
"Pure Python SSTV decoder replacing broken slowrx dependency",
|
||||||
"Real-time signal scope for pager, sensor, and SSTV modes",
|
"Real-time signal scope for pager, sensor, and SSTV modes",
|
||||||
"USB-level device probe to prevent cryptic rtl_fm crashes",
|
"USB-level device probe to prevent cryptic rtl_fm crashes",
|
||||||
"DMR dsd-fme protocol fixes, tuning controls, and state sync",
|
|
||||||
"SDR device lock-up fix from unreleased device registry on crash",
|
"SDR device lock-up fix from unreleased device registry on crash",
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -98,8 +125,6 @@ CHANGELOG = [
|
|||||||
"version": "2.14.0",
|
"version": "2.14.0",
|
||||||
"date": "February 2026",
|
"date": "February 2026",
|
||||||
"highlights": [
|
"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",
|
"HF SSTV general mode with predefined shortwave frequencies",
|
||||||
"WebSDR integration for remote HF/shortwave listening",
|
"WebSDR integration for remote HF/shortwave listening",
|
||||||
"Listening Post signal scanner and audio pipeline improvements",
|
"Listening Post signal scanner and audio pipeline improvements",
|
||||||
|
|||||||
@@ -24,17 +24,6 @@ Complete feature list for all modules.
|
|||||||
- **Wideband spectrum analysis** with real-time visualization
|
- **Wideband spectrum analysis** with real-time visualization
|
||||||
- **I/Q capture** - record raw samples for offline analysis
|
- **I/Q capture** - record raw samples for offline analysis
|
||||||
|
|
||||||
## AIS Vessel Tracking
|
|
||||||
|
|
||||||
- **Real-time vessel tracking** via AIS-catcher on 161.975/162.025 MHz
|
|
||||||
- **Full-screen dashboard** - dedicated popout with interactive map
|
|
||||||
- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
|
|
||||||
- **Vessel details popup** - name, MMSI, callsign, destination, ETA
|
|
||||||
- **Navigation data** - speed, course, heading, rate of turn
|
|
||||||
- **Ship type classification** - cargo, tanker, passenger, fishing, etc.
|
|
||||||
- **Vessel dimensions** - length, width, draught
|
|
||||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
|
||||||
|
|
||||||
## Spy Stations (Number Stations)
|
## Spy Stations (Number Stations)
|
||||||
|
|
||||||
- **Comprehensive database** of active number stations and diplomatic networks
|
- **Comprehensive database** of active number stations and diplomatic networks
|
||||||
|
|||||||
@@ -214,8 +214,6 @@ Extended base for full-screen dashboards (maps, visualizations).
|
|||||||
| `bt_locate` | BT Locate |
|
| `bt_locate` | BT Locate |
|
||||||
| `analytics` | Analytics dashboard |
|
| `analytics` | Analytics dashboard |
|
||||||
| `spaceweather` | Space weather |
|
| `spaceweather` | Space weather |
|
||||||
| `dmr` | DMR/P25 digital voice |
|
|
||||||
|
|
||||||
### Navigation Groups
|
### Navigation Groups
|
||||||
|
|
||||||
The navigation is organized into groups:
|
The navigation is organized into groups:
|
||||||
|
|||||||
+1
-1
@@ -172,7 +172,7 @@ Set the following environment variables (Docker recommended):
|
|||||||
```bash
|
```bash
|
||||||
INTERCEPT_ADSB_AUTO_START=true \
|
INTERCEPT_ADSB_AUTO_START=true \
|
||||||
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
INTERCEPT_SHARED_OBSERVER_LOCATION=false \
|
||||||
python app.py
|
sudo -E venv/bin/python intercept.py
|
||||||
```
|
```
|
||||||
|
|
||||||
**Docker example (.env)**
|
**Docker example (.env)**
|
||||||
|
|||||||
+3
-3
@@ -110,7 +110,7 @@
|
|||||||
<div class="feature-card" data-category="signals">
|
<div class="feature-card" data-category="signals">
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></div>
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></div>
|
||||||
<h3>Utility Meters</h3>
|
<h3>Utility Meters</h3>
|
||||||
<p>Smart meter monitoring via rtl_amr. Receive electric, gas, and water meter broadcasts in real time.</p>
|
<p>Smart meter monitoring via rtlamr. Receive electric, gas, and water meter broadcasts in real time.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="tracking">
|
<div class="feature-card" data-category="tracking">
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2L16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></svg></div>
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2L16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></svg></div>
|
||||||
@@ -321,7 +321,7 @@ sudo -E venv/bin/python intercept.py</code></pre>
|
|||||||
<div class="code-block">
|
<div class="code-block">
|
||||||
<pre><code>git clone https://github.com/smittix/intercept.git
|
<pre><code>git clone https://github.com/smittix/intercept.git
|
||||||
cd intercept
|
cd intercept
|
||||||
docker compose up -d</code></pre>
|
docker compose --profile basic up -d --build</code></pre>
|
||||||
</div>
|
</div>
|
||||||
<p class="install-note">Requires privileged mode for USB SDR access</p>
|
<p class="install-note">Requires privileged mode for USB SDR access</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -422,7 +422,7 @@ docker compose up -d</code></pre>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-bottom">
|
<div class="footer-bottom">
|
||||||
<p>Created by <a href="https://github.com/smittix" target="_blank">smittix</a> · MIT License</p>
|
<p>Created by <a href="https://github.com/smittix" target="_blank">smittix</a> · Apache 2.0 License</p>
|
||||||
<p class="disclaimer">For educational and authorized testing purposes only.</p>
|
<p class="disclaimer">For educational and authorized testing purposes only.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "intercept"
|
name = "intercept"
|
||||||
version = "2.21.0"
|
version = "2.21.1"
|
||||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
+31
-33
@@ -2,41 +2,40 @@
|
|||||||
|
|
||||||
def register_blueprints(app):
|
def register_blueprints(app):
|
||||||
"""Register all route blueprints with the Flask app."""
|
"""Register all route blueprints with the Flask app."""
|
||||||
from .pager import pager_bp
|
from .acars import acars_bp
|
||||||
from .sensor import sensor_bp
|
|
||||||
from .rtlamr import rtlamr_bp
|
|
||||||
from .wifi import wifi_bp
|
|
||||||
from .wifi_v2 import wifi_v2_bp
|
|
||||||
from .bluetooth import bluetooth_bp
|
|
||||||
from .bluetooth_v2 import bluetooth_v2_bp
|
|
||||||
from .adsb import adsb_bp
|
from .adsb import adsb_bp
|
||||||
from .ais import ais_bp
|
from .ais import ais_bp
|
||||||
from .dsc import dsc_bp
|
|
||||||
from .acars import acars_bp
|
|
||||||
from .vdl2 import vdl2_bp
|
|
||||||
from .aprs import aprs_bp
|
|
||||||
from .satellite import satellite_bp
|
|
||||||
from .gps import gps_bp
|
|
||||||
from .settings import settings_bp
|
|
||||||
from .correlation import correlation_bp
|
|
||||||
from .listening_post import listening_post_bp
|
|
||||||
from .meshtastic import meshtastic_bp
|
|
||||||
from .tscm import tscm_bp, init_tscm_state
|
|
||||||
from .spy_stations import spy_stations_bp
|
|
||||||
from .controller import controller_bp
|
|
||||||
from .offline import offline_bp
|
|
||||||
from .updater import updater_bp
|
|
||||||
from .sstv import sstv_bp
|
|
||||||
from .weather_sat import weather_sat_bp
|
|
||||||
from .sstv_general import sstv_general_bp
|
|
||||||
from .dmr import dmr_bp
|
|
||||||
from .websdr import websdr_bp
|
|
||||||
from .alerts import alerts_bp
|
from .alerts import alerts_bp
|
||||||
from .recordings import recordings_bp
|
from .aprs import aprs_bp
|
||||||
from .subghz import subghz_bp
|
from .bluetooth import bluetooth_bp
|
||||||
|
from .bluetooth_v2 import bluetooth_v2_bp
|
||||||
from .bt_locate import bt_locate_bp
|
from .bt_locate import bt_locate_bp
|
||||||
from .analytics import analytics_bp
|
from .controller import controller_bp
|
||||||
|
from .correlation import correlation_bp
|
||||||
|
from .dsc import dsc_bp
|
||||||
|
from .gps import gps_bp
|
||||||
|
from .listening_post import receiver_bp
|
||||||
|
from .meshtastic import meshtastic_bp
|
||||||
|
from .offline import offline_bp
|
||||||
|
from .pager import pager_bp
|
||||||
|
from .recordings import recordings_bp
|
||||||
|
from .rtlamr import rtlamr_bp
|
||||||
|
from .satellite import satellite_bp
|
||||||
|
from .sensor import sensor_bp
|
||||||
|
from .settings import settings_bp
|
||||||
|
from .signalid import signalid_bp
|
||||||
from .space_weather import space_weather_bp
|
from .space_weather import space_weather_bp
|
||||||
|
from .spy_stations import spy_stations_bp
|
||||||
|
from .sstv import sstv_bp
|
||||||
|
from .sstv_general import sstv_general_bp
|
||||||
|
from .subghz import subghz_bp
|
||||||
|
from .tscm import init_tscm_state, tscm_bp
|
||||||
|
from .updater import updater_bp
|
||||||
|
from .vdl2 import vdl2_bp
|
||||||
|
from .weather_sat import weather_sat_bp
|
||||||
|
from .websdr import websdr_bp
|
||||||
|
from .wifi import wifi_bp
|
||||||
|
from .wifi_v2 import wifi_v2_bp
|
||||||
|
|
||||||
app.register_blueprint(pager_bp)
|
app.register_blueprint(pager_bp)
|
||||||
app.register_blueprint(sensor_bp)
|
app.register_blueprint(sensor_bp)
|
||||||
@@ -55,7 +54,7 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(gps_bp)
|
app.register_blueprint(gps_bp)
|
||||||
app.register_blueprint(settings_bp)
|
app.register_blueprint(settings_bp)
|
||||||
app.register_blueprint(correlation_bp)
|
app.register_blueprint(correlation_bp)
|
||||||
app.register_blueprint(listening_post_bp)
|
app.register_blueprint(receiver_bp)
|
||||||
app.register_blueprint(meshtastic_bp)
|
app.register_blueprint(meshtastic_bp)
|
||||||
app.register_blueprint(tscm_bp)
|
app.register_blueprint(tscm_bp)
|
||||||
app.register_blueprint(spy_stations_bp)
|
app.register_blueprint(spy_stations_bp)
|
||||||
@@ -65,14 +64,13 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(sstv_bp) # ISS SSTV decoder
|
app.register_blueprint(sstv_bp) # ISS SSTV decoder
|
||||||
app.register_blueprint(weather_sat_bp) # NOAA/Meteor weather satellite decoder
|
app.register_blueprint(weather_sat_bp) # NOAA/Meteor weather satellite decoder
|
||||||
app.register_blueprint(sstv_general_bp) # General terrestrial SSTV
|
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(websdr_bp) # HF/Shortwave WebSDR
|
||||||
app.register_blueprint(alerts_bp) # Cross-mode alerts
|
app.register_blueprint(alerts_bp) # Cross-mode alerts
|
||||||
app.register_blueprint(recordings_bp) # Session recordings
|
app.register_blueprint(recordings_bp) # Session recordings
|
||||||
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
||||||
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
||||||
app.register_blueprint(analytics_bp) # Cross-mode analytics dashboard
|
|
||||||
app.register_blueprint(space_weather_bp) # Space weather monitoring
|
app.register_blueprint(space_weather_bp) # Space weather monitoring
|
||||||
|
app.register_blueprint(signalid_bp) # External signal ID enrichment
|
||||||
|
|
||||||
# Initialize TSCM state with queue and lock from app
|
# Initialize TSCM state with queue and lock from app
|
||||||
import app as app_module
|
import app as app_module
|
||||||
|
|||||||
+80
-42
@@ -379,10 +379,62 @@ def parse_sbs_stream(service_addr):
|
|||||||
adsb_bytes_received = 0
|
adsb_bytes_received = 0
|
||||||
adsb_lines_received = 0
|
adsb_lines_received = 0
|
||||||
|
|
||||||
|
def flush_pending_updates(force: bool = False) -> None:
|
||||||
|
nonlocal last_update
|
||||||
|
if not pending_updates:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
if not force and now - last_update < ADSB_UPDATE_INTERVAL:
|
||||||
|
return
|
||||||
|
|
||||||
|
captured_at = datetime.now(timezone.utc)
|
||||||
|
for update_icao in tuple(pending_updates):
|
||||||
|
if update_icao in app_module.adsb_aircraft:
|
||||||
|
snapshot = app_module.adsb_aircraft[update_icao]
|
||||||
|
_broadcast_adsb_update({
|
||||||
|
'type': 'aircraft',
|
||||||
|
**snapshot
|
||||||
|
})
|
||||||
|
adsb_snapshot_writer.enqueue({
|
||||||
|
'captured_at': captured_at,
|
||||||
|
'icao': update_icao,
|
||||||
|
'callsign': snapshot.get('callsign'),
|
||||||
|
'registration': snapshot.get('registration'),
|
||||||
|
'type_code': snapshot.get('type_code'),
|
||||||
|
'type_desc': snapshot.get('type_desc'),
|
||||||
|
'altitude': snapshot.get('altitude'),
|
||||||
|
'speed': snapshot.get('speed'),
|
||||||
|
'heading': snapshot.get('heading'),
|
||||||
|
'vertical_rate': snapshot.get('vertical_rate'),
|
||||||
|
'lat': snapshot.get('lat'),
|
||||||
|
'lon': snapshot.get('lon'),
|
||||||
|
'squawk': snapshot.get('squawk'),
|
||||||
|
'source_host': service_addr,
|
||||||
|
'snapshot': snapshot,
|
||||||
|
})
|
||||||
|
# Geofence check
|
||||||
|
_gf_lat = snapshot.get('lat')
|
||||||
|
_gf_lon = snapshot.get('lon')
|
||||||
|
if _gf_lat is not None and _gf_lon is not None:
|
||||||
|
try:
|
||||||
|
from utils.geofence import get_geofence_manager
|
||||||
|
for _gf_evt in get_geofence_manager().check_position(
|
||||||
|
update_icao, 'aircraft', _gf_lat, _gf_lon,
|
||||||
|
{'callsign': snapshot.get('callsign'), 'altitude': snapshot.get('altitude')}
|
||||||
|
):
|
||||||
|
process_event('adsb', _gf_evt, 'geofence')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
pending_updates.clear()
|
||||||
|
last_update = now
|
||||||
|
|
||||||
while adsb_using_service:
|
while adsb_using_service:
|
||||||
try:
|
try:
|
||||||
data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore')
|
data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore')
|
||||||
if not data:
|
if not data:
|
||||||
|
flush_pending_updates(force=True)
|
||||||
logger.warning("SBS connection closed (no data)")
|
logger.warning("SBS connection closed (no data)")
|
||||||
break
|
break
|
||||||
adsb_bytes_received += len(data)
|
adsb_bytes_received += len(data)
|
||||||
@@ -501,56 +553,40 @@ def parse_sbs_stream(service_addr):
|
|||||||
'squawk': sq, 'meaning': _EMERGENCY_SQUAWKS[sq],
|
'squawk': sq, 'meaning': _EMERGENCY_SQUAWKS[sq],
|
||||||
}, 'squawk_emergency')
|
}, 'squawk_emergency')
|
||||||
|
|
||||||
|
elif msg_type == '2' and len(parts) > 15:
|
||||||
|
if parts[11]:
|
||||||
|
try:
|
||||||
|
aircraft['altitude'] = int(float(parts[11]))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if parts[12]:
|
||||||
|
try:
|
||||||
|
aircraft['speed'] = int(float(parts[12]))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if parts[13]:
|
||||||
|
try:
|
||||||
|
aircraft['heading'] = int(float(parts[13]))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if parts[14] and parts[15]:
|
||||||
|
try:
|
||||||
|
aircraft['lat'] = float(parts[14])
|
||||||
|
aircraft['lon'] = float(parts[15])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
app_module.adsb_aircraft.set(icao, aircraft)
|
app_module.adsb_aircraft.set(icao, aircraft)
|
||||||
pending_updates.add(icao)
|
pending_updates.add(icao)
|
||||||
adsb_messages_received += 1
|
adsb_messages_received += 1
|
||||||
adsb_last_message_time = time.time()
|
adsb_last_message_time = time.time()
|
||||||
|
flush_pending_updates()
|
||||||
now = time.time()
|
|
||||||
if now - last_update >= ADSB_UPDATE_INTERVAL:
|
|
||||||
for update_icao in pending_updates:
|
|
||||||
if update_icao in app_module.adsb_aircraft:
|
|
||||||
snapshot = app_module.adsb_aircraft[update_icao]
|
|
||||||
_broadcast_adsb_update({
|
|
||||||
'type': 'aircraft',
|
|
||||||
**snapshot
|
|
||||||
})
|
|
||||||
adsb_snapshot_writer.enqueue({
|
|
||||||
'captured_at': datetime.now(timezone.utc),
|
|
||||||
'icao': update_icao,
|
|
||||||
'callsign': snapshot.get('callsign'),
|
|
||||||
'registration': snapshot.get('registration'),
|
|
||||||
'type_code': snapshot.get('type_code'),
|
|
||||||
'type_desc': snapshot.get('type_desc'),
|
|
||||||
'altitude': snapshot.get('altitude'),
|
|
||||||
'speed': snapshot.get('speed'),
|
|
||||||
'heading': snapshot.get('heading'),
|
|
||||||
'vertical_rate': snapshot.get('vertical_rate'),
|
|
||||||
'lat': snapshot.get('lat'),
|
|
||||||
'lon': snapshot.get('lon'),
|
|
||||||
'squawk': snapshot.get('squawk'),
|
|
||||||
'source_host': service_addr,
|
|
||||||
'snapshot': snapshot,
|
|
||||||
})
|
|
||||||
# Geofence check
|
|
||||||
_gf_lat = snapshot.get('lat')
|
|
||||||
_gf_lon = snapshot.get('lon')
|
|
||||||
if _gf_lat is not None and _gf_lon is not None:
|
|
||||||
try:
|
|
||||||
from utils.geofence import get_geofence_manager
|
|
||||||
for _gf_evt in get_geofence_manager().check_position(
|
|
||||||
update_icao, 'aircraft', _gf_lat, _gf_lon,
|
|
||||||
{'callsign': snapshot.get('callsign'), 'altitude': snapshot.get('altitude')}
|
|
||||||
):
|
|
||||||
process_event('adsb', _gf_evt, 'geofence')
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
pending_updates.clear()
|
|
||||||
last_update = now
|
|
||||||
|
|
||||||
except socket.timeout:
|
except socket.timeout:
|
||||||
|
flush_pending_updates()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
flush_pending_updates(force=True)
|
||||||
sock.close()
|
sock.close()
|
||||||
adsb_connected = False
|
adsb_connected = False
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
@@ -944,10 +980,12 @@ def stream_adsb():
|
|||||||
@adsb_bp.route('/dashboard')
|
@adsb_bp.route('/dashboard')
|
||||||
def adsb_dashboard():
|
def adsb_dashboard():
|
||||||
"""Popout ADS-B dashboard."""
|
"""Popout ADS-B dashboard."""
|
||||||
|
embedded = request.args.get('embedded', 'false') == 'true'
|
||||||
return render_template(
|
return render_template(
|
||||||
'adsb_dashboard.html',
|
'adsb_dashboard.html',
|
||||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
adsb_auto_start=ADSB_AUTO_START,
|
adsb_auto_start=ADSB_AUTO_START,
|
||||||
|
embedded=embedded,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -540,7 +540,9 @@ def get_vessel_dsc(mmsi: str):
|
|||||||
@ais_bp.route('/dashboard')
|
@ais_bp.route('/dashboard')
|
||||||
def ais_dashboard():
|
def ais_dashboard():
|
||||||
"""Popout AIS dashboard."""
|
"""Popout AIS dashboard."""
|
||||||
|
embedded = request.args.get('embedded', 'false') == 'true'
|
||||||
return render_template(
|
return render_template(
|
||||||
'ais_dashboard.html',
|
'ais_dashboard.html',
|
||||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
|
embedded=embedded,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,528 +0,0 @@
|
|||||||
"""Analytics dashboard: cross-mode summary, activity sparklines, export, geofence CRUD."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
|
||||||
|
|
||||||
import app as app_module
|
|
||||||
from utils.analytics import (
|
|
||||||
get_activity_tracker,
|
|
||||||
get_cross_mode_summary,
|
|
||||||
get_emergency_squawks,
|
|
||||||
get_mode_health,
|
|
||||||
)
|
|
||||||
from utils.alerts import get_alert_manager
|
|
||||||
from utils.flight_correlator import get_flight_correlator
|
|
||||||
from utils.geofence import get_geofence_manager
|
|
||||||
from utils.temporal_patterns import get_pattern_detector
|
|
||||||
|
|
||||||
analytics_bp = Blueprint('analytics', __name__, url_prefix='/analytics')
|
|
||||||
|
|
||||||
|
|
||||||
# Map mode names to DataStore attribute(s)
|
|
||||||
MODE_STORES: dict[str, list[str]] = {
|
|
||||||
'adsb': ['adsb_aircraft'],
|
|
||||||
'ais': ['ais_vessels'],
|
|
||||||
'wifi': ['wifi_networks', 'wifi_clients'],
|
|
||||||
'bluetooth': ['bt_devices'],
|
|
||||||
'dsc': ['dsc_messages'],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/summary')
|
|
||||||
def analytics_summary():
|
|
||||||
"""Return cross-mode counts, health, and emergency squawks."""
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'counts': get_cross_mode_summary(),
|
|
||||||
'health': get_mode_health(),
|
|
||||||
'squawks': get_emergency_squawks(),
|
|
||||||
'flight_messages': {
|
|
||||||
'acars': get_flight_correlator().acars_count,
|
|
||||||
'vdl2': get_flight_correlator().vdl2_count,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/activity')
|
|
||||||
def analytics_activity():
|
|
||||||
"""Return sparkline arrays for each mode."""
|
|
||||||
tracker = get_activity_tracker()
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'sparklines': tracker.get_all_sparklines(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/squawks')
|
|
||||||
def analytics_squawks():
|
|
||||||
"""Return current emergency squawk codes from ADS-B."""
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'squawks': get_emergency_squawks(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/patterns')
|
|
||||||
def analytics_patterns():
|
|
||||||
"""Return detected temporal patterns."""
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'patterns': get_pattern_detector().get_all_patterns(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/target')
|
|
||||||
def analytics_target():
|
|
||||||
"""Search entities across multiple modes for a target-centric view."""
|
|
||||||
query = (request.args.get('q') or '').strip()
|
|
||||||
requested_limit = request.args.get('limit', default=120, type=int) or 120
|
|
||||||
limit = max(1, min(500, requested_limit))
|
|
||||||
|
|
||||||
if not query:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'query': '',
|
|
||||||
'results': [],
|
|
||||||
'mode_counts': {},
|
|
||||||
})
|
|
||||||
|
|
||||||
needle = query.lower()
|
|
||||||
results: list[dict[str, Any]] = []
|
|
||||||
mode_counts: dict[str, int] = {}
|
|
||||||
|
|
||||||
def push(mode: str, entity_id: str, title: str, subtitle: str, last_seen: str | None = None) -> None:
|
|
||||||
if len(results) >= limit:
|
|
||||||
return
|
|
||||||
results.append({
|
|
||||||
'mode': mode,
|
|
||||||
'id': entity_id,
|
|
||||||
'title': title,
|
|
||||||
'subtitle': subtitle,
|
|
||||||
'last_seen': last_seen,
|
|
||||||
})
|
|
||||||
mode_counts[mode] = mode_counts.get(mode, 0) + 1
|
|
||||||
|
|
||||||
# ADS-B
|
|
||||||
for icao, aircraft in app_module.adsb_aircraft.items():
|
|
||||||
if not isinstance(aircraft, dict):
|
|
||||||
continue
|
|
||||||
fields = [
|
|
||||||
icao,
|
|
||||||
aircraft.get('icao'),
|
|
||||||
aircraft.get('hex'),
|
|
||||||
aircraft.get('callsign'),
|
|
||||||
aircraft.get('registration'),
|
|
||||||
aircraft.get('flight'),
|
|
||||||
]
|
|
||||||
if not _matches_query(needle, fields):
|
|
||||||
continue
|
|
||||||
title = str(aircraft.get('callsign') or icao or 'Aircraft').strip()
|
|
||||||
subtitle = f"ICAO {aircraft.get('icao') or icao} | Alt {aircraft.get('altitude', '--')} | Speed {aircraft.get('speed', '--')}"
|
|
||||||
push('adsb', str(icao), title, subtitle, aircraft.get('lastSeen') or aircraft.get('last_seen'))
|
|
||||||
if len(results) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
# AIS
|
|
||||||
if len(results) < limit:
|
|
||||||
for mmsi, vessel in app_module.ais_vessels.items():
|
|
||||||
if not isinstance(vessel, dict):
|
|
||||||
continue
|
|
||||||
fields = [
|
|
||||||
mmsi,
|
|
||||||
vessel.get('mmsi'),
|
|
||||||
vessel.get('name'),
|
|
||||||
vessel.get('shipname'),
|
|
||||||
vessel.get('callsign'),
|
|
||||||
vessel.get('imo'),
|
|
||||||
]
|
|
||||||
if not _matches_query(needle, fields):
|
|
||||||
continue
|
|
||||||
vessel_name = vessel.get('name') or vessel.get('shipname') or mmsi or 'Vessel'
|
|
||||||
subtitle = f"MMSI {vessel.get('mmsi') or mmsi} | Type {vessel.get('ship_type') or vessel.get('type') or '--'}"
|
|
||||||
push('ais', str(mmsi), str(vessel_name), subtitle, vessel.get('lastSeen') or vessel.get('last_seen'))
|
|
||||||
if len(results) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
# WiFi networks and clients
|
|
||||||
if len(results) < limit:
|
|
||||||
for bssid, net in app_module.wifi_networks.items():
|
|
||||||
if not isinstance(net, dict):
|
|
||||||
continue
|
|
||||||
fields = [bssid, net.get('bssid'), net.get('ssid'), net.get('vendor')]
|
|
||||||
if not _matches_query(needle, fields):
|
|
||||||
continue
|
|
||||||
title = str(net.get('ssid') or net.get('bssid') or bssid or 'WiFi Network')
|
|
||||||
subtitle = f"BSSID {net.get('bssid') or bssid} | CH {net.get('channel', '--')} | RSSI {net.get('signal', '--')}"
|
|
||||||
push('wifi', str(bssid), title, subtitle, net.get('lastSeen') or net.get('last_seen'))
|
|
||||||
if len(results) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
if len(results) < limit:
|
|
||||||
for client_mac, client in app_module.wifi_clients.items():
|
|
||||||
if not isinstance(client, dict):
|
|
||||||
continue
|
|
||||||
fields = [client_mac, client.get('mac'), client.get('bssid'), client.get('ssid'), client.get('vendor')]
|
|
||||||
if not _matches_query(needle, fields):
|
|
||||||
continue
|
|
||||||
title = str(client.get('mac') or client_mac or 'WiFi Client')
|
|
||||||
subtitle = f"BSSID {client.get('bssid') or '--'} | Probe {client.get('ssid') or '--'}"
|
|
||||||
push('wifi', str(client_mac), title, subtitle, client.get('lastSeen') or client.get('last_seen'))
|
|
||||||
if len(results) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Bluetooth
|
|
||||||
if len(results) < limit:
|
|
||||||
for address, dev in app_module.bt_devices.items():
|
|
||||||
if not isinstance(dev, dict):
|
|
||||||
continue
|
|
||||||
fields = [
|
|
||||||
address,
|
|
||||||
dev.get('address'),
|
|
||||||
dev.get('mac'),
|
|
||||||
dev.get('name'),
|
|
||||||
dev.get('manufacturer'),
|
|
||||||
dev.get('vendor'),
|
|
||||||
]
|
|
||||||
if not _matches_query(needle, fields):
|
|
||||||
continue
|
|
||||||
title = str(dev.get('name') or dev.get('address') or address or 'Bluetooth Device')
|
|
||||||
subtitle = f"MAC {dev.get('address') or address} | RSSI {dev.get('rssi', '--')} | Vendor {dev.get('manufacturer') or dev.get('vendor') or '--'}"
|
|
||||||
push('bluetooth', str(address), title, subtitle, dev.get('lastSeen') or dev.get('last_seen'))
|
|
||||||
if len(results) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
# DSC recent messages
|
|
||||||
if len(results) < limit:
|
|
||||||
for msg_id, msg in app_module.dsc_messages.items():
|
|
||||||
if not isinstance(msg, dict):
|
|
||||||
continue
|
|
||||||
fields = [
|
|
||||||
msg_id,
|
|
||||||
msg.get('mmsi'),
|
|
||||||
msg.get('from_mmsi'),
|
|
||||||
msg.get('to_mmsi'),
|
|
||||||
msg.get('from_callsign'),
|
|
||||||
msg.get('to_callsign'),
|
|
||||||
msg.get('category'),
|
|
||||||
]
|
|
||||||
if not _matches_query(needle, fields):
|
|
||||||
continue
|
|
||||||
title = str(msg.get('from_mmsi') or msg.get('mmsi') or msg_id or 'DSC Message')
|
|
||||||
subtitle = f"To {msg.get('to_mmsi') or '--'} | Cat {msg.get('category') or '--'} | Freq {msg.get('frequency') or '--'}"
|
|
||||||
push('dsc', str(msg_id), title, subtitle, msg.get('timestamp') or msg.get('lastSeen') or msg.get('last_seen'))
|
|
||||||
if len(results) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'query': query,
|
|
||||||
'results': results,
|
|
||||||
'mode_counts': mode_counts,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/insights')
|
|
||||||
def analytics_insights():
|
|
||||||
"""Return actionable insight cards and top changes."""
|
|
||||||
counts = get_cross_mode_summary()
|
|
||||||
tracker = get_activity_tracker()
|
|
||||||
sparklines = tracker.get_all_sparklines()
|
|
||||||
squawks = get_emergency_squawks()
|
|
||||||
patterns = get_pattern_detector().get_all_patterns()
|
|
||||||
alerts = get_alert_manager().list_events(limit=120)
|
|
||||||
|
|
||||||
top_changes = _compute_mode_changes(sparklines)
|
|
||||||
busiest_mode, busiest_count = _get_busiest_mode(counts)
|
|
||||||
critical_1h = _count_recent_alerts(alerts, severities={'critical', 'high'}, max_age_seconds=3600)
|
|
||||||
recurring_emitters = sum(1 for p in patterns if float(p.get('confidence') or 0.0) >= 0.7)
|
|
||||||
|
|
||||||
cards = []
|
|
||||||
if top_changes:
|
|
||||||
lead = top_changes[0]
|
|
||||||
direction = 'up' if lead['delta'] >= 0 else 'down'
|
|
||||||
cards.append({
|
|
||||||
'id': 'fastest_change',
|
|
||||||
'title': 'Fastest Change',
|
|
||||||
'value': f"{lead['mode_label']} ({lead['signed_delta']})",
|
|
||||||
'label': 'last window vs prior',
|
|
||||||
'severity': 'high' if lead['delta'] > 0 else 'low',
|
|
||||||
'detail': f"Traffic is trending {direction} in {lead['mode_label']}.",
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
cards.append({
|
|
||||||
'id': 'fastest_change',
|
|
||||||
'title': 'Fastest Change',
|
|
||||||
'value': 'Insufficient data',
|
|
||||||
'label': 'wait for activity history',
|
|
||||||
'severity': 'low',
|
|
||||||
'detail': 'Sparklines need more samples to score momentum.',
|
|
||||||
})
|
|
||||||
|
|
||||||
cards.append({
|
|
||||||
'id': 'busiest_mode',
|
|
||||||
'title': 'Busiest Mode',
|
|
||||||
'value': f"{busiest_mode} ({busiest_count})",
|
|
||||||
'label': 'current observed entities',
|
|
||||||
'severity': 'medium' if busiest_count > 0 else 'low',
|
|
||||||
'detail': 'Highest live entity count across monitoring modes.',
|
|
||||||
})
|
|
||||||
cards.append({
|
|
||||||
'id': 'critical_alerts',
|
|
||||||
'title': 'Critical Alerts (1h)',
|
|
||||||
'value': str(critical_1h),
|
|
||||||
'label': 'critical/high severities',
|
|
||||||
'severity': 'critical' if critical_1h > 0 else 'low',
|
|
||||||
'detail': 'Prioritize triage if this count is non-zero.',
|
|
||||||
})
|
|
||||||
cards.append({
|
|
||||||
'id': 'emergency_squawks',
|
|
||||||
'title': 'Emergency Squawks',
|
|
||||||
'value': str(len(squawks)),
|
|
||||||
'label': 'active ADS-B emergency codes',
|
|
||||||
'severity': 'critical' if squawks else 'low',
|
|
||||||
'detail': 'Immediate aviation anomalies currently visible.',
|
|
||||||
})
|
|
||||||
cards.append({
|
|
||||||
'id': 'recurring_emitters',
|
|
||||||
'title': 'Recurring Emitters',
|
|
||||||
'value': str(recurring_emitters),
|
|
||||||
'label': 'pattern confidence >= 0.70',
|
|
||||||
'severity': 'medium' if recurring_emitters > 0 else 'low',
|
|
||||||
'detail': 'Potentially stationary or periodic emitters detected.',
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'generated_at': datetime.now(timezone.utc).isoformat(),
|
|
||||||
'cards': cards,
|
|
||||||
'top_changes': top_changes[:5],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _compute_mode_changes(sparklines: dict[str, list[int]]) -> list[dict]:
|
|
||||||
mode_labels = {
|
|
||||||
'adsb': 'ADS-B',
|
|
||||||
'ais': 'AIS',
|
|
||||||
'wifi': 'WiFi',
|
|
||||||
'bluetooth': 'Bluetooth',
|
|
||||||
'dsc': 'DSC',
|
|
||||||
'acars': 'ACARS',
|
|
||||||
'vdl2': 'VDL2',
|
|
||||||
'aprs': 'APRS',
|
|
||||||
'meshtastic': 'Meshtastic',
|
|
||||||
}
|
|
||||||
rows = []
|
|
||||||
for mode, samples in (sparklines or {}).items():
|
|
||||||
if not isinstance(samples, list) or len(samples) < 4:
|
|
||||||
continue
|
|
||||||
|
|
||||||
window = max(2, min(12, len(samples) // 2))
|
|
||||||
recent = samples[-window:]
|
|
||||||
previous = samples[-(window * 2):-window]
|
|
||||||
if not previous:
|
|
||||||
continue
|
|
||||||
|
|
||||||
recent_avg = sum(recent) / len(recent)
|
|
||||||
prev_avg = sum(previous) / len(previous)
|
|
||||||
delta = round(recent_avg - prev_avg, 1)
|
|
||||||
rows.append({
|
|
||||||
'mode': mode,
|
|
||||||
'mode_label': mode_labels.get(mode, mode.upper()),
|
|
||||||
'delta': delta,
|
|
||||||
'signed_delta': ('+' if delta >= 0 else '') + str(delta),
|
|
||||||
'recent_avg': round(recent_avg, 1),
|
|
||||||
'previous_avg': round(prev_avg, 1),
|
|
||||||
'direction': 'up' if delta > 0 else ('down' if delta < 0 else 'flat'),
|
|
||||||
})
|
|
||||||
|
|
||||||
rows.sort(key=lambda r: abs(r['delta']), reverse=True)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def _matches_query(needle: str, values: list[Any]) -> bool:
|
|
||||||
for value in values:
|
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
if needle in str(value).lower():
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _count_recent_alerts(alerts: list[dict], severities: set[str], max_age_seconds: int) -> int:
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
count = 0
|
|
||||||
for event in alerts:
|
|
||||||
sev = str(event.get('severity') or '').lower()
|
|
||||||
if sev not in severities:
|
|
||||||
continue
|
|
||||||
created_raw = event.get('created_at')
|
|
||||||
if not created_raw:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
created = datetime.fromisoformat(str(created_raw).replace('Z', '+00:00'))
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if created.tzinfo is None:
|
|
||||||
created = created.replace(tzinfo=timezone.utc)
|
|
||||||
age = (now - created).total_seconds()
|
|
||||||
if 0 <= age <= max_age_seconds:
|
|
||||||
count += 1
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
|
||||||
def _get_busiest_mode(counts: dict[str, int]) -> tuple[str, int]:
|
|
||||||
mode_labels = {
|
|
||||||
'adsb': 'ADS-B',
|
|
||||||
'ais': 'AIS',
|
|
||||||
'wifi': 'WiFi',
|
|
||||||
'bluetooth': 'Bluetooth',
|
|
||||||
'dsc': 'DSC',
|
|
||||||
'acars': 'ACARS',
|
|
||||||
'vdl2': 'VDL2',
|
|
||||||
'aprs': 'APRS',
|
|
||||||
'meshtastic': 'Meshtastic',
|
|
||||||
}
|
|
||||||
filtered = {k: int(v or 0) for k, v in (counts or {}).items() if k in mode_labels}
|
|
||||||
if not filtered:
|
|
||||||
return ('None', 0)
|
|
||||||
mode = max(filtered, key=filtered.get)
|
|
||||||
return (mode_labels.get(mode, mode.upper()), filtered[mode])
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/export/<mode>')
|
|
||||||
def analytics_export(mode: str):
|
|
||||||
"""Export current DataStore contents as JSON or CSV."""
|
|
||||||
fmt = request.args.get('format', 'json').lower()
|
|
||||||
|
|
||||||
if mode == 'sensor':
|
|
||||||
# Sensor doesn't use DataStore; return recent queue-based data
|
|
||||||
return jsonify({'status': 'success', 'data': [], 'message': 'Sensor data is stream-only'})
|
|
||||||
|
|
||||||
store_names = MODE_STORES.get(mode)
|
|
||||||
if not store_names:
|
|
||||||
return jsonify({'status': 'error', 'message': f'Unknown mode: {mode}'}), 400
|
|
||||||
|
|
||||||
all_items: list[dict] = []
|
|
||||||
|
|
||||||
# Try v2 scanners first for wifi/bluetooth
|
|
||||||
if mode == 'wifi':
|
|
||||||
try:
|
|
||||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
|
||||||
if wifi_scanner is not None:
|
|
||||||
for ap in wifi_scanner.access_points:
|
|
||||||
all_items.append(ap.to_dict())
|
|
||||||
for client in wifi_scanner.clients:
|
|
||||||
item = client.to_dict()
|
|
||||||
item['_store'] = 'wifi_clients'
|
|
||||||
all_items.append(item)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
elif mode == 'bluetooth':
|
|
||||||
try:
|
|
||||||
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
|
||||||
if bt_scanner is not None:
|
|
||||||
for dev in bt_scanner.get_devices():
|
|
||||||
all_items.append(dev.to_dict())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Fall back to legacy DataStores if v2 scanners yielded nothing
|
|
||||||
if not all_items:
|
|
||||||
for store_name in store_names:
|
|
||||||
store = getattr(app_module, store_name, None)
|
|
||||||
if store is None:
|
|
||||||
continue
|
|
||||||
for key, value in store.items():
|
|
||||||
item = dict(value) if isinstance(value, dict) else {'id': key, 'value': value}
|
|
||||||
item.setdefault('_store', store_name)
|
|
||||||
all_items.append(item)
|
|
||||||
|
|
||||||
if fmt == 'csv':
|
|
||||||
if not all_items:
|
|
||||||
output = ''
|
|
||||||
else:
|
|
||||||
# Collect all keys across items
|
|
||||||
fieldnames: list[str] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for item in all_items:
|
|
||||||
for k in item:
|
|
||||||
if k not in seen:
|
|
||||||
fieldnames.append(k)
|
|
||||||
seen.add(k)
|
|
||||||
|
|
||||||
buf = io.StringIO()
|
|
||||||
writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction='ignore')
|
|
||||||
writer.writeheader()
|
|
||||||
for item in all_items:
|
|
||||||
# Serialize non-scalar values
|
|
||||||
row = {}
|
|
||||||
for k in fieldnames:
|
|
||||||
v = item.get(k)
|
|
||||||
if isinstance(v, (dict, list)):
|
|
||||||
row[k] = json.dumps(v)
|
|
||||||
else:
|
|
||||||
row[k] = v
|
|
||||||
writer.writerow(row)
|
|
||||||
output = buf.getvalue()
|
|
||||||
|
|
||||||
response = Response(output, mimetype='text/csv')
|
|
||||||
response.headers['Content-Disposition'] = f'attachment; filename={mode}_export.csv'
|
|
||||||
return response
|
|
||||||
|
|
||||||
# Default: JSON
|
|
||||||
return jsonify({'status': 'success', 'mode': mode, 'count': len(all_items), 'data': all_items})
|
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
|
||||||
# Geofence CRUD
|
|
||||||
# =========================================================================
|
|
||||||
|
|
||||||
@analytics_bp.route('/geofences')
|
|
||||||
def list_geofences():
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'zones': get_geofence_manager().list_zones(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/geofences', methods=['POST'])
|
|
||||||
def create_geofence():
|
|
||||||
data = request.get_json() or {}
|
|
||||||
name = data.get('name')
|
|
||||||
lat = data.get('lat')
|
|
||||||
lon = data.get('lon')
|
|
||||||
radius_m = data.get('radius_m')
|
|
||||||
|
|
||||||
if not all([name, lat is not None, lon is not None, radius_m is not None]):
|
|
||||||
return jsonify({'status': 'error', 'message': 'name, lat, lon, radius_m are required'}), 400
|
|
||||||
|
|
||||||
try:
|
|
||||||
lat = float(lat)
|
|
||||||
lon = float(lon)
|
|
||||||
radius_m = float(radius_m)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return jsonify({'status': 'error', 'message': 'lat, lon, radius_m must be numbers'}), 400
|
|
||||||
|
|
||||||
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
|
|
||||||
if radius_m <= 0:
|
|
||||||
return jsonify({'status': 'error', 'message': 'radius_m must be positive'}), 400
|
|
||||||
|
|
||||||
alert_on = data.get('alert_on', 'enter_exit')
|
|
||||||
zone_id = get_geofence_manager().add_zone(name, lat, lon, radius_m, alert_on)
|
|
||||||
return jsonify({'status': 'success', 'zone_id': zone_id})
|
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/geofences/<int:zone_id>', methods=['DELETE'])
|
|
||||||
def delete_geofence(zone_id: int):
|
|
||||||
ok = get_geofence_manager().delete_zone(zone_id)
|
|
||||||
if not ok:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Zone not found'}), 404
|
|
||||||
return jsonify({'status': 'success'})
|
|
||||||
+18
-4
@@ -109,9 +109,22 @@ def start_session():
|
|||||||
f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})"
|
f"env={environment.name}, fallback=({fallback_lat}, {fallback_lon})"
|
||||||
)
|
)
|
||||||
|
|
||||||
session = start_locate_session(
|
try:
|
||||||
target, environment, custom_exponent, fallback_lat, fallback_lon
|
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({
|
return jsonify({
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
@@ -140,7 +153,8 @@ def get_status():
|
|||||||
'target': None,
|
'target': None,
|
||||||
})
|
})
|
||||||
|
|
||||||
return jsonify(session.get_status())
|
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'])
|
@bt_locate_bp.route('/trail', methods=['GET'])
|
||||||
|
|||||||
-753
@@ -1,753 +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 sse_stream_fanout
|
|
||||||
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 _on_msg(msg: dict[str, Any]) -> None:
|
|
||||||
process_event('dmr', msg, msg.get('type'))
|
|
||||||
|
|
||||||
response = Response(
|
|
||||||
sse_stream_fanout(
|
|
||||||
source_queue=dmr_queue,
|
|
||||||
channel_key='dmr',
|
|
||||||
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
|
|
||||||
+284
-78
@@ -1,4 +1,4 @@
|
|||||||
"""Listening Post routes for radio monitoring and frequency scanning."""
|
"""Receiver routes for radio monitoring and frequency scanning."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -9,11 +9,12 @@ import queue
|
|||||||
import select
|
import select
|
||||||
import signal
|
import signal
|
||||||
import shutil
|
import shutil
|
||||||
|
import struct
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Generator, Optional, List, Dict
|
from typing import Any, Dict, Generator, List, Optional
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
@@ -28,9 +29,9 @@ from utils.constants import (
|
|||||||
)
|
)
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
|
||||||
logger = get_logger('intercept.listening_post')
|
logger = get_logger('intercept.receiver')
|
||||||
|
|
||||||
listening_post_bp = Blueprint('listening_post', __name__, url_prefix='/listening')
|
receiver_bp = Blueprint('receiver', __name__, url_prefix='/receiver')
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# GLOBAL STATE
|
# GLOBAL STATE
|
||||||
@@ -43,6 +44,7 @@ audio_lock = threading.Lock()
|
|||||||
audio_running = False
|
audio_running = False
|
||||||
audio_frequency = 0.0
|
audio_frequency = 0.0
|
||||||
audio_modulation = 'fm'
|
audio_modulation = 'fm'
|
||||||
|
audio_source = 'process'
|
||||||
|
|
||||||
# Scanner state
|
# Scanner state
|
||||||
scanner_thread: Optional[threading.Thread] = None
|
scanner_thread: Optional[threading.Thread] = None
|
||||||
@@ -51,7 +53,7 @@ scanner_lock = threading.Lock()
|
|||||||
scanner_paused = False
|
scanner_paused = False
|
||||||
scanner_current_freq = 0.0
|
scanner_current_freq = 0.0
|
||||||
scanner_active_device: Optional[int] = None
|
scanner_active_device: Optional[int] = None
|
||||||
listening_active_device: Optional[int] = None
|
receiver_active_device: Optional[int] = None
|
||||||
scanner_power_process: Optional[subprocess.Popen] = None
|
scanner_power_process: Optional[subprocess.Popen] = None
|
||||||
scanner_config = {
|
scanner_config = {
|
||||||
'start_freq': 88.0,
|
'start_freq': 88.0,
|
||||||
@@ -119,6 +121,22 @@ def _rtl_fm_demod_mode(modulation: str) -> str:
|
|||||||
return 'wbfm' if mod == 'wfm' else mod
|
return 'wbfm' if mod == 'wfm' else mod
|
||||||
|
|
||||||
|
|
||||||
|
def _wav_header(sample_rate: int = 48000, bits_per_sample: int = 16, channels: int = 1) -> bytes:
|
||||||
|
"""Create a streaming WAV header with unknown data length."""
|
||||||
|
bytes_per_sample = bits_per_sample // 8
|
||||||
|
byte_rate = sample_rate * channels * bytes_per_sample
|
||||||
|
block_align = channels * bytes_per_sample
|
||||||
|
return (
|
||||||
|
b'RIFF'
|
||||||
|
+ struct.pack('<I', 0xFFFFFFFF)
|
||||||
|
+ b'WAVE'
|
||||||
|
+ b'fmt '
|
||||||
|
+ struct.pack('<IHHIIHH', 16, 1, channels, sample_rate, byte_rate, block_align, bits_per_sample)
|
||||||
|
+ b'data'
|
||||||
|
+ struct.pack('<I', 0xFFFFFFFF)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def add_activity_log(event_type: str, frequency: float, details: str = ''):
|
def add_activity_log(event_type: str, frequency: float, details: str = ''):
|
||||||
@@ -697,8 +715,8 @@ def _start_audio_stream(frequency: float, modulation: str):
|
|||||||
]
|
]
|
||||||
if scanner_config.get('bias_t', False):
|
if scanner_config.get('bias_t', False):
|
||||||
sdr_cmd.append('-T')
|
sdr_cmd.append('-T')
|
||||||
# Explicitly output to stdout (some rtl_fm versions need this)
|
# Omit explicit filename: rtl_fm defaults to stdout.
|
||||||
sdr_cmd.append('-')
|
# (Some builds intermittently stall when '-' is passed explicitly.)
|
||||||
else:
|
else:
|
||||||
# Use SDR abstraction layer for HackRF, Airspy, LimeSDR, SDRPlay
|
# Use SDR abstraction layer for HackRF, Airspy, LimeSDR, SDRPlay
|
||||||
rx_fm_path = find_rx_fm()
|
rx_fm_path = find_rx_fm()
|
||||||
@@ -842,15 +860,15 @@ def _start_audio_stream(frequency: float, modulation: str):
|
|||||||
# Pipeline started successfully
|
# Pipeline started successfully
|
||||||
break
|
break
|
||||||
|
|
||||||
# Validate that audio is producing data quickly
|
# Keep monitor startup tolerant: some demod chains can take
|
||||||
try:
|
# several seconds before producing stream bytes.
|
||||||
ready, _, _ = select.select([audio_process.stdout], [], [], 4.0)
|
if (
|
||||||
if not ready:
|
not audio_process
|
||||||
logger.warning("Audio pipeline produced no data in startup window — killing stalled pipeline")
|
or not audio_rtl_process
|
||||||
_stop_audio_stream_internal()
|
or audio_process.poll() is not None
|
||||||
return
|
or audio_rtl_process.poll() is not None
|
||||||
except Exception as e:
|
):
|
||||||
logger.warning(f"Audio startup check failed: {e}")
|
logger.warning("Audio pipeline did not remain alive after startup")
|
||||||
_stop_audio_stream_internal()
|
_stop_audio_stream_internal()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -871,11 +889,21 @@ def _stop_audio_stream():
|
|||||||
|
|
||||||
def _stop_audio_stream_internal():
|
def _stop_audio_stream_internal():
|
||||||
"""Internal stop (must hold lock)."""
|
"""Internal stop (must hold lock)."""
|
||||||
global audio_process, audio_rtl_process, audio_running, audio_frequency
|
global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_source
|
||||||
|
|
||||||
# Set flag first to stop any streaming
|
# Set flag first to stop any streaming
|
||||||
audio_running = False
|
audio_running = False
|
||||||
audio_frequency = 0.0
|
audio_frequency = 0.0
|
||||||
|
previous_source = audio_source
|
||||||
|
audio_source = 'process'
|
||||||
|
|
||||||
|
if previous_source == 'waterfall':
|
||||||
|
try:
|
||||||
|
from routes.waterfall_websocket import stop_shared_monitor_from_capture
|
||||||
|
|
||||||
|
stop_shared_monitor_from_capture()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
had_processes = audio_process is not None or audio_rtl_process is not None
|
had_processes = audio_process is not None or audio_rtl_process is not None
|
||||||
|
|
||||||
@@ -913,7 +941,7 @@ def _stop_audio_stream_internal():
|
|||||||
# API ENDPOINTS
|
# API ENDPOINTS
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
@listening_post_bp.route('/tools')
|
@receiver_bp.route('/tools')
|
||||||
def check_tools() -> Response:
|
def check_tools() -> Response:
|
||||||
"""Check for required tools."""
|
"""Check for required tools."""
|
||||||
rtl_fm = find_rtl_fm()
|
rtl_fm = find_rtl_fm()
|
||||||
@@ -939,10 +967,10 @@ def check_tools() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/start', methods=['POST'])
|
@receiver_bp.route('/scanner/start', methods=['POST'])
|
||||||
def start_scanner() -> Response:
|
def start_scanner() -> Response:
|
||||||
"""Start the frequency scanner."""
|
"""Start the frequency scanner."""
|
||||||
global scanner_thread, scanner_running, scanner_config, scanner_active_device, listening_active_device
|
global scanner_thread, scanner_running, scanner_config, scanner_active_device, receiver_active_device
|
||||||
|
|
||||||
with scanner_lock:
|
with scanner_lock:
|
||||||
if scanner_running:
|
if scanner_running:
|
||||||
@@ -1008,9 +1036,9 @@ def start_scanner() -> Response:
|
|||||||
'message': 'rtl_power not found. Install rtl-sdr tools.'
|
'message': 'rtl_power not found. Install rtl-sdr tools.'
|
||||||
}), 503
|
}), 503
|
||||||
# Release listening device if active
|
# Release listening device if active
|
||||||
if listening_active_device is not None:
|
if receiver_active_device is not None:
|
||||||
app_module.release_sdr_device(listening_active_device)
|
app_module.release_sdr_device(receiver_active_device)
|
||||||
listening_active_device = None
|
receiver_active_device = None
|
||||||
# Claim device for scanner
|
# Claim device for scanner
|
||||||
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
|
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
|
||||||
if error:
|
if error:
|
||||||
@@ -1036,9 +1064,9 @@ def start_scanner() -> Response:
|
|||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
|
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
|
||||||
}), 503
|
}), 503
|
||||||
if listening_active_device is not None:
|
if receiver_active_device is not None:
|
||||||
app_module.release_sdr_device(listening_active_device)
|
app_module.release_sdr_device(receiver_active_device)
|
||||||
listening_active_device = None
|
receiver_active_device = None
|
||||||
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
|
error = app_module.claim_sdr_device(scanner_config['device'], 'scanner')
|
||||||
if error:
|
if error:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -1058,7 +1086,7 @@ def start_scanner() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/stop', methods=['POST'])
|
@receiver_bp.route('/scanner/stop', methods=['POST'])
|
||||||
def stop_scanner() -> Response:
|
def stop_scanner() -> Response:
|
||||||
"""Stop the frequency scanner."""
|
"""Stop the frequency scanner."""
|
||||||
global scanner_running, scanner_active_device, scanner_power_process
|
global scanner_running, scanner_active_device, scanner_power_process
|
||||||
@@ -1082,7 +1110,7 @@ def stop_scanner() -> Response:
|
|||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/pause', methods=['POST'])
|
@receiver_bp.route('/scanner/pause', methods=['POST'])
|
||||||
def pause_scanner() -> Response:
|
def pause_scanner() -> Response:
|
||||||
"""Pause/resume the scanner."""
|
"""Pause/resume the scanner."""
|
||||||
global scanner_paused
|
global scanner_paused
|
||||||
@@ -1104,7 +1132,7 @@ def pause_scanner() -> Response:
|
|||||||
scanner_skip_signal = False
|
scanner_skip_signal = False
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/skip', methods=['POST'])
|
@receiver_bp.route('/scanner/skip', methods=['POST'])
|
||||||
def skip_signal() -> Response:
|
def skip_signal() -> Response:
|
||||||
"""Skip current signal and continue scanning."""
|
"""Skip current signal and continue scanning."""
|
||||||
global scanner_skip_signal
|
global scanner_skip_signal
|
||||||
@@ -1124,7 +1152,7 @@ def skip_signal() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/config', methods=['POST'])
|
@receiver_bp.route('/scanner/config', methods=['POST'])
|
||||||
def update_scanner_config() -> Response:
|
def update_scanner_config() -> Response:
|
||||||
"""Update scanner config while running (step, squelch, gain, dwell)."""
|
"""Update scanner config while running (step, squelch, gain, dwell)."""
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
@@ -1166,7 +1194,7 @@ def update_scanner_config() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/status')
|
@receiver_bp.route('/scanner/status')
|
||||||
def scanner_status() -> Response:
|
def scanner_status() -> Response:
|
||||||
"""Get scanner status."""
|
"""Get scanner status."""
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -1179,16 +1207,16 @@ def scanner_status() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/stream')
|
@receiver_bp.route('/scanner/stream')
|
||||||
def stream_scanner_events() -> Response:
|
def stream_scanner_events() -> Response:
|
||||||
"""SSE stream for scanner events."""
|
"""SSE stream for scanner events."""
|
||||||
def _on_msg(msg: dict[str, Any]) -> None:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
process_event('listening_scanner', msg, msg.get('type'))
|
process_event('receiver_scanner', msg, msg.get('type'))
|
||||||
|
|
||||||
response = Response(
|
response = Response(
|
||||||
sse_stream_fanout(
|
sse_stream_fanout(
|
||||||
source_queue=scanner_queue,
|
source_queue=scanner_queue,
|
||||||
channel_key='listening_scanner',
|
channel_key='receiver_scanner',
|
||||||
timeout=SSE_QUEUE_TIMEOUT,
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
on_message=_on_msg,
|
on_message=_on_msg,
|
||||||
@@ -1200,7 +1228,7 @@ def stream_scanner_events() -> Response:
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/log')
|
@receiver_bp.route('/scanner/log')
|
||||||
def get_activity_log() -> Response:
|
def get_activity_log() -> Response:
|
||||||
"""Get activity log."""
|
"""Get activity log."""
|
||||||
limit = request.args.get('limit', 100, type=int)
|
limit = request.args.get('limit', 100, type=int)
|
||||||
@@ -1211,7 +1239,7 @@ def get_activity_log() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/log/clear', methods=['POST'])
|
@receiver_bp.route('/scanner/log/clear', methods=['POST'])
|
||||||
def clear_activity_log() -> Response:
|
def clear_activity_log() -> Response:
|
||||||
"""Clear activity log."""
|
"""Clear activity log."""
|
||||||
with activity_log_lock:
|
with activity_log_lock:
|
||||||
@@ -1219,7 +1247,7 @@ def clear_activity_log() -> Response:
|
|||||||
return jsonify({'status': 'cleared'})
|
return jsonify({'status': 'cleared'})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/presets')
|
@receiver_bp.route('/presets')
|
||||||
def get_presets() -> Response:
|
def get_presets() -> Response:
|
||||||
"""Get scanner presets."""
|
"""Get scanner presets."""
|
||||||
presets = [
|
presets = [
|
||||||
@@ -1239,10 +1267,11 @@ def get_presets() -> Response:
|
|||||||
# MANUAL AUDIO ENDPOINTS (for direct listening)
|
# MANUAL AUDIO ENDPOINTS (for direct listening)
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
@listening_post_bp.route('/audio/start', methods=['POST'])
|
@receiver_bp.route('/audio/start', methods=['POST'])
|
||||||
def start_audio() -> Response:
|
def start_audio() -> Response:
|
||||||
"""Start audio at specific frequency (manual mode)."""
|
"""Start audio at specific frequency (manual mode)."""
|
||||||
global scanner_running, scanner_active_device, listening_active_device, scanner_power_process, scanner_thread
|
global scanner_running, scanner_active_device, receiver_active_device, scanner_power_process, scanner_thread
|
||||||
|
global audio_running, audio_frequency, audio_modulation, audio_source
|
||||||
|
|
||||||
# Stop scanner if running
|
# Stop scanner if running
|
||||||
if scanner_running:
|
if scanner_running:
|
||||||
@@ -1280,6 +1309,11 @@ def start_audio() -> Response:
|
|||||||
gain = int(data.get('gain', 40))
|
gain = int(data.get('gain', 40))
|
||||||
device = int(data.get('device', 0))
|
device = int(data.get('device', 0))
|
||||||
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
|
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||||
|
bias_t_raw = data.get('bias_t', scanner_config.get('bias_t', False))
|
||||||
|
if isinstance(bias_t_raw, str):
|
||||||
|
bias_t = bias_t_raw.strip().lower() in {'1', 'true', 'yes', 'on'}
|
||||||
|
else:
|
||||||
|
bias_t = bool(bias_t_raw)
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -1304,6 +1338,43 @@ def start_audio() -> Response:
|
|||||||
scanner_config['gain'] = gain
|
scanner_config['gain'] = gain
|
||||||
scanner_config['device'] = device
|
scanner_config['device'] = device
|
||||||
scanner_config['sdr_type'] = sdr_type
|
scanner_config['sdr_type'] = sdr_type
|
||||||
|
scanner_config['bias_t'] = bias_t
|
||||||
|
|
||||||
|
# Preferred path: when waterfall WebSocket is active on the same SDR,
|
||||||
|
# derive monitor audio from that IQ stream instead of spawning rtl_fm.
|
||||||
|
try:
|
||||||
|
from routes.waterfall_websocket import (
|
||||||
|
get_shared_capture_status,
|
||||||
|
start_shared_monitor_from_capture,
|
||||||
|
)
|
||||||
|
|
||||||
|
shared = get_shared_capture_status()
|
||||||
|
if shared.get('running') and shared.get('device') == device:
|
||||||
|
_stop_audio_stream()
|
||||||
|
ok, msg = start_shared_monitor_from_capture(
|
||||||
|
device=device,
|
||||||
|
frequency_mhz=frequency,
|
||||||
|
modulation=modulation,
|
||||||
|
squelch=squelch,
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
audio_running = True
|
||||||
|
audio_frequency = frequency
|
||||||
|
audio_modulation = modulation
|
||||||
|
audio_source = 'waterfall'
|
||||||
|
# Shared monitor uses the waterfall's existing SDR claim.
|
||||||
|
if receiver_active_device is not None:
|
||||||
|
app_module.release_sdr_device(receiver_active_device)
|
||||||
|
receiver_active_device = None
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'frequency': frequency,
|
||||||
|
'modulation': modulation,
|
||||||
|
'source': 'waterfall',
|
||||||
|
})
|
||||||
|
logger.warning(f"Shared waterfall monitor unavailable: {msg}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Shared waterfall monitor probe failed: {e}")
|
||||||
|
|
||||||
# Stop waterfall if it's using the same SDR (SSE path)
|
# Stop waterfall if it's using the same SDR (SSE path)
|
||||||
if waterfall_running and waterfall_active_device == device:
|
if waterfall_running and waterfall_active_device == device:
|
||||||
@@ -1314,22 +1385,15 @@ def start_audio() -> Response:
|
|||||||
# may still be tearing down its IQ capture process (thread join +
|
# may still be tearing down its IQ capture process (thread join +
|
||||||
# safe_terminate can take several seconds), so we retry with back-off
|
# safe_terminate can take several seconds), so we retry with back-off
|
||||||
# to give the USB device time to be fully released.
|
# to give the USB device time to be fully released.
|
||||||
if listening_active_device is None or listening_active_device != device:
|
if receiver_active_device is None or receiver_active_device != device:
|
||||||
if listening_active_device is not None:
|
if receiver_active_device is not None:
|
||||||
app_module.release_sdr_device(listening_active_device)
|
app_module.release_sdr_device(receiver_active_device)
|
||||||
listening_active_device = None
|
receiver_active_device = None
|
||||||
|
|
||||||
error = None
|
error = None
|
||||||
max_claim_attempts = 6
|
max_claim_attempts = 6
|
||||||
for attempt in range(max_claim_attempts):
|
for attempt in range(max_claim_attempts):
|
||||||
# Force-release a stale waterfall registry entry on each
|
error = app_module.claim_sdr_device(device, 'receiver')
|
||||||
# attempt — the WebSocket handler may not have finished
|
|
||||||
# cleanup yet.
|
|
||||||
device_status = app_module.get_sdr_device_status()
|
|
||||||
if device_status.get(device) == 'waterfall':
|
|
||||||
app_module.release_sdr_device(device)
|
|
||||||
|
|
||||||
error = app_module.claim_sdr_device(device, 'listening')
|
|
||||||
if not error:
|
if not error:
|
||||||
break
|
break
|
||||||
if attempt < max_claim_attempts - 1:
|
if attempt < max_claim_attempts - 1:
|
||||||
@@ -1345,45 +1409,77 @@ def start_audio() -> Response:
|
|||||||
'error_type': 'DEVICE_BUSY',
|
'error_type': 'DEVICE_BUSY',
|
||||||
'message': error
|
'message': error
|
||||||
}), 409
|
}), 409
|
||||||
listening_active_device = device
|
receiver_active_device = device
|
||||||
|
|
||||||
_start_audio_stream(frequency, modulation)
|
_start_audio_stream(frequency, modulation)
|
||||||
|
|
||||||
if audio_running:
|
if audio_running:
|
||||||
|
audio_source = 'process'
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
'frequency': frequency,
|
'frequency': frequency,
|
||||||
'modulation': modulation
|
'modulation': modulation,
|
||||||
|
'source': 'process',
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
|
# Avoid leaving a stale device claim after startup failure.
|
||||||
|
if receiver_active_device is not None:
|
||||||
|
app_module.release_sdr_device(receiver_active_device)
|
||||||
|
receiver_active_device = None
|
||||||
|
|
||||||
|
start_error = ''
|
||||||
|
for log_path in ('/tmp/rtl_fm_stderr.log', '/tmp/ffmpeg_stderr.log'):
|
||||||
|
try:
|
||||||
|
with open(log_path, 'r') as handle:
|
||||||
|
content = handle.read().strip()
|
||||||
|
if content:
|
||||||
|
start_error = content.splitlines()[-1]
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
message = 'Failed to start audio. Check SDR device.'
|
||||||
|
if start_error:
|
||||||
|
message = f'Failed to start audio: {start_error}'
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Failed to start audio. Check SDR device.'
|
'message': message
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/audio/stop', methods=['POST'])
|
@receiver_bp.route('/audio/stop', methods=['POST'])
|
||||||
def stop_audio() -> Response:
|
def stop_audio() -> Response:
|
||||||
"""Stop audio."""
|
"""Stop audio."""
|
||||||
global listening_active_device
|
global receiver_active_device
|
||||||
_stop_audio_stream()
|
_stop_audio_stream()
|
||||||
if listening_active_device is not None:
|
if receiver_active_device is not None:
|
||||||
app_module.release_sdr_device(listening_active_device)
|
app_module.release_sdr_device(receiver_active_device)
|
||||||
listening_active_device = None
|
receiver_active_device = None
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/audio/status')
|
@receiver_bp.route('/audio/status')
|
||||||
def audio_status() -> Response:
|
def audio_status() -> Response:
|
||||||
"""Get audio status."""
|
"""Get audio status."""
|
||||||
|
running = audio_running
|
||||||
|
if audio_source == 'waterfall':
|
||||||
|
try:
|
||||||
|
from routes.waterfall_websocket import get_shared_capture_status
|
||||||
|
|
||||||
|
shared = get_shared_capture_status()
|
||||||
|
running = bool(shared.get('running') and shared.get('monitor_enabled'))
|
||||||
|
except Exception:
|
||||||
|
running = False
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'running': audio_running,
|
'running': running,
|
||||||
'frequency': audio_frequency,
|
'frequency': audio_frequency,
|
||||||
'modulation': audio_modulation
|
'modulation': audio_modulation,
|
||||||
|
'source': audio_source,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/audio/debug')
|
@receiver_bp.route('/audio/debug')
|
||||||
def audio_debug() -> Response:
|
def audio_debug() -> Response:
|
||||||
"""Get audio debug status and recent stderr logs."""
|
"""Get audio debug status and recent stderr logs."""
|
||||||
rtl_log_path = '/tmp/rtl_fm_stderr.log'
|
rtl_log_path = '/tmp/rtl_fm_stderr.log'
|
||||||
@@ -1397,26 +1493,51 @@ def audio_debug() -> Response:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
shared = {}
|
||||||
|
if audio_source == 'waterfall':
|
||||||
|
try:
|
||||||
|
from routes.waterfall_websocket import get_shared_capture_status
|
||||||
|
|
||||||
|
shared = get_shared_capture_status()
|
||||||
|
except Exception:
|
||||||
|
shared = {}
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'running': audio_running,
|
'running': audio_running,
|
||||||
'frequency': audio_frequency,
|
'frequency': audio_frequency,
|
||||||
'modulation': audio_modulation,
|
'modulation': audio_modulation,
|
||||||
|
'source': audio_source,
|
||||||
'sdr_type': scanner_config.get('sdr_type', 'rtlsdr'),
|
'sdr_type': scanner_config.get('sdr_type', 'rtlsdr'),
|
||||||
'device': scanner_config.get('device', 0),
|
'device': scanner_config.get('device', 0),
|
||||||
'gain': scanner_config.get('gain', 0),
|
'gain': scanner_config.get('gain', 0),
|
||||||
'squelch': scanner_config.get('squelch', 0),
|
'squelch': scanner_config.get('squelch', 0),
|
||||||
'audio_process_alive': bool(audio_process and audio_process.poll() is None),
|
'audio_process_alive': bool(audio_process and audio_process.poll() is None),
|
||||||
|
'shared_capture': shared,
|
||||||
'rtl_fm_stderr': _read_log(rtl_log_path),
|
'rtl_fm_stderr': _read_log(rtl_log_path),
|
||||||
'ffmpeg_stderr': _read_log(ffmpeg_log_path),
|
'ffmpeg_stderr': _read_log(ffmpeg_log_path),
|
||||||
'audio_probe_bytes': os.path.getsize(sample_path) if os.path.exists(sample_path) else 0,
|
'audio_probe_bytes': os.path.getsize(sample_path) if os.path.exists(sample_path) else 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/audio/probe')
|
@receiver_bp.route('/audio/probe')
|
||||||
def audio_probe() -> Response:
|
def audio_probe() -> Response:
|
||||||
"""Grab a small chunk of audio bytes from the pipeline for debugging."""
|
"""Grab a small chunk of audio bytes from the pipeline for debugging."""
|
||||||
global audio_process
|
global audio_process
|
||||||
|
|
||||||
|
if audio_source == 'waterfall':
|
||||||
|
try:
|
||||||
|
from routes.waterfall_websocket import read_shared_monitor_audio_chunk
|
||||||
|
|
||||||
|
data = read_shared_monitor_audio_chunk(timeout=2.0)
|
||||||
|
if not data:
|
||||||
|
return jsonify({'status': 'error', 'message': 'no shared audio data available'}), 504
|
||||||
|
sample_path = '/tmp/audio_probe.bin'
|
||||||
|
with open(sample_path, 'wb') as handle:
|
||||||
|
handle.write(data)
|
||||||
|
return jsonify({'status': 'ok', 'bytes': len(data), 'source': 'waterfall'})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
if not audio_process or not audio_process.stdout:
|
if not audio_process or not audio_process.stdout:
|
||||||
return jsonify({'status': 'error', 'message': 'audio process not running'}), 400
|
return jsonify({'status': 'error', 'message': 'audio process not running'}), 400
|
||||||
|
|
||||||
@@ -1438,17 +1559,61 @@ def audio_probe() -> Response:
|
|||||||
return jsonify({'status': 'ok', 'bytes': size})
|
return jsonify({'status': 'ok', 'bytes': size})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/audio/stream')
|
@receiver_bp.route('/audio/stream')
|
||||||
def stream_audio() -> Response:
|
def stream_audio() -> Response:
|
||||||
"""Stream WAV audio."""
|
"""Stream WAV audio."""
|
||||||
# Wait for audio to be ready (up to 2 seconds for modulation/squelch changes)
|
if audio_source == 'waterfall':
|
||||||
|
for _ in range(40):
|
||||||
|
if audio_running:
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
if not audio_running:
|
||||||
|
return Response(b'', mimetype='audio/wav', status=204)
|
||||||
|
|
||||||
|
def generate_shared():
|
||||||
|
global audio_running, audio_source
|
||||||
|
try:
|
||||||
|
from routes.waterfall_websocket import (
|
||||||
|
get_shared_capture_status,
|
||||||
|
read_shared_monitor_audio_chunk,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Browser expects an immediate WAV header.
|
||||||
|
yield _wav_header(sample_rate=48000)
|
||||||
|
|
||||||
|
while audio_running and audio_source == 'waterfall':
|
||||||
|
chunk = read_shared_monitor_audio_chunk(timeout=1.0)
|
||||||
|
if chunk:
|
||||||
|
yield chunk
|
||||||
|
continue
|
||||||
|
shared = get_shared_capture_status()
|
||||||
|
if not shared.get('running') or not shared.get('monitor_enabled'):
|
||||||
|
audio_running = False
|
||||||
|
audio_source = 'process'
|
||||||
|
break
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
generate_shared(),
|
||||||
|
mimetype='audio/wav',
|
||||||
|
headers={
|
||||||
|
'Content-Type': 'audio/wav',
|
||||||
|
'Cache-Control': 'no-cache, no-store',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
'Transfer-Encoding': 'chunked',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for audio process to be ready (up to 2 seconds).
|
||||||
for _ in range(40):
|
for _ in range(40):
|
||||||
if audio_running and audio_process:
|
if audio_running and audio_process:
|
||||||
break
|
break
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
|
|
||||||
if not audio_running or not audio_process:
|
if not audio_running or not audio_process:
|
||||||
return Response(b'', mimetype='audio/mpeg', status=204)
|
return Response(b'', mimetype='audio/wav', status=204)
|
||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
# Capture local reference to avoid race condition with stop
|
# Capture local reference to avoid race condition with stop
|
||||||
@@ -1474,21 +1639,25 @@ def stream_audio() -> Response:
|
|||||||
yield header_chunk
|
yield header_chunk
|
||||||
|
|
||||||
# Stream real-time audio
|
# Stream real-time audio
|
||||||
first_chunk_deadline = time.time() + 3.0
|
first_chunk_deadline = time.time() + 20.0
|
||||||
|
warned_wait = False
|
||||||
while audio_running and proc.poll() is None:
|
while audio_running and proc.poll() is None:
|
||||||
# Use select to avoid blocking forever
|
# Use select to avoid blocking forever
|
||||||
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
|
ready, _, _ = select.select([proc.stdout], [], [], 2.0)
|
||||||
if ready:
|
if ready:
|
||||||
chunk = proc.stdout.read(8192)
|
chunk = proc.stdout.read(8192)
|
||||||
if chunk:
|
if chunk:
|
||||||
|
warned_wait = False
|
||||||
yield chunk
|
yield chunk
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
# If no data arrives shortly after start, exit so caller can retry
|
# Keep connection open while demodulator settles.
|
||||||
if time.time() > first_chunk_deadline:
|
if time.time() > first_chunk_deadline:
|
||||||
logger.warning("Audio stream timed out waiting for first chunk")
|
if not warned_wait:
|
||||||
break
|
logger.warning("Audio stream still waiting for first chunk")
|
||||||
|
warned_wait = True
|
||||||
|
continue
|
||||||
# Timeout - check if process died
|
# Timeout - check if process died
|
||||||
if proc.poll() is not None:
|
if proc.poll() is not None:
|
||||||
break
|
break
|
||||||
@@ -1513,7 +1682,7 @@ def stream_audio() -> Response:
|
|||||||
# SIGNAL IDENTIFICATION ENDPOINT
|
# SIGNAL IDENTIFICATION ENDPOINT
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
@listening_post_bp.route('/signal/guess', methods=['POST'])
|
@receiver_bp.route('/signal/guess', methods=['POST'])
|
||||||
def guess_signal() -> Response:
|
def guess_signal() -> Response:
|
||||||
"""Identify a signal based on frequency, modulation, and other parameters."""
|
"""Identify a signal based on frequency, modulation, and other parameters."""
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
@@ -1621,9 +1790,20 @@ def _waterfall_loop():
|
|||||||
"""Continuous rtl_power sweep loop emitting waterfall data."""
|
"""Continuous rtl_power sweep loop emitting waterfall data."""
|
||||||
global waterfall_running, waterfall_process
|
global waterfall_running, waterfall_process
|
||||||
|
|
||||||
|
def _queue_waterfall_error(message: str) -> None:
|
||||||
|
try:
|
||||||
|
waterfall_queue.put_nowait({
|
||||||
|
'type': 'waterfall_error',
|
||||||
|
'message': message,
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
rtl_power_path = find_rtl_power()
|
rtl_power_path = find_rtl_power()
|
||||||
if not rtl_power_path:
|
if not rtl_power_path:
|
||||||
logger.error("rtl_power not found for waterfall")
|
logger.error("rtl_power not found for waterfall")
|
||||||
|
_queue_waterfall_error('rtl_power not found')
|
||||||
waterfall_running = False
|
waterfall_running = False
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1646,17 +1826,33 @@ def _waterfall_loop():
|
|||||||
waterfall_process = subprocess.Popen(
|
waterfall_process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.PIPE,
|
||||||
bufsize=1,
|
bufsize=1,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Detect immediate startup failures (e.g. device busy / no device).
|
||||||
|
time.sleep(0.35)
|
||||||
|
if waterfall_process.poll() is not None:
|
||||||
|
stderr_text = ''
|
||||||
|
try:
|
||||||
|
if waterfall_process.stderr:
|
||||||
|
stderr_text = waterfall_process.stderr.read().strip()
|
||||||
|
except Exception:
|
||||||
|
stderr_text = ''
|
||||||
|
msg = stderr_text or f'rtl_power exited early (code {waterfall_process.returncode})'
|
||||||
|
logger.error(f"Waterfall startup failed: {msg}")
|
||||||
|
_queue_waterfall_error(msg)
|
||||||
|
return
|
||||||
|
|
||||||
current_ts = None
|
current_ts = None
|
||||||
all_bins: list[float] = []
|
all_bins: list[float] = []
|
||||||
sweep_start_hz = start_hz
|
sweep_start_hz = start_hz
|
||||||
sweep_end_hz = end_hz
|
sweep_end_hz = end_hz
|
||||||
|
received_any = False
|
||||||
|
|
||||||
if not waterfall_process.stdout:
|
if not waterfall_process.stdout:
|
||||||
|
_queue_waterfall_error('rtl_power stdout unavailable')
|
||||||
return
|
return
|
||||||
|
|
||||||
for line in waterfall_process.stdout:
|
for line in waterfall_process.stdout:
|
||||||
@@ -1666,6 +1862,7 @@ def _waterfall_loop():
|
|||||||
ts, seg_start, seg_end, bins = _parse_rtl_power_line(line)
|
ts, seg_start, seg_end, bins = _parse_rtl_power_line(line)
|
||||||
if ts is None or not bins:
|
if ts is None or not bins:
|
||||||
continue
|
continue
|
||||||
|
received_any = True
|
||||||
|
|
||||||
if current_ts is None:
|
if current_ts is None:
|
||||||
current_ts = ts
|
current_ts = ts
|
||||||
@@ -1723,8 +1920,12 @@ def _waterfall_loop():
|
|||||||
except queue.Full:
|
except queue.Full:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if waterfall_running and not received_any:
|
||||||
|
_queue_waterfall_error('No waterfall FFT data received from rtl_power')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Waterfall loop error: {e}")
|
logger.error(f"Waterfall loop error: {e}")
|
||||||
|
_queue_waterfall_error(f"Waterfall loop error: {e}")
|
||||||
finally:
|
finally:
|
||||||
waterfall_running = False
|
waterfall_running = False
|
||||||
if waterfall_process and waterfall_process.poll() is None:
|
if waterfall_process and waterfall_process.poll() is None:
|
||||||
@@ -1761,14 +1962,19 @@ def _stop_waterfall_internal() -> None:
|
|||||||
waterfall_active_device = None
|
waterfall_active_device = None
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/waterfall/start', methods=['POST'])
|
@receiver_bp.route('/waterfall/start', methods=['POST'])
|
||||||
def start_waterfall() -> Response:
|
def start_waterfall() -> Response:
|
||||||
"""Start the waterfall/spectrogram display."""
|
"""Start the waterfall/spectrogram display."""
|
||||||
global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device
|
global waterfall_thread, waterfall_running, waterfall_config, waterfall_active_device
|
||||||
|
|
||||||
with waterfall_lock:
|
with waterfall_lock:
|
||||||
if waterfall_running:
|
if waterfall_running:
|
||||||
return jsonify({'status': 'error', 'message': 'Waterfall already running'}), 409
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'already_running': True,
|
||||||
|
'message': 'Waterfall already running',
|
||||||
|
'config': waterfall_config,
|
||||||
|
})
|
||||||
|
|
||||||
if not find_rtl_power():
|
if not find_rtl_power():
|
||||||
return jsonify({'status': 'error', 'message': 'rtl_power not found'}), 503
|
return jsonify({'status': 'error', 'message': 'rtl_power not found'}), 503
|
||||||
@@ -1817,7 +2023,7 @@ def start_waterfall() -> Response:
|
|||||||
return jsonify({'status': 'started', 'config': waterfall_config})
|
return jsonify({'status': 'started', 'config': waterfall_config})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/waterfall/stop', methods=['POST'])
|
@receiver_bp.route('/waterfall/stop', methods=['POST'])
|
||||||
def stop_waterfall() -> Response:
|
def stop_waterfall() -> Response:
|
||||||
"""Stop the waterfall display."""
|
"""Stop the waterfall display."""
|
||||||
_stop_waterfall_internal()
|
_stop_waterfall_internal()
|
||||||
@@ -1825,7 +2031,7 @@ def stop_waterfall() -> Response:
|
|||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/waterfall/stream')
|
@receiver_bp.route('/waterfall/stream')
|
||||||
def stream_waterfall() -> Response:
|
def stream_waterfall() -> Response:
|
||||||
"""SSE stream for waterfall data."""
|
"""SSE stream for waterfall data."""
|
||||||
def _on_msg(msg: dict[str, Any]) -> None:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
@@ -1834,7 +2040,7 @@ def stream_waterfall() -> Response:
|
|||||||
response = Response(
|
response = Response(
|
||||||
sse_stream_fanout(
|
sse_stream_fanout(
|
||||||
source_queue=waterfall_queue,
|
source_queue=waterfall_queue,
|
||||||
channel_key='listening_waterfall',
|
channel_key='receiver_waterfall',
|
||||||
timeout=SSE_QUEUE_TIMEOUT,
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
on_message=_on_msg,
|
on_message=_on_msg,
|
||||||
|
|||||||
+3
-2
@@ -11,8 +11,9 @@ offline_bp = Blueprint('offline', __name__, url_prefix='/offline')
|
|||||||
# Default offline settings
|
# Default offline settings
|
||||||
OFFLINE_DEFAULTS = {
|
OFFLINE_DEFAULTS = {
|
||||||
'offline.enabled': False,
|
'offline.enabled': False,
|
||||||
'offline.assets_source': 'cdn',
|
# Default to bundled assets/fonts to avoid third-party CDN privacy blocks.
|
||||||
'offline.fonts_source': 'cdn',
|
'offline.assets_source': 'local',
|
||||||
|
'offline.fonts_source': 'local',
|
||||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||||
'offline.tile_server_url': ''
|
'offline.tile_server_url': ''
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -108,6 +108,20 @@ def log_message(msg: dict[str, Any]) -> None:
|
|||||||
logger.error(f"Failed to log message: {e}")
|
logger.error(f"Failed to log message: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_scope_waveform(samples: tuple[int, ...], window_size: int = 256) -> list[int]:
|
||||||
|
"""Compress recent PCM samples into a signed 8-bit waveform for SSE."""
|
||||||
|
if not samples:
|
||||||
|
return []
|
||||||
|
|
||||||
|
window = samples[-window_size:] if len(samples) > window_size else samples
|
||||||
|
waveform: list[int] = []
|
||||||
|
for sample in window:
|
||||||
|
# Convert int16 PCM to int8 range for lightweight transport.
|
||||||
|
packed = int(round(sample / 256))
|
||||||
|
waveform.append(max(-127, min(127, packed)))
|
||||||
|
return waveform
|
||||||
|
|
||||||
|
|
||||||
def audio_relay_thread(
|
def audio_relay_thread(
|
||||||
rtl_stdout,
|
rtl_stdout,
|
||||||
multimon_stdin,
|
multimon_stdin,
|
||||||
@@ -118,7 +132,7 @@ def audio_relay_thread(
|
|||||||
|
|
||||||
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
Reads raw 16-bit LE PCM from *rtl_stdout*, writes every chunk straight
|
||||||
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
through to *multimon_stdin*, and every ~100 ms pushes an RMS / peak scope
|
||||||
event onto *output_queue*.
|
event plus a compact waveform sample onto *output_queue*.
|
||||||
"""
|
"""
|
||||||
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
CHUNK = 4096 # bytes – 2048 samples at 16-bit mono
|
||||||
INTERVAL = 0.1 # seconds between scope updates
|
INTERVAL = 0.1 # seconds between scope updates
|
||||||
@@ -152,6 +166,7 @@ def audio_relay_thread(
|
|||||||
'type': 'scope',
|
'type': 'scope',
|
||||||
'rms': rms,
|
'rms': rms,
|
||||||
'peak': peak,
|
'peak': peak,
|
||||||
|
'waveform': _encode_scope_waveform(samples),
|
||||||
})
|
})
|
||||||
except (struct.error, ValueError, queue.Full):
|
except (struct.error, ValueError, queue.Full):
|
||||||
pass
|
pass
|
||||||
|
|||||||
+36
-7
@@ -166,9 +166,11 @@ def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Opti
|
|||||||
@satellite_bp.route('/dashboard')
|
@satellite_bp.route('/dashboard')
|
||||||
def satellite_dashboard():
|
def satellite_dashboard():
|
||||||
"""Popout satellite tracking dashboard."""
|
"""Popout satellite tracking dashboard."""
|
||||||
|
embedded = request.args.get('embedded', 'false') == 'true'
|
||||||
return render_template(
|
return render_template(
|
||||||
'satellite_dashboard.html',
|
'satellite_dashboard.html',
|
||||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
|
embedded=embedded,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -588,14 +590,14 @@ def list_tracked_satellites():
|
|||||||
def add_tracked_satellites_endpoint():
|
def add_tracked_satellites_endpoint():
|
||||||
"""Add one or more tracked satellites."""
|
"""Add one or more tracked satellites."""
|
||||||
global _tle_cache
|
global _tle_cache
|
||||||
data = request.json
|
data = request.get_json(silent=True)
|
||||||
if not data:
|
if not data:
|
||||||
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
||||||
|
|
||||||
# Accept a single satellite dict or a list
|
# Accept a single satellite dict or a list
|
||||||
sat_list = data if isinstance(data, list) else [data]
|
sat_list = data if isinstance(data, list) else [data]
|
||||||
|
|
||||||
added = 0
|
normalized: list[dict] = []
|
||||||
for sat in sat_list:
|
for sat in sat_list:
|
||||||
norad_id = str(sat.get('norad_id', sat.get('norad', '')))
|
norad_id = str(sat.get('norad_id', sat.get('norad', '')))
|
||||||
name = sat.get('name', '')
|
name = sat.get('name', '')
|
||||||
@@ -605,19 +607,46 @@ def add_tracked_satellites_endpoint():
|
|||||||
tle2 = sat.get('tle_line2', sat.get('tle2'))
|
tle2 = sat.get('tle_line2', sat.get('tle2'))
|
||||||
enabled = sat.get('enabled', True)
|
enabled = sat.get('enabled', True)
|
||||||
|
|
||||||
if add_tracked_satellite(norad_id, name, tle1, tle2, enabled):
|
normalized.append({
|
||||||
added += 1
|
'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
|
# Also inject into TLE cache if we have TLE data
|
||||||
if tle1 and tle2:
|
if tle1 and tle2:
|
||||||
cache_key = name.replace(' ', '-').upper()
|
cache_key = name.replace(' ', '-').upper()
|
||||||
_tle_cache[cache_key] = (name, tle1, tle2)
|
_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',
|
'status': 'success',
|
||||||
'added': added,
|
'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'])
|
@satellite_bp.route('/tracked/<norad_id>', methods=['PUT'])
|
||||||
|
|||||||
+44
-5
@@ -3,12 +3,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import queue
|
import queue
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Generator
|
from typing import Any, Generator
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
@@ -33,6 +34,36 @@ sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
|
|||||||
_MAX_RSSI_HISTORY = 60
|
_MAX_RSSI_HISTORY = 60
|
||||||
|
|
||||||
|
|
||||||
|
def _build_scope_waveform(rssi: float, snr: float, noise: float, points: int = 256) -> list[int]:
|
||||||
|
"""Synthesize a compact waveform from rtl_433 level metrics."""
|
||||||
|
points = max(32, min(points, 512))
|
||||||
|
|
||||||
|
# rssi is usually negative; stronger signals are closer to 0 dBm.
|
||||||
|
rssi_norm = min(max(abs(rssi) / 40.0, 0.0), 1.0)
|
||||||
|
snr_norm = min(max((snr + 5.0) / 35.0, 0.0), 1.0)
|
||||||
|
noise_norm = min(max(abs(noise) / 40.0, 0.0), 1.0)
|
||||||
|
|
||||||
|
amplitude = max(0.06, min(1.0, (0.6 * rssi_norm + 0.4 * snr_norm) - (0.22 * noise_norm)))
|
||||||
|
cycles = 3.0 + (snr_norm * 8.0)
|
||||||
|
harmonic = 0.25 + (0.35 * snr_norm)
|
||||||
|
hiss = 0.08 + (0.18 * noise_norm)
|
||||||
|
phase = (time.monotonic() * (1.4 + (snr_norm * 2.2))) % (2.0 * math.pi)
|
||||||
|
|
||||||
|
waveform: list[int] = []
|
||||||
|
for i in range(points):
|
||||||
|
t = i / (points - 1)
|
||||||
|
base = math.sin((2.0 * math.pi * cycles * t) + phase)
|
||||||
|
overtone = math.sin((2.0 * math.pi * (cycles * 2.4) * t) + (phase * 0.7))
|
||||||
|
noise_wobble = math.sin((2.0 * math.pi * (cycles * 7.0) * t) + (phase * 2.1))
|
||||||
|
|
||||||
|
sample = amplitude * (base + (harmonic * overtone) + (hiss * noise_wobble))
|
||||||
|
sample /= (1.0 + harmonic + hiss)
|
||||||
|
packed = int(round(max(-1.0, min(1.0, sample)) * 127.0))
|
||||||
|
waveform.append(max(-127, min(127, packed)))
|
||||||
|
|
||||||
|
return waveform
|
||||||
|
|
||||||
|
|
||||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||||
"""Stream rtl_433 JSON output to queue."""
|
"""Stream rtl_433 JSON output to queue."""
|
||||||
try:
|
try:
|
||||||
@@ -66,13 +97,21 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
noise = data.get('noise')
|
noise = data.get('noise')
|
||||||
if rssi is not None or snr is not None:
|
if rssi is not None or snr is not None:
|
||||||
try:
|
try:
|
||||||
|
rssi_value = float(rssi) if rssi is not None else 0.0
|
||||||
|
snr_value = float(snr) if snr is not None else 0.0
|
||||||
|
noise_value = float(noise) if noise is not None else 0.0
|
||||||
app_module.sensor_queue.put_nowait({
|
app_module.sensor_queue.put_nowait({
|
||||||
'type': 'scope',
|
'type': 'scope',
|
||||||
'rssi': rssi if rssi is not None else 0,
|
'rssi': rssi_value,
|
||||||
'snr': snr if snr is not None else 0,
|
'snr': snr_value,
|
||||||
'noise': noise if noise is not None else 0,
|
'noise': noise_value,
|
||||||
|
'waveform': _build_scope_waveform(
|
||||||
|
rssi=rssi_value,
|
||||||
|
snr=snr_value,
|
||||||
|
noise=noise_value,
|
||||||
|
),
|
||||||
})
|
})
|
||||||
except queue.Full:
|
except (TypeError, ValueError, queue.Full):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Log if enabled
|
# Log if enabled
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
"""Signal identification enrichment routes (SigID Wiki proxy lookup)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('intercept.signalid')
|
||||||
|
|
||||||
|
signalid_bp = Blueprint('signalid', __name__, url_prefix='/signalid')
|
||||||
|
|
||||||
|
SIGID_API_URL = 'https://www.sigidwiki.com/api.php'
|
||||||
|
SIGID_USER_AGENT = 'INTERCEPT-SignalID/1.0'
|
||||||
|
SIGID_TIMEOUT_SECONDS = 12
|
||||||
|
SIGID_CACHE_TTL_SECONDS = 600
|
||||||
|
|
||||||
|
_cache: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_get(key: str) -> Any | None:
|
||||||
|
entry = _cache.get(key)
|
||||||
|
if not entry:
|
||||||
|
return None
|
||||||
|
if time.time() >= entry['expires']:
|
||||||
|
_cache.pop(key, None)
|
||||||
|
return None
|
||||||
|
return entry['data']
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_set(key: str, data: Any, ttl_seconds: int = SIGID_CACHE_TTL_SECONDS) -> None:
|
||||||
|
_cache[key] = {
|
||||||
|
'data': data,
|
||||||
|
'expires': time.time() + ttl_seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_api_json(params: dict[str, str]) -> dict[str, Any] | None:
|
||||||
|
query = urllib.parse.urlencode(params, doseq=True)
|
||||||
|
url = f'{SIGID_API_URL}?{query}'
|
||||||
|
req = urllib.request.Request(url, headers={'User-Agent': SIGID_USER_AGENT})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=SIGID_TIMEOUT_SECONDS) as resp:
|
||||||
|
payload = resp.read().decode('utf-8', errors='replace')
|
||||||
|
data = json.loads(payload)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('SigID API request failed: %s', exc)
|
||||||
|
return None
|
||||||
|
if isinstance(data, dict) and data.get('error'):
|
||||||
|
logger.warning('SigID API returned error: %s', data.get('error'))
|
||||||
|
return None
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _ask_query(query: str) -> dict[str, Any] | None:
|
||||||
|
return _fetch_api_json({
|
||||||
|
'action': 'ask',
|
||||||
|
'query': query,
|
||||||
|
'format': 'json',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _search_query(search_text: str, limit: int) -> dict[str, Any] | None:
|
||||||
|
return _fetch_api_json({
|
||||||
|
'action': 'query',
|
||||||
|
'list': 'search',
|
||||||
|
'srsearch': search_text,
|
||||||
|
'srlimit': str(limit),
|
||||||
|
'format': 'json',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _to_float_list(values: Any) -> list[float]:
|
||||||
|
if not isinstance(values, list):
|
||||||
|
return []
|
||||||
|
out: list[float] = []
|
||||||
|
for value in values:
|
||||||
|
try:
|
||||||
|
out.append(float(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _to_text_list(values: Any) -> list[str]:
|
||||||
|
if not isinstance(values, list):
|
||||||
|
return []
|
||||||
|
out: list[str] = []
|
||||||
|
for value in values:
|
||||||
|
text = str(value or '').strip()
|
||||||
|
if text:
|
||||||
|
out.append(text)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_modes(values: list[str]) -> list[str]:
|
||||||
|
out: list[str] = []
|
||||||
|
for value in values:
|
||||||
|
for token in str(value).replace('/', ',').split(','):
|
||||||
|
mode = token.strip().upper()
|
||||||
|
if mode and mode not in out:
|
||||||
|
out.append(mode)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_matches_from_ask(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
results = data.get('query', {}).get('results', {})
|
||||||
|
if not isinstance(results, dict):
|
||||||
|
return []
|
||||||
|
|
||||||
|
matches: list[dict[str, Any]] = []
|
||||||
|
for title, entry in results.items():
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
printouts = entry.get('printouts', {})
|
||||||
|
if not isinstance(printouts, dict):
|
||||||
|
printouts = {}
|
||||||
|
|
||||||
|
frequencies_hz = _to_float_list(printouts.get('Frequencies'))
|
||||||
|
frequencies_mhz = [round(v / 1e6, 6) for v in frequencies_hz if v > 0]
|
||||||
|
|
||||||
|
modes = _normalize_modes(_to_text_list(printouts.get('Mode')))
|
||||||
|
modulations = _normalize_modes(_to_text_list(printouts.get('Modulation')))
|
||||||
|
|
||||||
|
match = {
|
||||||
|
'title': str(entry.get('fulltext') or title),
|
||||||
|
'url': str(entry.get('fullurl') or ''),
|
||||||
|
'frequencies_mhz': frequencies_mhz,
|
||||||
|
'modes': modes,
|
||||||
|
'modulations': modulations,
|
||||||
|
'source': 'SigID Wiki',
|
||||||
|
}
|
||||||
|
matches.append(match)
|
||||||
|
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_matches(matches: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
deduped: dict[str, dict[str, Any]] = {}
|
||||||
|
for match in matches:
|
||||||
|
key = f"{match.get('title', '')}|{match.get('url', '')}"
|
||||||
|
if key not in deduped:
|
||||||
|
deduped[key] = match
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Merge frequencies/modes/modulations from duplicates.
|
||||||
|
existing = deduped[key]
|
||||||
|
for field in ('frequencies_mhz', 'modes', 'modulations'):
|
||||||
|
base = existing.get(field, [])
|
||||||
|
extra = match.get(field, [])
|
||||||
|
if not isinstance(base, list):
|
||||||
|
base = []
|
||||||
|
if not isinstance(extra, list):
|
||||||
|
extra = []
|
||||||
|
merged = list(base)
|
||||||
|
for item in extra:
|
||||||
|
if item not in merged:
|
||||||
|
merged.append(item)
|
||||||
|
existing[field] = merged
|
||||||
|
return list(deduped.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _rank_matches(
|
||||||
|
matches: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
frequency_mhz: float,
|
||||||
|
modulation: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
target_hz = frequency_mhz * 1e6
|
||||||
|
wanted_mod = str(modulation or '').strip().upper()
|
||||||
|
|
||||||
|
def score(match: dict[str, Any]) -> tuple[int, float, str]:
|
||||||
|
score_value = 0
|
||||||
|
freqs_mhz = match.get('frequencies_mhz') or []
|
||||||
|
distances_hz: list[float] = []
|
||||||
|
for f_mhz in freqs_mhz:
|
||||||
|
try:
|
||||||
|
distances_hz.append(abs((float(f_mhz) * 1e6) - target_hz))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
min_distance_hz = min(distances_hz) if distances_hz else 1e12
|
||||||
|
|
||||||
|
if min_distance_hz <= 100:
|
||||||
|
score_value += 120
|
||||||
|
elif min_distance_hz <= 1_000:
|
||||||
|
score_value += 90
|
||||||
|
elif min_distance_hz <= 10_000:
|
||||||
|
score_value += 70
|
||||||
|
elif min_distance_hz <= 100_000:
|
||||||
|
score_value += 40
|
||||||
|
|
||||||
|
if wanted_mod:
|
||||||
|
modes = [str(v).upper() for v in (match.get('modes') or [])]
|
||||||
|
modulations = [str(v).upper() for v in (match.get('modulations') or [])]
|
||||||
|
if wanted_mod in modes:
|
||||||
|
score_value += 25
|
||||||
|
if wanted_mod in modulations:
|
||||||
|
score_value += 25
|
||||||
|
|
||||||
|
title = str(match.get('title') or '')
|
||||||
|
title_lower = title.lower()
|
||||||
|
if 'unidentified' in title_lower or 'unknown' in title_lower:
|
||||||
|
score_value -= 10
|
||||||
|
|
||||||
|
return (score_value, min_distance_hz, title.lower())
|
||||||
|
|
||||||
|
ranked = sorted(matches, key=score, reverse=True)
|
||||||
|
for match in ranked:
|
||||||
|
try:
|
||||||
|
nearest = min(abs((float(f) * 1e6) - target_hz) for f in (match.get('frequencies_mhz') or []))
|
||||||
|
match['distance_hz'] = int(round(nearest))
|
||||||
|
except Exception:
|
||||||
|
match['distance_hz'] = None
|
||||||
|
return ranked
|
||||||
|
|
||||||
|
|
||||||
|
def _format_freq_variants_mhz(freq_mhz: float) -> list[str]:
|
||||||
|
variants = [
|
||||||
|
f'{freq_mhz:.6f}'.rstrip('0').rstrip('.'),
|
||||||
|
f'{freq_mhz:.4f}'.rstrip('0').rstrip('.'),
|
||||||
|
f'{freq_mhz:.3f}'.rstrip('0').rstrip('.'),
|
||||||
|
]
|
||||||
|
out: list[str] = []
|
||||||
|
for value in variants:
|
||||||
|
if value and value not in out:
|
||||||
|
out.append(value)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_sigidwiki_matches(frequency_mhz: float, modulation: str, limit: int) -> dict[str, Any]:
|
||||||
|
all_matches: list[dict[str, Any]] = []
|
||||||
|
exact_queries: list[str] = []
|
||||||
|
|
||||||
|
for freq_token in _format_freq_variants_mhz(frequency_mhz):
|
||||||
|
query = (
|
||||||
|
f'[[Category:Signal]][[Frequencies::{freq_token} MHz]]'
|
||||||
|
f'|?Frequencies|?Mode|?Modulation|limit={max(10, limit * 2)}'
|
||||||
|
)
|
||||||
|
exact_queries.append(query)
|
||||||
|
data = _ask_query(query)
|
||||||
|
if data:
|
||||||
|
all_matches.extend(_extract_matches_from_ask(data))
|
||||||
|
if all_matches:
|
||||||
|
break
|
||||||
|
|
||||||
|
search_used = False
|
||||||
|
if not all_matches:
|
||||||
|
search_used = True
|
||||||
|
search_terms = [f'{frequency_mhz:.4f} MHz']
|
||||||
|
if modulation:
|
||||||
|
search_terms.insert(0, f'{frequency_mhz:.4f} MHz {modulation.upper()}')
|
||||||
|
|
||||||
|
seen_titles: set[str] = set()
|
||||||
|
for term in search_terms:
|
||||||
|
search_data = _search_query(term, max(5, min(limit * 2, 10)))
|
||||||
|
search_results = search_data.get('query', {}).get('search', []) if isinstance(search_data, dict) else []
|
||||||
|
if not isinstance(search_results, list) or not search_results:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for item in search_results:
|
||||||
|
title = str(item.get('title') or '').strip()
|
||||||
|
if not title or title in seen_titles:
|
||||||
|
continue
|
||||||
|
seen_titles.add(title)
|
||||||
|
page_query = f'[[{title}]]|?Frequencies|?Mode|?Modulation|limit=1'
|
||||||
|
page_data = _ask_query(page_query)
|
||||||
|
if page_data:
|
||||||
|
all_matches.extend(_extract_matches_from_ask(page_data))
|
||||||
|
if len(all_matches) >= max(limit * 3, 12):
|
||||||
|
break
|
||||||
|
if all_matches:
|
||||||
|
break
|
||||||
|
|
||||||
|
deduped = _dedupe_matches(all_matches)
|
||||||
|
ranked = _rank_matches(deduped, frequency_mhz=frequency_mhz, modulation=modulation)
|
||||||
|
return {
|
||||||
|
'matches': ranked[:limit],
|
||||||
|
'search_used': search_used,
|
||||||
|
'exact_queries': exact_queries,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@signalid_bp.route('/sigidwiki', methods=['POST'])
|
||||||
|
def sigidwiki_lookup() -> Response:
|
||||||
|
"""Lookup likely signal types from SigID Wiki by tuned frequency."""
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
freq_raw = payload.get('frequency_mhz')
|
||||||
|
if freq_raw is None:
|
||||||
|
return jsonify({'status': 'error', 'message': 'frequency_mhz is required'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
frequency_mhz = float(freq_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid frequency_mhz'}), 400
|
||||||
|
|
||||||
|
if frequency_mhz <= 0:
|
||||||
|
return jsonify({'status': 'error', 'message': 'frequency_mhz must be positive'}), 400
|
||||||
|
|
||||||
|
modulation = str(payload.get('modulation') or '').strip().upper()
|
||||||
|
if modulation and len(modulation) > 16:
|
||||||
|
modulation = modulation[:16]
|
||||||
|
|
||||||
|
limit_raw = payload.get('limit', 8)
|
||||||
|
try:
|
||||||
|
limit = int(limit_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = 8
|
||||||
|
limit = max(1, min(limit, 20))
|
||||||
|
|
||||||
|
cache_key = f'{round(frequency_mhz, 6)}|{modulation}|{limit}'
|
||||||
|
cached = _cache_get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'source': 'sigidwiki',
|
||||||
|
'frequency_mhz': round(frequency_mhz, 6),
|
||||||
|
'modulation': modulation or None,
|
||||||
|
'cached': True,
|
||||||
|
**cached,
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
lookup = _lookup_sigidwiki_matches(frequency_mhz, modulation, limit)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('SigID lookup failed: %s', exc)
|
||||||
|
return jsonify({'status': 'error', 'message': 'SigID lookup failed'}), 502
|
||||||
|
|
||||||
|
response_payload = {
|
||||||
|
'matches': lookup.get('matches', []),
|
||||||
|
'match_count': len(lookup.get('matches', [])),
|
||||||
|
'search_used': bool(lookup.get('search_used')),
|
||||||
|
'exact_queries': lookup.get('exact_queries', []),
|
||||||
|
}
|
||||||
|
_cache_set(cache_key, response_payload)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'source': 'sigidwiki',
|
||||||
|
'frequency_mhz': round(frequency_mhz, 6),
|
||||||
|
'modulation': modulation or None,
|
||||||
|
'cached': False,
|
||||||
|
**response_payload,
|
||||||
|
})
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ from flask import Blueprint, jsonify, request, Response, send_file
|
|||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import sse_stream
|
from utils.sse import sse_stream
|
||||||
from utils.subghz import get_subghz_manager
|
from utils.subghz import get_subghz_manager
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
SUBGHZ_FREQ_MIN_MHZ,
|
SUBGHZ_FREQ_MIN_MHZ,
|
||||||
SUBGHZ_FREQ_MAX_MHZ,
|
SUBGHZ_FREQ_MAX_MHZ,
|
||||||
@@ -34,6 +35,10 @@ _subghz_queue: queue.Queue = queue.Queue(maxsize=200)
|
|||||||
|
|
||||||
def _event_callback(event: dict) -> None:
|
def _event_callback(event: dict) -> None:
|
||||||
"""Forward SubGhzManager events to the SSE queue."""
|
"""Forward SubGhzManager events to the SSE queue."""
|
||||||
|
try:
|
||||||
|
process_event('subghz', event, event.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
_subghz_queue.put_nowait(event)
|
_subghz_queue.put_nowait(event)
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
|
|||||||
+414
-48
@@ -6,7 +6,10 @@ import socket
|
|||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from contextlib import suppress
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -17,18 +20,33 @@ except ImportError:
|
|||||||
Sock = None
|
Sock = None
|
||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
from utils.process import register_process, safe_terminate, unregister_process
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.sdr.base import SDRCapabilities, SDRDevice
|
||||||
from utils.waterfall_fft import (
|
from utils.waterfall_fft import (
|
||||||
build_binary_frame,
|
build_binary_frame,
|
||||||
compute_power_spectrum,
|
compute_power_spectrum,
|
||||||
cu8_to_complex,
|
cu8_to_complex,
|
||||||
quantize_to_uint8,
|
quantize_to_uint8,
|
||||||
)
|
)
|
||||||
from utils.sdr import SDRFactory, SDRType
|
|
||||||
from utils.sdr.base import SDRCapabilities, SDRDevice
|
|
||||||
|
|
||||||
logger = get_logger('intercept.waterfall_ws')
|
logger = get_logger('intercept.waterfall_ws')
|
||||||
|
|
||||||
|
AUDIO_SAMPLE_RATE = 48000
|
||||||
|
_shared_state_lock = threading.Lock()
|
||||||
|
_shared_audio_queue: queue.Queue[bytes] = queue.Queue(maxsize=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,
|
||||||
|
}
|
||||||
|
|
||||||
# Maximum bandwidth per SDR type (Hz)
|
# Maximum bandwidth per SDR type (Hz)
|
||||||
MAX_BANDWIDTH = {
|
MAX_BANDWIDTH = {
|
||||||
SDRType.RTL_SDR: 2400000,
|
SDRType.RTL_SDR: 2400000,
|
||||||
@@ -39,6 +57,237 @@ MAX_BANDWIDTH = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_shared_audio_queue() -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
_shared_audio_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def _set_shared_capture_state(
|
||||||
|
*,
|
||||||
|
running: bool,
|
||||||
|
device: int | None = None,
|
||||||
|
center_mhz: float | None = None,
|
||||||
|
span_mhz: float | None = None,
|
||||||
|
sample_rate: int | None = None,
|
||||||
|
) -> 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:
|
def _resolve_sdr_type(sdr_type_str: str) -> SDRType:
|
||||||
"""Convert client sdr_type string to SDRType enum."""
|
"""Convert client sdr_type string to SDRType enum."""
|
||||||
mapping = {
|
mapping = {
|
||||||
@@ -87,6 +336,10 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
reader_thread = None
|
reader_thread = None
|
||||||
stop_event = threading.Event()
|
stop_event = threading.Event()
|
||||||
claimed_device = None
|
claimed_device = None
|
||||||
|
capture_center_mhz = 0.0
|
||||||
|
capture_start_freq = 0.0
|
||||||
|
capture_end_freq = 0.0
|
||||||
|
capture_span_mhz = 0.0
|
||||||
# Queue for outgoing messages — only the main loop touches ws.send()
|
# Queue for outgoing messages — only the main loop touches ws.send()
|
||||||
send_queue = queue.Queue(maxsize=120)
|
send_queue = queue.Queue(maxsize=120)
|
||||||
|
|
||||||
@@ -105,7 +358,7 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
break
|
break
|
||||||
|
|
||||||
try:
|
try:
|
||||||
msg = ws.receive(timeout=0.1)
|
msg = ws.receive(timeout=0.01)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
err = str(e).lower()
|
err = str(e).lower()
|
||||||
if "closed" in err:
|
if "closed" in err:
|
||||||
@@ -143,6 +396,7 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
if claimed_device is not None:
|
if claimed_device is not None:
|
||||||
app_module.release_sdr_device(claimed_device)
|
app_module.release_sdr_device(claimed_device)
|
||||||
claimed_device = None
|
claimed_device = None
|
||||||
|
_set_shared_capture_state(running=False)
|
||||||
stop_event.clear()
|
stop_event.clear()
|
||||||
# Flush stale frames from previous capture
|
# Flush stale frames from previous capture
|
||||||
while not send_queue.empty():
|
while not send_queue.empty():
|
||||||
@@ -155,34 +409,58 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
# Parse config
|
# Parse config
|
||||||
center_freq = float(data.get('center_freq', 100.0))
|
try:
|
||||||
span_mhz = float(data.get('span_mhz', 2.0))
|
center_freq_mhz = _parse_center_freq_mhz(data)
|
||||||
gain = data.get('gain')
|
span_mhz = _parse_span_mhz(data)
|
||||||
if gain is not None:
|
gain_raw = data.get('gain')
|
||||||
gain = float(gain)
|
if gain_raw is None or str(gain_raw).lower() == 'auto':
|
||||||
device_index = int(data.get('device', 0))
|
gain = None
|
||||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
else:
|
||||||
fft_size = int(data.get('fft_size', 1024))
|
gain = float(gain_raw)
|
||||||
fps = int(data.get('fps', 25))
|
device_index = int(data.get('device', 0))
|
||||||
avg_count = int(data.get('avg_count', 4))
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
ppm = data.get('ppm')
|
fft_size = int(data.get('fft_size', 1024))
|
||||||
if ppm is not None:
|
fps = int(data.get('fps', 25))
|
||||||
ppm = int(ppm)
|
avg_count = int(data.get('avg_count', 4))
|
||||||
bias_t = bool(data.get('bias_t', False))
|
ppm = data.get('ppm')
|
||||||
|
if ppm is not None:
|
||||||
|
ppm = int(ppm)
|
||||||
|
bias_t = bool(data.get('bias_t', False))
|
||||||
|
db_min = data.get('db_min')
|
||||||
|
db_max = data.get('db_max')
|
||||||
|
if db_min is not None:
|
||||||
|
db_min = float(db_min)
|
||||||
|
if db_max is not None:
|
||||||
|
db_max = float(db_max)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid waterfall configuration: {exc}',
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
|
||||||
# Clamp FFT size to valid powers of 2
|
# Clamp and normalize runtime settings
|
||||||
fft_size = max(256, min(8192, fft_size))
|
fft_size = max(256, min(8192, fft_size))
|
||||||
|
fps = max(2, min(60, fps))
|
||||||
|
avg_count = max(1, min(32, avg_count))
|
||||||
|
if center_freq_mhz <= 0 or span_mhz <= 0:
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'center_freq_mhz and span_mhz must be > 0',
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
|
||||||
# Resolve SDR type and bandwidth
|
# Resolve SDR type and choose a valid sample rate
|
||||||
sdr_type = _resolve_sdr_type(sdr_type_str)
|
sdr_type = _resolve_sdr_type(sdr_type_str)
|
||||||
max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000)
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
span_hz = int(span_mhz * 1e6)
|
caps = builder.get_capabilities()
|
||||||
sample_rate = min(span_hz, max_bw)
|
requested_span_hz = max(1000, int(span_mhz * 1e6))
|
||||||
|
sample_rate = _pick_sample_rate(requested_span_hz, caps, sdr_type)
|
||||||
|
|
||||||
# Compute effective frequency range
|
# Compute effective frequency range
|
||||||
effective_span_mhz = sample_rate / 1e6
|
effective_span_mhz = sample_rate / 1e6
|
||||||
start_freq = center_freq - effective_span_mhz / 2
|
start_freq = center_freq_mhz - effective_span_mhz / 2
|
||||||
end_freq = center_freq + effective_span_mhz / 2
|
end_freq = center_freq_mhz + effective_span_mhz / 2
|
||||||
|
|
||||||
# Claim the device
|
# Claim the device
|
||||||
claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
|
claim_err = app_module.claim_sdr_device(device_index, 'waterfall')
|
||||||
@@ -197,11 +475,10 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
|
|
||||||
# Build I/Q capture command
|
# Build I/Q capture command
|
||||||
try:
|
try:
|
||||||
builder = SDRFactory.get_builder(sdr_type)
|
|
||||||
device = _build_dummy_device(device_index, sdr_type)
|
device = _build_dummy_device(device_index, sdr_type)
|
||||||
iq_cmd = builder.build_iq_capture_command(
|
iq_cmd = builder.build_iq_capture_command(
|
||||||
device=device,
|
device=device,
|
||||||
frequency_mhz=center_freq,
|
frequency_mhz=center_freq_mhz,
|
||||||
sample_rate=sample_rate,
|
sample_rate=sample_rate,
|
||||||
gain=gain,
|
gain=gain,
|
||||||
ppm=ppm,
|
ppm=ppm,
|
||||||
@@ -221,7 +498,7 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
try:
|
try:
|
||||||
for attempt in range(max_attempts):
|
for attempt in range(max_attempts):
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Starting I/Q capture: {center_freq} MHz, "
|
f"Starting I/Q capture: {center_freq_mhz:.6f} MHz, "
|
||||||
f"span={effective_span_mhz:.1f} MHz, "
|
f"span={effective_span_mhz:.1f} MHz, "
|
||||||
f"sr={sample_rate}, fft={fft_size}"
|
f"sr={sample_rate}, fft={fft_size}"
|
||||||
)
|
)
|
||||||
@@ -263,23 +540,50 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
}))
|
}))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
capture_center_mhz = center_freq_mhz
|
||||||
|
capture_start_freq = start_freq
|
||||||
|
capture_end_freq = end_freq
|
||||||
|
capture_span_mhz = effective_span_mhz
|
||||||
|
|
||||||
|
_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
|
# Send started confirmation
|
||||||
ws.send(json.dumps({
|
ws.send(json.dumps({
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
|
'center_mhz': center_freq_mhz,
|
||||||
'start_freq': start_freq,
|
'start_freq': start_freq,
|
||||||
'end_freq': end_freq,
|
'end_freq': end_freq,
|
||||||
'fft_size': fft_size,
|
'fft_size': fft_size,
|
||||||
'sample_rate': sample_rate,
|
'sample_rate': sample_rate,
|
||||||
|
'effective_span_mhz': effective_span_mhz,
|
||||||
|
'db_min': db_min,
|
||||||
|
'db_max': db_max,
|
||||||
|
'vfo_freq_mhz': center_freq_mhz,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
# Start reader thread — puts frames on queue, never calls ws.send()
|
# Start reader thread — puts frames on queue, never calls ws.send()
|
||||||
def fft_reader(
|
def fft_reader(
|
||||||
proc, _send_q, stop_evt,
|
proc, _send_q, stop_evt,
|
||||||
_fft_size, _avg_count, _fps,
|
_fft_size, _avg_count, _fps, _sample_rate,
|
||||||
_start_freq, _end_freq,
|
_start_freq, _end_freq, _center_mhz,
|
||||||
|
_db_min=None, _db_max=None,
|
||||||
):
|
):
|
||||||
"""Read I/Q from subprocess, compute FFT, enqueue binary frames."""
|
"""Read I/Q from subprocess, compute FFT, enqueue binary frames."""
|
||||||
bytes_per_frame = _fft_size * _avg_count * 2
|
required_fft_samples = _fft_size * _avg_count
|
||||||
|
timeslice_samples = max(required_fft_samples, int(_sample_rate / max(1, _fps)))
|
||||||
|
bytes_per_frame = timeslice_samples * 2
|
||||||
frame_interval = 1.0 / _fps
|
frame_interval = 1.0 / _fps
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -304,21 +608,37 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
|
|
||||||
# Process FFT pipeline
|
# Process FFT pipeline
|
||||||
samples = cu8_to_complex(raw)
|
samples = cu8_to_complex(raw)
|
||||||
|
fft_samples = samples[-required_fft_samples:] if len(samples) > required_fft_samples else samples
|
||||||
power_db = compute_power_spectrum(
|
power_db = compute_power_spectrum(
|
||||||
samples,
|
fft_samples,
|
||||||
fft_size=_fft_size,
|
fft_size=_fft_size,
|
||||||
avg_count=_avg_count,
|
avg_count=_avg_count,
|
||||||
)
|
)
|
||||||
quantized = quantize_to_uint8(power_db)
|
quantized = quantize_to_uint8(
|
||||||
|
power_db,
|
||||||
|
db_min=_db_min,
|
||||||
|
db_max=_db_max,
|
||||||
|
)
|
||||||
frame = build_binary_frame(
|
frame = build_binary_frame(
|
||||||
_start_freq, _end_freq, quantized,
|
_start_freq, _end_freq, quantized,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
# Drop frame if main loop cannot keep up.
|
||||||
|
with suppress(queue.Full):
|
||||||
_send_q.put_nowait(frame)
|
_send_q.put_nowait(frame)
|
||||||
except queue.Full:
|
|
||||||
# Drop frame if main loop can't keep up
|
monitor_cfg = _snapshot_monitor_config()
|
||||||
pass
|
if monitor_cfg:
|
||||||
|
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
|
# Pace to target FPS
|
||||||
elapsed = time.monotonic() - frame_start
|
elapsed = time.monotonic() - frame_start
|
||||||
@@ -333,13 +653,63 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
target=fft_reader,
|
target=fft_reader,
|
||||||
args=(
|
args=(
|
||||||
iq_process, send_queue, stop_event,
|
iq_process, send_queue, stop_event,
|
||||||
fft_size, avg_count, fps,
|
fft_size, avg_count, fps, sample_rate,
|
||||||
start_freq, end_freq,
|
start_freq, end_freq, center_freq_mhz,
|
||||||
|
db_min, db_max,
|
||||||
),
|
),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
reader_thread.start()
|
reader_thread.start()
|
||||||
|
|
||||||
|
elif cmd in ('tune', 'set_vfo'):
|
||||||
|
if not iq_process or claimed_device is None or iq_process.poll() is not None:
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Waterfall capture is not running',
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
shared = get_shared_capture_status()
|
||||||
|
vfo_freq_mhz = float(
|
||||||
|
data.get(
|
||||||
|
'vfo_freq_mhz',
|
||||||
|
data.get('frequency_mhz', data.get('center_freq_mhz', capture_center_mhz)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
squelch = int(data.get('squelch', shared.get('monitor_squelch', 0)))
|
||||||
|
modulation = str(data.get('modulation', shared.get('monitor_modulation', 'wfm')))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid tune request: {exc}',
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not (capture_start_freq <= vfo_freq_mhz <= capture_end_freq):
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'retune_required',
|
||||||
|
'message': 'Frequency outside current capture span',
|
||||||
|
'capture_start_freq': capture_start_freq,
|
||||||
|
'capture_end_freq': capture_end_freq,
|
||||||
|
'vfo_freq_mhz': vfo_freq_mhz,
|
||||||
|
}))
|
||||||
|
continue
|
||||||
|
|
||||||
|
monitor_enabled = bool(shared.get('monitor_enabled'))
|
||||||
|
_set_shared_monitor(
|
||||||
|
enabled=monitor_enabled,
|
||||||
|
frequency_mhz=vfo_freq_mhz,
|
||||||
|
modulation=modulation,
|
||||||
|
squelch=squelch,
|
||||||
|
)
|
||||||
|
ws.send(json.dumps({
|
||||||
|
'status': 'tuned',
|
||||||
|
'vfo_freq_mhz': vfo_freq_mhz,
|
||||||
|
'start_freq': capture_start_freq,
|
||||||
|
'end_freq': capture_end_freq,
|
||||||
|
'center_mhz': capture_center_mhz,
|
||||||
|
}))
|
||||||
|
|
||||||
elif cmd == 'stop':
|
elif cmd == 'stop':
|
||||||
stop_event.set()
|
stop_event.set()
|
||||||
if reader_thread and reader_thread.is_alive():
|
if reader_thread and reader_thread.is_alive():
|
||||||
@@ -352,6 +722,7 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
if claimed_device is not None:
|
if claimed_device is not None:
|
||||||
app_module.release_sdr_device(claimed_device)
|
app_module.release_sdr_device(claimed_device)
|
||||||
claimed_device = None
|
claimed_device = None
|
||||||
|
_set_shared_capture_state(running=False)
|
||||||
stop_event.clear()
|
stop_event.clear()
|
||||||
ws.send(json.dumps({'status': 'stopped'}))
|
ws.send(json.dumps({'status': 'stopped'}))
|
||||||
|
|
||||||
@@ -367,20 +738,15 @@ def init_waterfall_websocket(app: Flask):
|
|||||||
unregister_process(iq_process)
|
unregister_process(iq_process)
|
||||||
if claimed_device is not None:
|
if claimed_device is not None:
|
||||||
app_module.release_sdr_device(claimed_device)
|
app_module.release_sdr_device(claimed_device)
|
||||||
|
_set_shared_capture_state(running=False)
|
||||||
# Complete WebSocket close handshake, then shut down the
|
# Complete WebSocket close handshake, then shut down the
|
||||||
# raw socket so Werkzeug cannot write its HTTP 200 response
|
# raw socket so Werkzeug cannot write its HTTP 200 response
|
||||||
# on top of the WebSocket stream (which browsers see as
|
# on top of the WebSocket stream (which browsers see as
|
||||||
# "Invalid frame header").
|
# "Invalid frame header").
|
||||||
try:
|
with suppress(Exception):
|
||||||
ws.close()
|
ws.close()
|
||||||
except Exception:
|
with suppress(Exception):
|
||||||
pass
|
|
||||||
try:
|
|
||||||
ws.sock.shutdown(socket.SHUT_RDWR)
|
ws.sock.shutdown(socket.SHUT_RDWR)
|
||||||
except Exception:
|
with suppress(Exception):
|
||||||
pass
|
|
||||||
try:
|
|
||||||
ws.sock.close()
|
ws.sock.close()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
logger.info("WebSocket waterfall client disconnected")
|
logger.info("WebSocket waterfall client disconnected")
|
||||||
|
|||||||
@@ -233,10 +233,6 @@ check_tools() {
|
|||||||
info "GPS:"
|
info "GPS:"
|
||||||
check_required "gpsd" "GPS daemon" gpsd
|
check_required "gpsd" "GPS daemon" gpsd
|
||||||
|
|
||||||
echo
|
|
||||||
info "Digital Voice:"
|
|
||||||
check_optional "dsd" "Digital Speech Decoder (DMR/P25)" dsd dsd-fme
|
|
||||||
|
|
||||||
echo
|
echo
|
||||||
info "Audio:"
|
info "Audio:"
|
||||||
check_required "ffmpeg" "Audio encoder/decoder" ffmpeg
|
check_required "ffmpeg" "Audio encoder/decoder" ffmpeg
|
||||||
@@ -458,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() {
|
install_dump1090_from_source_macos() {
|
||||||
info "dump1090 not available via Homebrew. Building from source..."
|
info "dump1090 not available via Homebrew. Building from source..."
|
||||||
|
|
||||||
@@ -918,7 +825,7 @@ install_macos_packages() {
|
|||||||
sudo -v || { fail "sudo authentication failed"; exit 1; }
|
sudo -v || { fail "sudo authentication failed"; exit 1; }
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TOTAL_STEPS=22
|
TOTAL_STEPS=21
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Checking Homebrew"
|
progress "Checking Homebrew"
|
||||||
@@ -941,19 +848,6 @@ install_macos_packages() {
|
|||||||
progress "SSTV decoder"
|
progress "SSTV decoder"
|
||||||
ok "SSTV uses built-in pure Python decoder (no external tools needed)"
|
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"
|
progress "Installing ffmpeg"
|
||||||
brew_install ffmpeg
|
brew_install ffmpeg
|
||||||
|
|
||||||
@@ -1409,7 +1303,7 @@ install_debian_packages() {
|
|||||||
export NEEDRESTART_MODE=a
|
export NEEDRESTART_MODE=a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TOTAL_STEPS=28
|
TOTAL_STEPS=27
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Updating APT package lists"
|
progress "Updating APT package lists"
|
||||||
@@ -1474,19 +1368,6 @@ install_debian_packages() {
|
|||||||
progress "SSTV decoder"
|
progress "SSTV decoder"
|
||||||
ok "SSTV uses built-in pure Python decoder (no external tools needed)"
|
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"
|
progress "Installing ffmpeg"
|
||||||
apt_install ffmpeg
|
apt_install ffmpeg
|
||||||
|
|
||||||
|
|||||||
@@ -893,6 +893,92 @@ body {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-crosshair-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1200;
|
||||||
|
--crosshair-x-start: 100%;
|
||||||
|
--crosshair-y-start: 100%;
|
||||||
|
--crosshair-x-end: 50%;
|
||||||
|
--crosshair-y-end: 50%;
|
||||||
|
--crosshair-duration: 1500ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-crosshair-line {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
box-shadow: none;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-crosshair-vertical {
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
left: 0;
|
||||||
|
transform: translateX(var(--crosshair-x-start));
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-crosshair-horizontal {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
top: 0;
|
||||||
|
transform: translateY(var(--crosshair-y-start));
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-crosshair-overlay.active .map-crosshair-vertical {
|
||||||
|
animation: mapCrosshairSweepX var(--crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-crosshair-overlay.active .map-crosshair-horizontal {
|
||||||
|
animation: mapCrosshairSweepY var(--crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mapCrosshairSweepX {
|
||||||
|
0% {
|
||||||
|
transform: translateX(var(--crosshair-x-start));
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
12% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
85% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(var(--crosshair-x-end));
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mapCrosshairSweepY {
|
||||||
|
0% {
|
||||||
|
transform: translateY(var(--crosshair-y-start));
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
12% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
85% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(var(--crosshair-y-end));
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.map-crosshair-overlay.active .map-crosshair-vertical,
|
||||||
|
.map-crosshair-overlay.active .map-crosshair-horizontal {
|
||||||
|
animation-duration: 220ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Right sidebar - Mobile first */
|
/* Right sidebar - Mobile first */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -13,13 +13,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.radar-device {
|
.radar-device {
|
||||||
transition: transform 0.2s ease;
|
|
||||||
transform-origin: center center;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.radar-device:hover {
|
.radar-device:hover .radar-dot {
|
||||||
transform: scale(1.2);
|
filter: brightness(1.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Invisible larger hit area to prevent hover flicker */
|
/* Invisible larger hit area to prevent hover flicker */
|
||||||
|
|||||||
@@ -1802,6 +1802,14 @@ header h1 .tagline {
|
|||||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes stop-btn-pulse {
|
||||||
|
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(239,68,68,0); }
|
||||||
|
50% { opacity: 0.75; box-shadow: 0 0 8px 2px rgba(239,68,68,0.45); }
|
||||||
|
}
|
||||||
|
.stop-btn {
|
||||||
|
animation: stop-btn-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.output-panel {
|
.output-panel {
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2172,6 +2180,10 @@ header h1 .tagline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.control-btn {
|
.control-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -2182,6 +2194,14 @@ header h1 .tagline {
|
|||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
font-family: var(--font-sans);
|
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 {
|
.control-btn:hover {
|
||||||
|
|||||||
@@ -1,500 +0,0 @@
|
|||||||
/* Analytics Dashboard Styles */
|
|
||||||
|
|
||||||
/* Analytics is a sidebar-only mode — hide the output panel and expand the sidebar */
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.main-content.analytics-active {
|
|
||||||
grid-template-columns: 1fr !important;
|
|
||||||
}
|
|
||||||
.main-content.analytics-active > .output-panel {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
.main-content.analytics-active > .sidebar {
|
|
||||||
max-width: 100% !important;
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
.main-content.analytics-active .sidebar-collapse-btn {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1023px) {
|
|
||||||
.main-content.analytics-active > .output-panel {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
|
||||||
gap: var(--space-3, 12px);
|
|
||||||
margin-bottom: var(--space-4, 16px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
|
||||||
gap: var(--space-3, 12px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card {
|
|
||||||
background: var(--bg-card, #151f2b);
|
|
||||||
border: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
border-radius: var(--radius-md, 8px);
|
|
||||||
padding: 10px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card.low {
|
|
||||||
border-color: rgba(90, 106, 122, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card.medium {
|
|
||||||
border-color: rgba(74, 163, 255, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card.high {
|
|
||||||
border-color: rgba(214, 168, 94, 0.55);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card.critical {
|
|
||||||
border-color: rgba(226, 93, 93, 0.65);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card .insight-title {
|
|
||||||
font-size: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card .insight-value {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card .insight-label {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-secondary, #9aabba);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-insight-card .insight-detail {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-top-changes {
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-change-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 7px 0;
|
|
||||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-change-row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-change-row .mode {
|
|
||||||
min-width: 84px;
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-change-row .delta {
|
|
||||||
min-width: 48px;
|
|
||||||
font-family: var(--font-mono, monospace);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-change-row .delta.up {
|
|
||||||
color: var(--accent-green, #38c180);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-change-row .delta.down {
|
|
||||||
color: var(--accent-red, #e25d5d);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-change-row .delta.flat {
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-change-row .avg {
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-card {
|
|
||||||
background: var(--bg-card, #151f2b);
|
|
||||||
border: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
border-radius: var(--radius-md, 8px);
|
|
||||||
padding: var(--space-3, 12px);
|
|
||||||
text-align: center;
|
|
||||||
transition: var(--transition-fast, 150ms ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-card:hover {
|
|
||||||
border-color: var(--accent-cyan, #4aa3ff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-card .card-count {
|
|
||||||
font-size: var(--text-2xl, 24px);
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-card .card-label {
|
|
||||||
font-size: var(--text-xs, 10px);
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin-top: var(--space-1, 4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-card .card-sparkline {
|
|
||||||
height: 24px;
|
|
||||||
margin-top: var(--space-2, 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-card .card-sparkline svg {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-card .card-sparkline polyline {
|
|
||||||
fill: none;
|
|
||||||
stroke: var(--accent-cyan, #4aa3ff);
|
|
||||||
stroke-width: 1.5;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Health indicators */
|
|
||||||
.analytics-health {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-2, 8px);
|
|
||||||
margin-bottom: var(--space-4, 16px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.health-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-1, 4px);
|
|
||||||
font-size: var(--text-xs, 10px);
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.health-dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--accent-red, #e25d5d);
|
|
||||||
}
|
|
||||||
|
|
||||||
.health-dot.running {
|
|
||||||
background: var(--accent-green, #38c180);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Emergency squawk panel */
|
|
||||||
.squawk-emergency {
|
|
||||||
background: rgba(226, 93, 93, 0.1);
|
|
||||||
border: 1px solid var(--accent-red, #e25d5d);
|
|
||||||
border-radius: var(--radius-md, 8px);
|
|
||||||
padding: var(--space-3, 12px);
|
|
||||||
margin-bottom: var(--space-3, 12px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.squawk-emergency .squawk-title {
|
|
||||||
color: var(--accent-red, #e25d5d);
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: var(--text-sm, 12px);
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-bottom: var(--space-2, 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.squawk-emergency .squawk-item {
|
|
||||||
font-size: var(--text-sm, 12px);
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
padding: var(--space-1, 4px) 0;
|
|
||||||
border-bottom: 1px solid rgba(226, 93, 93, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.squawk-emergency .squawk-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Alert feed */
|
|
||||||
.analytics-alert-feed {
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
margin-bottom: var(--space-4, 16px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-alert-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: var(--space-2, 8px);
|
|
||||||
padding: var(--space-2, 8px);
|
|
||||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
font-size: var(--text-xs, 10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-alert-item .alert-severity {
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: var(--radius-sm, 4px);
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 9px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-severity.critical { background: var(--accent-red, #e25d5d); color: #fff; }
|
|
||||||
.alert-severity.high { background: var(--accent-orange, #d6a85e); color: #000; }
|
|
||||||
.alert-severity.medium { background: var(--accent-cyan, #4aa3ff); color: #fff; }
|
|
||||||
.alert-severity.low { background: var(--border-color, #1e2d3d); color: var(--text-dim, #5a6a7a); }
|
|
||||||
|
|
||||||
/* Correlation panel */
|
|
||||||
.analytics-correlation-pair {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2, 8px);
|
|
||||||
padding: var(--space-2, 8px);
|
|
||||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
font-size: var(--text-xs, 10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-correlation-pair .confidence-bar {
|
|
||||||
height: 4px;
|
|
||||||
background: var(--bg-secondary, #101823);
|
|
||||||
border-radius: 2px;
|
|
||||||
flex: 1;
|
|
||||||
max-width: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-correlation-pair .confidence-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: var(--accent-green, #38c180);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-pattern-item {
|
|
||||||
padding: 8px;
|
|
||||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-pattern-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-pattern-item .pattern-main {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-pattern-item .pattern-mode {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-pattern-item .pattern-device {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
font-family: var(--font-mono, monospace);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-pattern-item .pattern-meta {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-pattern-item .pattern-confidence {
|
|
||||||
color: var(--accent-green, #38c180);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Geofence zone list */
|
|
||||||
.geofence-zone-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: var(--space-2, 8px);
|
|
||||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
font-size: var(--text-xs, 10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.geofence-zone-item .zone-name {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
}
|
|
||||||
|
|
||||||
.geofence-zone-item .zone-radius {
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.geofence-zone-item .zone-delete {
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--accent-red, #e25d5d);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border: 1px solid var(--accent-red, #e25d5d);
|
|
||||||
border-radius: var(--radius-sm, 4px);
|
|
||||||
background: transparent;
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Export controls */
|
|
||||||
.export-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-2, 8px);
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-controls select,
|
|
||||||
.export-controls button {
|
|
||||||
font-size: var(--text-xs, 10px);
|
|
||||||
padding: var(--space-1, 4px) var(--space-2, 8px);
|
|
||||||
background: var(--bg-card, #151f2b);
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
border: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
border-radius: var(--radius-sm, 4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-controls button {
|
|
||||||
cursor: pointer;
|
|
||||||
background: var(--accent-cyan, #4aa3ff);
|
|
||||||
color: #fff;
|
|
||||||
border-color: var(--accent-cyan, #4aa3ff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-controls button:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Section headers */
|
|
||||||
.analytics-section-header {
|
|
||||||
font-size: var(--text-xs, 10px);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin-bottom: var(--space-2, 8px);
|
|
||||||
padding-bottom: var(--space-1, 4px);
|
|
||||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Empty state */
|
|
||||||
.analytics-empty {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
font-size: var(--text-xs, 10px);
|
|
||||||
padding: var(--space-4, 16px);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-toolbar,
|
|
||||||
.analytics-replay-toolbar {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-toolbar input {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 220px;
|
|
||||||
background: var(--bg-card, #151f2b);
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
border: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-toolbar button,
|
|
||||||
.analytics-replay-toolbar button,
|
|
||||||
.analytics-replay-toolbar select {
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 5px 9px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
background: var(--bg-card, #151f2b);
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-toolbar button,
|
|
||||||
.analytics-replay-toolbar button {
|
|
||||||
cursor: pointer;
|
|
||||||
background: rgba(74, 163, 255, 0.2);
|
|
||||||
border-color: rgba(74, 163, 255, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-summary {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-item,
|
|
||||||
.analytics-replay-item {
|
|
||||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
|
||||||
padding: 7px 0;
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-item:last-child,
|
|
||||||
.analytics-replay-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-item .title,
|
|
||||||
.analytics-replay-item .title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-primary, #e0e6ed);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-item .mode,
|
|
||||||
.analytics-replay-item .mode {
|
|
||||||
font-size: 9px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
border: 1px solid rgba(74, 163, 255, 0.35);
|
|
||||||
color: var(--accent-cyan, #4aa3ff);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analytics-target-item .meta,
|
|
||||||
.analytics-replay-item .meta {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-dim, #5a6a7a);
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
@@ -266,7 +266,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
height: 100%;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,8 +282,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#btLocateMap {
|
#btLocateMap {
|
||||||
width: 100%;
|
position: absolute;
|
||||||
height: 100%;
|
inset: 0;
|
||||||
background: #1a1a2e;
|
background: #1a1a2e;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,3 +560,69 @@
|
|||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Crosshair sweep animation ───────────────────────────────────── */
|
||||||
|
.btl-crosshair-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1200;
|
||||||
|
--btl-crosshair-x-start: 100%;
|
||||||
|
--btl-crosshair-y-start: 100%;
|
||||||
|
--btl-crosshair-x-end: 50%;
|
||||||
|
--btl-crosshair-y-end: 50%;
|
||||||
|
--btl-crosshair-duration: 1500ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-crosshair-line {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-crosshair-vertical {
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
left: 0;
|
||||||
|
transform: translateX(var(--btl-crosshair-x-start));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-crosshair-horizontal {
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
top: 0;
|
||||||
|
transform: translateY(var(--btl-crosshair-y-start));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-crosshair-overlay.active .btl-crosshair-vertical {
|
||||||
|
animation: btlCrosshairSweepX var(--btl-crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-crosshair-overlay.active .btl-crosshair-horizontal {
|
||||||
|
animation: btlCrosshairSweepY var(--btl-crosshair-duration) cubic-bezier(0.2, 0.85, 0.28, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes btlCrosshairSweepX {
|
||||||
|
0% { transform: translateX(var(--btl-crosshair-x-start)); opacity: 0; }
|
||||||
|
12% { opacity: 1; }
|
||||||
|
85% { opacity: 1; }
|
||||||
|
100% { transform: translateX(var(--btl-crosshair-x-end)); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes btlCrosshairSweepY {
|
||||||
|
0% { transform: translateY(var(--btl-crosshair-y-start)); opacity: 0; }
|
||||||
|
12% { opacity: 1; }
|
||||||
|
85% { opacity: 1; }
|
||||||
|
100% { transform: translateY(var(--btl-crosshair-y-end)); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.btl-crosshair-overlay.active .btl-crosshair-vertical,
|
||||||
|
.btl-crosshair-overlay.active .btl-crosshair-horizontal {
|
||||||
|
animation-duration: 220ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -140,14 +140,65 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.gps-skyview-canvas-wrap {
|
.gps-skyview-canvas-wrap {
|
||||||
display: flex;
|
position: relative;
|
||||||
justify-content: center;
|
display: block;
|
||||||
align-items: center;
|
width: min(100%, 430px);
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
margin: 0 auto;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#gpsSkyCanvas {
|
#gpsSkyCanvas {
|
||||||
max-width: 100%;
|
display: block;
|
||||||
height: auto;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#gpsSkyCanvas:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-sky-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-sky-label {
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
text-shadow: 0 0 6px rgba(0, 0, 0, 0.9);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-sky-label-cardinal {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-sky-label-sat {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-sky-label-sat.unused {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-sky-hint {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Position info panel */
|
/* Position info panel */
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 750 B |
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" fill="#0b1118" rx="80"/>
|
||||||
|
<!-- Signal wave arcs radiating from center-left -->
|
||||||
|
<g fill="none" stroke="#4aa3ff" stroke-linecap="round">
|
||||||
|
<!-- Inner arc -->
|
||||||
|
<path stroke-width="22" d="M 160 256 Q 192 210 192 256 Q 192 302 160 256" opacity="0.5"/>
|
||||||
|
<!-- Small arc -->
|
||||||
|
<path stroke-width="22" d="M 130 256 Q 180 185 180 256 Q 180 327 130 256" opacity="0.65"/>
|
||||||
|
<!-- Medium arc -->
|
||||||
|
<path stroke-width="24" d="M 100 256 Q 175 155 175 256 Q 175 357 100 256" opacity="0.8"/>
|
||||||
|
<!-- Large arc -->
|
||||||
|
<path stroke-width="26" d="M 68 256 Q 170 120 170 256 Q 170 392 68 256" opacity="0.95"/>
|
||||||
|
</g>
|
||||||
|
<!-- Horizontal beam line -->
|
||||||
|
<line x1="190" y1="256" x2="420" y2="256" stroke="#4aa3ff" stroke-width="20" stroke-linecap="round"/>
|
||||||
|
<!-- Signal dot at origin -->
|
||||||
|
<circle cx="190" cy="256" r="18" fill="#4aa3ff"/>
|
||||||
|
<!-- Target reticle at end -->
|
||||||
|
<circle cx="420" cy="256" r="28" fill="none" stroke="#4aa3ff" stroke-width="14"/>
|
||||||
|
<circle cx="420" cy="256" r="8" fill="#4aa3ff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Activity Timeline Component
|
* Activity Timeline Component
|
||||||
* Reusable, configuration-driven timeline visualization for time-based metadata
|
* Reusable, configuration-driven timeline visualization for time-based metadata
|
||||||
* Supports multiple modes: TSCM, Listening Post, Bluetooth, WiFi, Monitoring
|
* Supports multiple modes: TSCM, RF Receiver, Bluetooth, WiFi, Monitoring
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ActivityTimeline = (function() {
|
const ActivityTimeline = (function() {
|
||||||
@@ -176,7 +176,7 @@ const ActivityTimeline = (function() {
|
|||||||
*/
|
*/
|
||||||
function categorizeById(id, mode) {
|
function categorizeById(id, mode) {
|
||||||
// RF frequency categorization
|
// RF frequency categorization
|
||||||
if (mode === 'rf' || mode === 'tscm' || mode === 'listening-post') {
|
if (mode === 'rf' || mode === 'tscm' || mode === 'waterfall') {
|
||||||
const f = parseFloat(id);
|
const f = parseFloat(id);
|
||||||
if (!isNaN(f)) {
|
if (!isNaN(f)) {
|
||||||
if (f >= 2400 && f <= 2500) return '2.4 GHz wireless band';
|
if (f >= 2400 && f <= 2500) return '2.4 GHz wireless band';
|
||||||
|
|||||||
@@ -33,10 +33,7 @@ const ProximityRadar = (function() {
|
|||||||
let activeFilter = null;
|
let activeFilter = null;
|
||||||
let onDeviceClick = null;
|
let onDeviceClick = null;
|
||||||
let selectedDeviceKey = null;
|
let selectedDeviceKey = null;
|
||||||
let isHovered = false;
|
|
||||||
let renderPending = false;
|
|
||||||
let renderTimer = null;
|
let renderTimer = null;
|
||||||
let interactionLockUntil = 0; // timestamp: suppress renders briefly after click
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the radar component
|
* Initialize the radar component
|
||||||
@@ -128,28 +125,10 @@ const ProximityRadar = (function() {
|
|||||||
if (!deviceEl) return;
|
if (!deviceEl) return;
|
||||||
const deviceKey = deviceEl.getAttribute('data-device-key');
|
const deviceKey = deviceEl.getAttribute('data-device-key');
|
||||||
if (onDeviceClick && deviceKey) {
|
if (onDeviceClick && deviceKey) {
|
||||||
// Lock out re-renders briefly so the DOM stays stable after click
|
|
||||||
interactionLockUntil = Date.now() + 500;
|
|
||||||
onDeviceClick(deviceKey);
|
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
|
// Add sweep animation
|
||||||
animateSweep();
|
animateSweep();
|
||||||
}
|
}
|
||||||
@@ -191,17 +170,10 @@ const ProximityRadar = (function() {
|
|||||||
function updateDevices(deviceList) {
|
function updateDevices(deviceList) {
|
||||||
if (isPaused) return;
|
if (isPaused) return;
|
||||||
|
|
||||||
// Update device map
|
|
||||||
deviceList.forEach(device => {
|
deviceList.forEach(device => {
|
||||||
devices.set(device.device_key, 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)
|
// Debounce rapid updates (e.g. per-device SSE events)
|
||||||
if (renderTimer) clearTimeout(renderTimer);
|
if (renderTimer) clearTimeout(renderTimer);
|
||||||
renderTimer = setTimeout(() => {
|
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() {
|
function renderDevices() {
|
||||||
const devicesGroup = svg.querySelector('.radar-devices');
|
const devicesGroup = svg.querySelector('.radar-devices');
|
||||||
@@ -219,6 +193,7 @@ const ProximityRadar = (function() {
|
|||||||
|
|
||||||
const center = CONFIG.size / 2;
|
const center = CONFIG.size / 2;
|
||||||
const maxRadius = center - CONFIG.padding;
|
const maxRadius = center - CONFIG.padding;
|
||||||
|
const ns = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
// Filter devices
|
// Filter devices
|
||||||
let visibleDevices = Array.from(devices.values());
|
let visibleDevices = Array.from(devices.values());
|
||||||
@@ -234,69 +209,195 @@ const ProximityRadar = (function() {
|
|||||||
visibleDevices = visibleDevices.filter(d => !d.in_baseline);
|
visibleDevices = visibleDevices.filter(d => !d.in_baseline);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build SVG for each device
|
const visibleKeys = new Set(visibleDevices.map(d => d.device_key));
|
||||||
const dots = visibleDevices.map(device => {
|
|
||||||
// Calculate position
|
|
||||||
const { x, y, radius } = calculateDevicePosition(device, center, maxRadius);
|
|
||||||
|
|
||||||
// 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 confidence = device.distance_confidence || 0.5;
|
||||||
const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence;
|
const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence;
|
||||||
|
|
||||||
// Get color based on proximity band
|
|
||||||
const color = getBandColor(device.proximity_band);
|
const color = getBandColor(device.proximity_band);
|
||||||
|
|
||||||
// Check if newly seen (pulse animation)
|
|
||||||
const isNew = device.age_seconds < 5;
|
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 existing = devicesGroup.querySelector(
|
||||||
const hitAreaSize = Math.max(dotSize * 2, 15);
|
`.radar-device-wrapper[data-device-key="${CSS.escape(key)}"]`
|
||||||
|
);
|
||||||
|
|
||||||
return `
|
if (existing) {
|
||||||
<g transform="translate(${x}, ${y})">
|
// ── In-place update: mutate attributes, never recreate ──
|
||||||
<g class="radar-device ${pulseClass}${isSelected ? ' selected' : ''}" data-device-key="${escapeAttr(device.device_key)}"
|
existing.setAttribute('transform', `translate(${x}, ${y})`);
|
||||||
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('');
|
|
||||||
|
|
||||||
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
|
* Calculate device position on radar
|
||||||
*/
|
*/
|
||||||
function calculateDevicePosition(device, center, maxRadius) {
|
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;
|
let radiusRatio;
|
||||||
const band = device.proximity_band || 'unknown';
|
switch (device.proximity_band || 'unknown') {
|
||||||
|
case 'immediate': radiusRatio = 0.15; break;
|
||||||
if (device.estimated_distance_m != null) {
|
case 'near': radiusRatio = 0.40; break;
|
||||||
// Use actual distance (log scale)
|
case 'far': radiusRatio = 0.70; break;
|
||||||
const maxDistance = 15;
|
default: radiusRatio = 0.90; break;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate angle based on device key hash (stable positioning)
|
// Calculate angle based on device key hash (stable positioning)
|
||||||
@@ -306,7 +407,53 @@ const ProximityRadar = (function() {
|
|||||||
const x = center + Math.sin(angle) * radius;
|
const x = center + Math.sin(angle) * radius;
|
||||||
const y = center - Math.cos(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,19 +289,6 @@ const SignalGuess = (function() {
|
|||||||
regions: ['GLOBAL']
|
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
|
// Key Fob
|
||||||
{
|
{
|
||||||
label: 'Remote Control / Key Fob',
|
label: 'Remote Control / Key Fob',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* RF Signal Timeline Adapter
|
* RF Signal Timeline Adapter
|
||||||
* Normalizes RF signal data for the Activity Timeline component
|
* Normalizes RF signal data for the Activity Timeline component
|
||||||
* Used by: Listening Post, TSCM
|
* Used by: Spectrum Waterfall, TSCM
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const RFTimelineAdapter = (function() {
|
const RFTimelineAdapter = (function() {
|
||||||
@@ -158,12 +158,12 @@ const RFTimelineAdapter = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create timeline configuration for Listening Post mode
|
* Create timeline configuration for spectrum waterfall mode.
|
||||||
*/
|
*/
|
||||||
function getListeningPostConfig() {
|
function getWaterfallConfig() {
|
||||||
return {
|
return {
|
||||||
title: 'Signal Activity',
|
title: 'Spectrum Activity',
|
||||||
mode: 'listening-post',
|
mode: 'waterfall',
|
||||||
visualMode: 'enriched',
|
visualMode: 'enriched',
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
showAnnotations: true,
|
showAnnotations: true,
|
||||||
@@ -188,6 +188,11 @@ const RFTimelineAdapter = (function() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backward compatibility alias for legacy callers.
|
||||||
|
function getListeningPostConfig() {
|
||||||
|
return getWaterfallConfig();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create timeline configuration for TSCM mode
|
* Create timeline configuration for TSCM mode
|
||||||
*/
|
*/
|
||||||
@@ -224,6 +229,7 @@ const RFTimelineAdapter = (function() {
|
|||||||
categorizeFrequency: categorizeFrequency,
|
categorizeFrequency: categorizeFrequency,
|
||||||
|
|
||||||
// Configuration presets
|
// Configuration presets
|
||||||
|
getWaterfallConfig: getWaterfallConfig,
|
||||||
getListeningPostConfig: getListeningPostConfig,
|
getListeningPostConfig: getListeningPostConfig,
|
||||||
getTscmConfig: getTscmConfig,
|
getTscmConfig: getTscmConfig,
|
||||||
|
|
||||||
|
|||||||
+4
-13
@@ -98,7 +98,7 @@ function switchMode(mode) {
|
|||||||
const modeMap = {
|
const modeMap = {
|
||||||
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
|
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
|
||||||
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
|
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
|
||||||
'listening': 'listening', 'meshtastic': 'meshtastic'
|
'meshtastic': 'meshtastic'
|
||||||
};
|
};
|
||||||
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
||||||
const label = btn.querySelector('.nav-label');
|
const label = btn.querySelector('.nav-label');
|
||||||
@@ -114,7 +114,6 @@ function switchMode(mode) {
|
|||||||
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
|
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
|
||||||
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
|
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
|
||||||
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
|
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
|
||||||
document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
|
|
||||||
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
||||||
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
||||||
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
|
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
|
||||||
@@ -143,7 +142,6 @@ function switchMode(mode) {
|
|||||||
'satellite': 'SATELLITE',
|
'satellite': 'SATELLITE',
|
||||||
'wifi': 'WIFI',
|
'wifi': 'WIFI',
|
||||||
'bluetooth': 'BLUETOOTH',
|
'bluetooth': 'BLUETOOTH',
|
||||||
'listening': 'LISTENING POST',
|
|
||||||
'tscm': 'TSCM',
|
'tscm': 'TSCM',
|
||||||
'aprs': 'APRS',
|
'aprs': 'APRS',
|
||||||
'meshtastic': 'MESHTASTIC'
|
'meshtastic': 'MESHTASTIC'
|
||||||
@@ -166,7 +164,6 @@ function switchMode(mode) {
|
|||||||
const showRadar = document.getElementById('adsbEnableMap')?.checked;
|
const showRadar = document.getElementById('adsbEnableMap')?.checked;
|
||||||
document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
|
document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
|
||||||
document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
|
document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
|
||||||
document.getElementById('listeningPostVisuals').style.display = mode === 'listening' ? 'grid' : 'none';
|
|
||||||
|
|
||||||
// Update output panel title based on mode
|
// Update output panel title based on mode
|
||||||
const titles = {
|
const titles = {
|
||||||
@@ -176,7 +173,6 @@ function switchMode(mode) {
|
|||||||
'satellite': 'Satellite Monitor',
|
'satellite': 'Satellite Monitor',
|
||||||
'wifi': 'WiFi Scanner',
|
'wifi': 'WiFi Scanner',
|
||||||
'bluetooth': 'Bluetooth Scanner',
|
'bluetooth': 'Bluetooth Scanner',
|
||||||
'listening': 'Listening Post',
|
|
||||||
'meshtastic': 'Meshtastic Mesh Monitor'
|
'meshtastic': 'Meshtastic Mesh Monitor'
|
||||||
};
|
};
|
||||||
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
|
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
|
||||||
@@ -184,7 +180,7 @@ function switchMode(mode) {
|
|||||||
// Show/hide Device Intelligence for modes that use it
|
// Show/hide Device Intelligence for modes that use it
|
||||||
const reconBtn = document.getElementById('reconBtn');
|
const reconBtn = document.getElementById('reconBtn');
|
||||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||||
if (mode === 'satellite' || mode === 'aircraft' || mode === 'listening') {
|
if (mode === 'satellite' || mode === 'aircraft') {
|
||||||
document.getElementById('reconPanel').style.display = 'none';
|
document.getElementById('reconPanel').style.display = 'none';
|
||||||
if (reconBtn) reconBtn.style.display = 'none';
|
if (reconBtn) reconBtn.style.display = 'none';
|
||||||
if (intelBtn) intelBtn.style.display = 'none';
|
if (intelBtn) intelBtn.style.display = 'none';
|
||||||
@@ -198,7 +194,7 @@ function switchMode(mode) {
|
|||||||
|
|
||||||
// Show RTL-SDR device section for modes that use it
|
// Show RTL-SDR device section for modes that use it
|
||||||
document.getElementById('rtlDeviceSection').style.display =
|
document.getElementById('rtlDeviceSection').style.display =
|
||||||
(mode === 'pager' || mode === 'sensor' || mode === 'aircraft' || mode === 'listening') ? 'block' : 'none';
|
(mode === 'pager' || mode === 'sensor' || mode === 'aircraft') ? 'block' : 'none';
|
||||||
|
|
||||||
// Toggle mode-specific tool status displays
|
// Toggle mode-specific tool status displays
|
||||||
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
|
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
|
||||||
@@ -207,7 +203,7 @@ function switchMode(mode) {
|
|||||||
|
|
||||||
// Hide waterfall and output console for modes with their own visualizations
|
// Hide waterfall and output console for modes with their own visualizations
|
||||||
document.querySelector('.waterfall-container').style.display =
|
document.querySelector('.waterfall-container').style.display =
|
||||||
(mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||||
document.getElementById('output').style.display =
|
document.getElementById('output').style.display =
|
||||||
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'meshtastic' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations') ? 'none' : 'block';
|
||||||
document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex';
|
document.querySelector('.status-bar').style.display = (mode === 'satellite' || mode === 'tscm' || mode === 'meshtastic' || mode === 'aprs' || mode === 'spystations') ? 'none' : 'flex';
|
||||||
@@ -226,11 +222,6 @@ function switchMode(mode) {
|
|||||||
} else if (mode === 'satellite') {
|
} else if (mode === 'satellite') {
|
||||||
if (typeof initPolarPlot === 'function') initPolarPlot();
|
if (typeof initPolarPlot === 'function') initPolarPlot();
|
||||||
if (typeof initSatelliteList === 'function') initSatelliteList();
|
if (typeof initSatelliteList === 'function') initSatelliteList();
|
||||||
} else if (mode === 'listening') {
|
|
||||||
if (typeof checkScannerTools === 'function') checkScannerTools();
|
|
||||||
if (typeof checkAudioTools === 'function') checkAudioTools();
|
|
||||||
if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect();
|
|
||||||
if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect();
|
|
||||||
} else if (mode === 'meshtastic') {
|
} else if (mode === 'meshtastic') {
|
||||||
if (typeof Meshtastic !== 'undefined' && Meshtastic.init) Meshtastic.init();
|
if (typeof Meshtastic !== 'undefined' && Meshtastic.init) Meshtastic.init();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/* INTERCEPT Per-Mode Cheat Sheets */
|
||||||
|
const CheatSheets = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const CONTENT = {
|
||||||
|
pager: { title: 'Pager Decoder', icon: '📟', hardware: 'RTL-SDR dongle', description: 'Decodes POCSAG and FLEX pager protocols via rtl_fm + multimon-ng.', whatToExpect: 'Numeric and alphanumeric pager messages with address codes.', tips: ['Try frequencies 152.240, 157.450, 462.9625 MHz', 'Gain 38–45 dB works well for most dongles', 'POCSAG 512/1200/2400 baud are common'] },
|
||||||
|
sensor: { title: '433MHz Sensors', icon: '🌡️', hardware: 'RTL-SDR dongle', description: 'Decodes 433MHz IoT sensors via rtl_433.', whatToExpect: 'JSON events from weather stations, door sensors, car key fobs.', tips: ['Leave gain on AUTO', 'Walk around to discover hidden sensors', 'Protocol filter narrows false positives'] },
|
||||||
|
wifi: { title: 'WiFi Scanner', icon: '📡', hardware: 'WiFi adapter (monitor mode)', description: 'Scans WiFi networks and clients via airodump-ng or nmcli.', whatToExpect: 'SSIDs, BSSIDs, channel, signal strength, encryption type.', tips: ['Run airmon-ng check kill before monitoring', 'Proximity radar shows signal strength', 'TSCM baseline detects rogue APs'] },
|
||||||
|
bluetooth: { title: 'Bluetooth Scanner', icon: '🔵', hardware: 'Built-in or USB Bluetooth adapter', description: 'Scans BLE and classic Bluetooth devices. Identifies trackers.', whatToExpect: 'Device names, MACs, RSSI, manufacturer, tracker type.', tips: ['Proximity radar shows device distance', 'Known tracker DB has 47K+ fingerprints', 'Use BT Locate to physically find a tracker'] },
|
||||||
|
bt_locate: { title: 'BT Locate (SAR)', icon: '🎯', hardware: 'Bluetooth adapter + optional GPS', description: 'SAR Bluetooth locator. Tracks RSSI over time to triangulate position.', whatToExpect: 'RSSI chart, proximity band (IMMEDIATE/NEAR/FAR), GPS trail.', tips: ['Handoff from Bluetooth mode to lock onto a device', 'Indoor n=3.0 gives better distance estimates', 'Follow the heat trail toward stronger signal'] },
|
||||||
|
meshtastic: { title: 'Meshtastic', icon: '🕸️', hardware: 'Meshtastic LoRa node (USB)', description: 'Monitors Meshtastic LoRa mesh network messages and positions.', whatToExpect: 'Text messages, node map, telemetry.', tips: ['Default channel must match your mesh', 'Long-Fast has best range', 'GPS nodes appear on map automatically'] },
|
||||||
|
adsb: { title: 'ADS-B Aircraft', icon: '✈️', hardware: 'RTL-SDR + 1090MHz antenna', description: 'Tracks aircraft via ADS-B Mode S transponders using dump1090.', whatToExpect: 'Flight numbers, positions, altitude, speed, squawk codes.', tips: ['1090MHz — use a dedicated antenna', 'Emergency squawks: 7500 hijack, 7600 radio fail, 7700 emergency', 'Full Dashboard shows map view'] },
|
||||||
|
ais: { title: 'AIS Vessels', icon: '🚢', hardware: 'RTL-SDR + VHF antenna (162 MHz)', description: 'Tracks marine vessels via AIS using AIS-catcher.', whatToExpect: 'MMSI, vessel names, positions, speed, heading, cargo type.', tips: ['VHF antenna centered at 162MHz works best', 'DSC distress alerts appear in red', 'Coastline range ~40 nautical miles'] },
|
||||||
|
aprs: { title: 'APRS', icon: '📻', hardware: 'RTL-SDR + VHF + direwolf', description: 'Decodes APRS amateur packet radio via direwolf TNC modem.', whatToExpect: 'Station positions, weather reports, messages, telemetry.', tips: ['Primary APRS frequency: 144.390 MHz (North America)', 'direwolf must be running', 'Positions appear on the map'] },
|
||||||
|
satellite: { title: 'Satellite Tracker', icon: '🛰️', hardware: 'None (pass prediction only)', description: 'Predicts satellite pass times using TLE data from CelesTrak.', whatToExpect: 'Pass windows with AOS/LOS times, max elevation, bearing.', tips: ['Set observer location in Settings', 'Plan ISS SSTV using pass times', 'TLEs auto-update every 24 hours'] },
|
||||||
|
sstv: { title: 'ISS SSTV', icon: '🖼️', hardware: 'RTL-SDR + 145MHz antenna', description: 'Receives ISS SSTV images via slowrx.', whatToExpect: 'Color images during ISS SSTV events (PD180 mode).', tips: ['ISS SSTV: 145.800 MHz', 'Check ARISS for active event dates', 'ISS must be overhead — check pass times'] },
|
||||||
|
weathersat: { title: 'Weather Satellites', icon: '🌤️', hardware: 'RTL-SDR + 137MHz turnstile/QFH antenna', description: 'Decodes NOAA APT and Meteor LRPT weather imagery via SatDump.', whatToExpect: 'Infrared/visible cloud imagery.', tips: ['NOAA 15/18/19: 137.1–137.9 MHz APT', 'Meteor M2-3: 137.9 MHz LRPT', 'Use circular polarized antenna (QFH or turnstile)'] },
|
||||||
|
sstv_general:{ title: 'HF SSTV', icon: '📷', hardware: 'RTL-SDR + HF upconverter', description: 'Receives HF SSTV transmissions.', whatToExpect: 'Amateur radio images on 14.230 MHz (USB mode).', tips: ['14.230 MHz USB is primary HF SSTV frequency', 'Scottie 1 and Martin 1 most common', 'Best during daylight hours'] },
|
||||||
|
gps: { title: 'GPS Receiver', icon: '🗺️', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction', 'Verify a 3D fix before relying on altitude'] },
|
||||||
|
spaceweather:{ title: 'Space Weather', icon: '☀️', hardware: 'None (NOAA/SpaceWeatherLive data)', description: 'Monitors solar activity and geomagnetic storm indices.', whatToExpect: 'Kp index, solar flux, X-ray flare alerts, CME tracking.', tips: ['High Kp (≥5) = geomagnetic storm', 'X-class flares cause HF radio blackouts', 'Check before HF or satellite operations'] },
|
||||||
|
tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] },
|
||||||
|
spystations: { title: 'Spy Stations', icon: '🕵️', hardware: 'RTL-SDR + HF antenna', description: 'Database of known number stations, military, and diplomatic HF signals.', whatToExpect: 'Scheduled broadcasts, frequency database, tune-to links.', tips: ['Numbers stations often broadcast on the hour', 'Use Spectrum Waterfall to tune directly', 'STANAG and HF mil signals are common'] },
|
||||||
|
websdr: { title: 'WebSDR', icon: '🌐', hardware: 'None (uses remote SDR servers)', description: 'Access remote WebSDR receivers worldwide for HF shortwave listening.', whatToExpect: 'Live audio from global HF receivers, waterfall display.', tips: ['websdr.org lists available servers', 'Good for HF when local antenna is lacking', 'Use in-app player for seamless experience'] },
|
||||||
|
subghz: { title: 'SubGHz Transceiver', icon: '📡', hardware: 'HackRF One', description: 'Transmit and receive sub-GHz RF signals for IoT and industrial protocols.', whatToExpect: 'Raw signal capture, replay, and protocol analysis.', tips: ['Only use on licensed frequencies', 'Capture mode records raw IQ for replay', 'Common: garage doors, keyfobs, 315/433/868/915 MHz'] },
|
||||||
|
rtlamr: { title: 'Utility Meter Reader', icon: '⚡', hardware: 'RTL-SDR dongle', description: 'Reads AMI/AMR smart utility meter broadcasts via rtlamr.', whatToExpect: 'Meter IDs, consumption readings, interval data.', tips: ['Most meters broadcast on 915 MHz', 'MSG types 5, 7, 13, 21 most common', 'Consumption data is read-only public broadcast'] },
|
||||||
|
waterfall: { title: 'Spectrum Waterfall', icon: '🌊', hardware: 'RTL-SDR or HackRF (WebSocket)', description: 'Full-screen real-time FFT spectrum waterfall display.', whatToExpect: 'Color-coded signal intensity scrolling over time.', tips: ['Turbo palette has best contrast for weak signals', 'Peak hold shows max power in red', 'Hover over waterfall to see frequency'] },
|
||||||
|
};
|
||||||
|
|
||||||
|
function show(mode) {
|
||||||
|
const data = CONTENT[mode];
|
||||||
|
const modal = document.getElementById('cheatSheetModal');
|
||||||
|
const content = document.getElementById('cheatSheetContent');
|
||||||
|
if (!modal || !content) return;
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
content.innerHTML = `<p style="color:var(--text-dim); font-family:var(--font-mono);">No cheat sheet for: ${mode}</p>`;
|
||||||
|
} else {
|
||||||
|
content.innerHTML = `
|
||||||
|
<div style="font-family:var(--font-mono, monospace);">
|
||||||
|
<div style="font-size:24px; margin-bottom:4px;">${data.icon}</div>
|
||||||
|
<h2 style="margin:0 0 8px; font-size:16px; color:var(--accent-cyan, #4aa3ff);">${data.title}</h2>
|
||||||
|
<div style="font-size:11px; color:var(--text-dim); margin-bottom:12px; border-bottom:1px solid rgba(255,255,255,0.08); padding-bottom:8px;">
|
||||||
|
Hardware: <span style="color:var(--text-secondary);">${data.hardware}</span>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:12px; color:var(--text-secondary); margin:0 0 12px;">${data.description}</p>
|
||||||
|
<div style="margin-bottom:12px;">
|
||||||
|
<div style="font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.08em; color:var(--text-dim); margin-bottom:4px;">What to expect</div>
|
||||||
|
<p style="font-size:12px; color:var(--text-secondary); margin:0;">${data.whatToExpect}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.08em; color:var(--text-dim); margin-bottom:6px;">Tips</div>
|
||||||
|
<ul style="margin:0; padding-left:16px; display:flex; flex-direction:column; gap:4px;">
|
||||||
|
${data.tips.map(t => `<li style="font-size:11px; color:var(--text-secondary);">${t}</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
const modal = document.getElementById('cheatSheetModal');
|
||||||
|
if (modal) modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showForCurrentMode() {
|
||||||
|
const mode = document.body.getAttribute('data-mode');
|
||||||
|
if (mode) show(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { show, hide, showForCurrentMode };
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.CheatSheets = CheatSheets;
|
||||||
@@ -12,8 +12,8 @@ const CommandPalette = (function() {
|
|||||||
{ mode: 'pager', label: 'Pager' },
|
{ mode: 'pager', label: 'Pager' },
|
||||||
{ mode: 'sensor', label: '433MHz Sensors' },
|
{ mode: 'sensor', label: '433MHz Sensors' },
|
||||||
{ mode: 'rtlamr', label: 'Meters' },
|
{ mode: 'rtlamr', label: 'Meters' },
|
||||||
{ mode: 'listening', label: 'Listening Post' },
|
|
||||||
{ mode: 'subghz', label: 'SubGHz' },
|
{ mode: 'subghz', label: 'SubGHz' },
|
||||||
|
{ mode: 'waterfall', label: 'Spectrum Waterfall' },
|
||||||
{ mode: 'aprs', label: 'APRS' },
|
{ mode: 'aprs', label: 'APRS' },
|
||||||
{ mode: 'wifi', label: 'WiFi Scanner' },
|
{ mode: 'wifi', label: 'WiFi Scanner' },
|
||||||
{ mode: 'bluetooth', label: 'Bluetooth Scanner' },
|
{ mode: 'bluetooth', label: 'Bluetooth Scanner' },
|
||||||
@@ -24,9 +24,7 @@ const CommandPalette = (function() {
|
|||||||
{ mode: 'sstv_general', label: 'HF SSTV' },
|
{ mode: 'sstv_general', label: 'HF SSTV' },
|
||||||
{ mode: 'gps', label: 'GPS' },
|
{ mode: 'gps', label: 'GPS' },
|
||||||
{ mode: 'meshtastic', label: 'Meshtastic' },
|
{ mode: 'meshtastic', label: 'Meshtastic' },
|
||||||
{ mode: 'dmr', label: 'Digital Voice' },
|
|
||||||
{ mode: 'websdr', label: 'WebSDR' },
|
{ mode: 'websdr', label: 'WebSDR' },
|
||||||
{ mode: 'analytics', label: 'Analytics' },
|
|
||||||
{ mode: 'spaceweather', label: 'Space Weather' },
|
{ mode: 'spaceweather', label: 'Space Weather' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -189,13 +187,39 @@ const CommandPalette = (function() {
|
|||||||
title: 'View Aircraft Dashboard',
|
title: 'View Aircraft Dashboard',
|
||||||
description: 'Open dedicated ADS-B dashboard page',
|
description: 'Open dedicated ADS-B dashboard page',
|
||||||
keyword: 'aircraft adsb dashboard',
|
keyword: 'aircraft adsb dashboard',
|
||||||
run: () => { window.location.href = '/adsb/dashboard'; }
|
run: () => {
|
||||||
|
if (window.InterceptNavPerf && typeof window.InterceptNavPerf.markStart === 'function') {
|
||||||
|
window.InterceptNavPerf.markStart({
|
||||||
|
targetPath: '/adsb/dashboard',
|
||||||
|
trigger: 'command-palette',
|
||||||
|
sourceMode: (typeof currentMode === 'string' && currentMode) ? currentMode : null,
|
||||||
|
activeScans: (typeof getActiveScanSummary === 'function') ? getActiveScanSummary() : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof stopActiveLocalScansForNavigation === 'function') {
|
||||||
|
stopActiveLocalScansForNavigation();
|
||||||
|
}
|
||||||
|
window.location.href = '/adsb/dashboard';
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'View Vessel Dashboard',
|
title: 'View Vessel Dashboard',
|
||||||
description: 'Open dedicated AIS dashboard page',
|
description: 'Open dedicated AIS dashboard page',
|
||||||
keyword: 'vessel ais dashboard',
|
keyword: 'vessel ais dashboard',
|
||||||
run: () => { window.location.href = '/ais/dashboard'; }
|
run: () => {
|
||||||
|
if (window.InterceptNavPerf && typeof window.InterceptNavPerf.markStart === 'function') {
|
||||||
|
window.InterceptNavPerf.markStart({
|
||||||
|
targetPath: '/ais/dashboard',
|
||||||
|
trigger: 'command-palette',
|
||||||
|
sourceMode: (typeof currentMode === 'string' && currentMode) ? currentMode : null,
|
||||||
|
activeScans: (typeof getActiveScanSummary === 'function') ? getActiveScanSummary() : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof stopActiveLocalScansForNavigation === 'function') {
|
||||||
|
stopActiveLocalScansForNavigation();
|
||||||
|
}
|
||||||
|
window.location.href = '/ais/dashboard';
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Kill All Running Processes',
|
title: 'Kill All Running Processes',
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ const FirstRunSetup = (function() {
|
|||||||
['pager', 'Pager'],
|
['pager', 'Pager'],
|
||||||
['sensor', '433MHz'],
|
['sensor', '433MHz'],
|
||||||
['rtlamr', 'Meters'],
|
['rtlamr', 'Meters'],
|
||||||
['listening', 'Listening Post'],
|
['waterfall', 'Waterfall'],
|
||||||
['wifi', 'WiFi'],
|
['wifi', 'WiFi'],
|
||||||
['bluetooth', 'Bluetooth'],
|
['bluetooth', 'Bluetooth'],
|
||||||
['bt_locate', 'BT Locate'],
|
['bt_locate', 'BT Locate'],
|
||||||
@@ -139,7 +139,6 @@ const FirstRunSetup = (function() {
|
|||||||
['sstv', 'ISS SSTV'],
|
['sstv', 'ISS SSTV'],
|
||||||
['weathersat', 'Weather Sat'],
|
['weathersat', 'Weather Sat'],
|
||||||
['sstv_general', 'HF SSTV'],
|
['sstv_general', 'HF SSTV'],
|
||||||
['analytics', 'Analytics'],
|
|
||||||
];
|
];
|
||||||
for (const [value, label] of modes) {
|
for (const [value, label] of modes) {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
@@ -150,7 +149,11 @@ const FirstRunSetup = (function() {
|
|||||||
|
|
||||||
const savedDefaultMode = localStorage.getItem(DEFAULT_MODE_KEY);
|
const savedDefaultMode = localStorage.getItem(DEFAULT_MODE_KEY);
|
||||||
if (savedDefaultMode) {
|
if (savedDefaultMode) {
|
||||||
modeSelectEl.value = savedDefaultMode;
|
const normalizedMode = savedDefaultMode === 'listening' ? 'waterfall' : savedDefaultMode;
|
||||||
|
modeSelectEl.value = normalizedMode;
|
||||||
|
if (normalizedMode !== savedDefaultMode) {
|
||||||
|
localStorage.setItem(DEFAULT_MODE_KEY, normalizedMode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actionsEl.appendChild(modeSelectEl);
|
actionsEl.appendChild(modeSelectEl);
|
||||||
|
|||||||
@@ -18,6 +18,18 @@
|
|||||||
if (menuLink) {
|
if (menuLink) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
try {
|
||||||
|
const target = new URL(menuLink.href, window.location.href);
|
||||||
|
if (window.InterceptNavPerf && typeof window.InterceptNavPerf.markStart === 'function') {
|
||||||
|
window.InterceptNavPerf.markStart({
|
||||||
|
targetPath: target.pathname,
|
||||||
|
trigger: 'global-nav',
|
||||||
|
sourceMode: document.body?.getAttribute('data-mode') || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore malformed link targets.
|
||||||
|
}
|
||||||
window.location.href = menuLink.href;
|
window.location.href = menuLink.href;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/* INTERCEPT Keyboard Shortcuts — global hotkey handler + help modal */
|
||||||
|
const KeyboardShortcuts = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const GUARD_SELECTOR = 'input, textarea, select, [contenteditable], .CodeMirror *';
|
||||||
|
let _handler = null;
|
||||||
|
|
||||||
|
function _handle(e) {
|
||||||
|
if (e.target.matches(GUARD_SELECTOR)) return;
|
||||||
|
|
||||||
|
if (e.altKey) {
|
||||||
|
switch (e.code) {
|
||||||
|
case 'KeyW': e.preventDefault(); window.switchMode && switchMode('waterfall'); break;
|
||||||
|
case 'KeyM': e.preventDefault(); window.VoiceAlerts && VoiceAlerts.toggleMute(); break;
|
||||||
|
case 'KeyS': e.preventDefault(); _toggleSidebar(); break;
|
||||||
|
case 'KeyK': e.preventDefault(); showHelp(); break;
|
||||||
|
case 'KeyC': e.preventDefault(); window.CheatSheets && CheatSheets.showForCurrentMode(); break;
|
||||||
|
default:
|
||||||
|
if (e.code >= 'Digit1' && e.code <= 'Digit9') {
|
||||||
|
e.preventDefault();
|
||||||
|
_switchToNthMode(parseInt(e.code.replace('Digit', '')) - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!e.ctrlKey && !e.metaKey) {
|
||||||
|
if (e.key === '?') { showHelp(); }
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
const kbModal = document.getElementById('kbShortcutsModal');
|
||||||
|
if (kbModal && kbModal.style.display !== 'none') { hideHelp(); return; }
|
||||||
|
const csModal = document.getElementById('cheatSheetModal');
|
||||||
|
if (csModal && csModal.style.display !== 'none') {
|
||||||
|
window.CheatSheets && CheatSheets.hide(); return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _toggleSidebar() {
|
||||||
|
const mc = document.querySelector('.main-content');
|
||||||
|
if (mc) mc.classList.toggle('sidebar-collapsed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _switchToNthMode(n) {
|
||||||
|
if (!window.interceptModeCatalog) return;
|
||||||
|
const mode = document.body.getAttribute('data-mode');
|
||||||
|
if (!mode) return;
|
||||||
|
const catalog = window.interceptModeCatalog;
|
||||||
|
const entry = catalog[mode];
|
||||||
|
if (!entry) return;
|
||||||
|
const groupModes = Object.keys(catalog).filter(k => catalog[k].group === entry.group);
|
||||||
|
if (groupModes[n]) window.switchMode && switchMode(groupModes[n]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showHelp() {
|
||||||
|
const modal = document.getElementById('kbShortcutsModal');
|
||||||
|
if (modal) modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideHelp() {
|
||||||
|
const modal = document.getElementById('kbShortcutsModal');
|
||||||
|
if (modal) modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (_handler) document.removeEventListener('keydown', _handler);
|
||||||
|
_handler = _handle;
|
||||||
|
document.addEventListener('keydown', _handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init, showHelp, hideHelp };
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.KeyboardShortcuts = KeyboardShortcuts;
|
||||||
@@ -114,13 +114,7 @@ const RecordingUI = (function() {
|
|||||||
|
|
||||||
function openReplay(sessionId) {
|
function openReplay(sessionId) {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
localStorage.setItem('analyticsReplaySession', sessionId);
|
window.open(`/recordings/${sessionId}/download`, '_blank');
|
||||||
if (typeof hideSettings === 'function') hideSettings();
|
|
||||||
if (typeof switchMode === 'function') {
|
|
||||||
switchMode('analytics', { updateUrl: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.location.href = '/?mode=analytics';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const RunState = (function() {
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const REFRESH_MS = 5000;
|
const REFRESH_MS = 5000;
|
||||||
const CHIP_MODES = ['pager', 'sensor', 'wifi', 'bluetooth', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'dsc', 'dmr', 'subghz'];
|
const CHIP_MODES = ['pager', 'sensor', 'wifi', 'bluetooth', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'dsc', 'subghz'];
|
||||||
const MODE_ALIASES = {
|
const MODE_ALIASES = {
|
||||||
bt: 'bluetooth',
|
bt: 'bluetooth',
|
||||||
bt_locate: 'bluetooth',
|
bt_locate: 'bluetooth',
|
||||||
@@ -21,7 +21,6 @@ const RunState = (function() {
|
|||||||
vdl2: 'VDL2',
|
vdl2: 'VDL2',
|
||||||
aprs: 'APRS',
|
aprs: 'APRS',
|
||||||
dsc: 'DSC',
|
dsc: 'DSC',
|
||||||
dmr: 'DMR',
|
|
||||||
subghz: 'SubGHz',
|
subghz: 'SubGHz',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -181,7 +180,6 @@ const RunState = (function() {
|
|||||||
if (normalized.includes('aprs')) return 'aprs';
|
if (normalized.includes('aprs')) return 'aprs';
|
||||||
if (normalized.includes('dsc')) return 'dsc';
|
if (normalized.includes('dsc')) return 'dsc';
|
||||||
if (normalized.includes('subghz')) return 'subghz';
|
if (normalized.includes('subghz')) return 'subghz';
|
||||||
if (normalized.includes('dmr')) return 'dmr';
|
|
||||||
if (normalized.includes('433')) return 'sensor';
|
if (normalized.includes('433')) return 'sensor';
|
||||||
return 'pager';
|
return 'pager';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ const Settings = {
|
|||||||
// Default settings
|
// Default settings
|
||||||
defaults: {
|
defaults: {
|
||||||
'offline.enabled': false,
|
'offline.enabled': false,
|
||||||
'offline.assets_source': 'cdn',
|
'offline.assets_source': 'local',
|
||||||
'offline.fonts_source': 'cdn',
|
'offline.fonts_source': 'local',
|
||||||
'offline.tile_provider': 'cartodb_dark_cyan',
|
'offline.tile_provider': 'cartodb_dark_cyan',
|
||||||
'offline.tile_server_url': ''
|
'offline.tile_server_url': ''
|
||||||
},
|
},
|
||||||
@@ -98,24 +98,15 @@ const Settings = {
|
|||||||
localStorage.setItem('intercept_map_theme_pref', pref);
|
localStorage.setItem('intercept_map_theme_pref', pref);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether Cyber map theme should be considered active globally.
|
|
||||||
* @param {Object} [config]
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
_isCyberThemeEnabled(config) {
|
|
||||||
const resolvedConfig = config || this.getTileConfig();
|
|
||||||
return this._getMapThemeClass(resolvedConfig) === 'map-theme-cyber';
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle root class used for hard global Leaflet theming.
|
* Toggle root class used for hard global Leaflet theming.
|
||||||
* @param {Object} [config]
|
* @param {Object} [config]
|
||||||
*/
|
*/
|
||||||
_syncRootMapThemeClass(config) {
|
_syncRootMapThemeClass(config) {
|
||||||
if (typeof document === 'undefined' || !document.documentElement) return;
|
if (typeof document === 'undefined' || !document.documentElement) return;
|
||||||
const enabled = this._isCyberThemeEnabled(config);
|
const resolvedConfig = config || this.getTileConfig();
|
||||||
document.documentElement.classList.toggle('map-cyber-enabled', enabled);
|
const themeClass = this._getMapThemeClass(resolvedConfig);
|
||||||
|
document.documentElement.classList.toggle('map-cyber-enabled', themeClass === 'map-theme-cyber');
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -381,17 +372,19 @@ const Settings = {
|
|||||||
|
|
||||||
container.classList.add(themeClass);
|
container.classList.add(themeClass);
|
||||||
|
|
||||||
if (container.style) {
|
if (themeClass === 'map-theme-cyber') {
|
||||||
container.style.background = '#020813';
|
if (container.style) {
|
||||||
}
|
container.style.background = '#020813';
|
||||||
if (tilePane && tilePane.style) {
|
}
|
||||||
tilePane.style.filter = 'sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08)';
|
if (tilePane && tilePane.style) {
|
||||||
tilePane.style.opacity = '1';
|
tilePane.style.filter = 'sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08)';
|
||||||
tilePane.style.willChange = 'filter';
|
tilePane.style.opacity = '1';
|
||||||
|
tilePane.style.willChange = 'filter';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grid/glow overlays are rendered via CSS pseudo elements on
|
// Map overlays are rendered via CSS pseudo elements on
|
||||||
// `html.map-cyber-enabled .leaflet-container` for consistent stacking.
|
// `html.map-*-enabled .leaflet-container` for consistent stacking.
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1265,6 +1258,7 @@ function switchSettingsTab(tabName) {
|
|||||||
} else if (tabName === 'location') {
|
} else if (tabName === 'location') {
|
||||||
loadObserverLocation();
|
loadObserverLocation();
|
||||||
} else if (tabName === 'alerts') {
|
} else if (tabName === 'alerts') {
|
||||||
|
loadVoiceAlertConfig();
|
||||||
if (typeof AlertCenter !== 'undefined') {
|
if (typeof AlertCenter !== 'undefined') {
|
||||||
AlertCenter.loadFeed();
|
AlertCenter.loadFeed();
|
||||||
}
|
}
|
||||||
@@ -1277,6 +1271,61 @@ function switchSettingsTab(tabName) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load voice alert configuration into Settings > Alerts tab
|
||||||
|
*/
|
||||||
|
function loadVoiceAlertConfig() {
|
||||||
|
if (typeof VoiceAlerts === 'undefined') return;
|
||||||
|
const cfg = VoiceAlerts.getConfig();
|
||||||
|
|
||||||
|
const pager = document.getElementById('voiceCfgPager');
|
||||||
|
const tscm = document.getElementById('voiceCfgTscm');
|
||||||
|
const tracker = document.getElementById('voiceCfgTracker');
|
||||||
|
const squawk = document.getElementById('voiceCfgSquawk');
|
||||||
|
const rate = document.getElementById('voiceCfgRate');
|
||||||
|
const pitch = document.getElementById('voiceCfgPitch');
|
||||||
|
const rateVal = document.getElementById('voiceCfgRateVal');
|
||||||
|
const pitchVal = document.getElementById('voiceCfgPitchVal');
|
||||||
|
|
||||||
|
if (pager) pager.checked = cfg.streams.pager !== false;
|
||||||
|
if (tscm) tscm.checked = cfg.streams.tscm !== false;
|
||||||
|
if (tracker) tracker.checked = cfg.streams.bluetooth !== false;
|
||||||
|
if (squawk) squawk.checked = cfg.streams.squawks !== false;
|
||||||
|
if (rate) rate.value = cfg.rate;
|
||||||
|
if (pitch) pitch.value = cfg.pitch;
|
||||||
|
if (rateVal) rateVal.textContent = cfg.rate;
|
||||||
|
if (pitchVal) pitchVal.textContent = cfg.pitch;
|
||||||
|
|
||||||
|
// Populate voice dropdown
|
||||||
|
VoiceAlerts.getAvailableVoices().then(function (voices) {
|
||||||
|
var sel = document.getElementById('voiceCfgVoice');
|
||||||
|
if (!sel) return;
|
||||||
|
sel.innerHTML = '<option value="">Default</option>' +
|
||||||
|
voices.filter(function (v) { return v.lang.startsWith('en'); }).map(function (v) {
|
||||||
|
return '<option value="' + v.name + '"' + (v.name === cfg.voiceName ? ' selected' : '') + '>' + v.name + '</option>';
|
||||||
|
}).join('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveVoiceAlertConfig() {
|
||||||
|
if (typeof VoiceAlerts === 'undefined') return;
|
||||||
|
VoiceAlerts.setConfig({
|
||||||
|
rate: parseFloat(document.getElementById('voiceCfgRate')?.value) || 1.1,
|
||||||
|
pitch: parseFloat(document.getElementById('voiceCfgPitch')?.value) || 0.9,
|
||||||
|
voiceName: document.getElementById('voiceCfgVoice')?.value || '',
|
||||||
|
streams: {
|
||||||
|
pager: !!document.getElementById('voiceCfgPager')?.checked,
|
||||||
|
tscm: !!document.getElementById('voiceCfgTscm')?.checked,
|
||||||
|
bluetooth: !!document.getElementById('voiceCfgTracker')?.checked,
|
||||||
|
squawks: !!document.getElementById('voiceCfgSquawk')?.checked,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function testVoiceAlert() {
|
||||||
|
if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.testVoice();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load API key status into the API Keys settings tab
|
* Load API key status into the API Keys settings tab
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
/* INTERCEPT Voice Alerts — Web Speech API queue with priority system */
|
||||||
|
const VoiceAlerts = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const PRIORITY = { LOW: 0, MEDIUM: 1, HIGH: 2 };
|
||||||
|
let _enabled = true;
|
||||||
|
let _muted = false;
|
||||||
|
let _queue = [];
|
||||||
|
let _speaking = false;
|
||||||
|
let _sources = {};
|
||||||
|
const STORAGE_KEY = 'intercept-voice-muted';
|
||||||
|
const CONFIG_KEY = 'intercept-voice-config';
|
||||||
|
const RATE_MIN = 0.5;
|
||||||
|
const RATE_MAX = 2.0;
|
||||||
|
const PITCH_MIN = 0.5;
|
||||||
|
const PITCH_MAX = 2.0;
|
||||||
|
|
||||||
|
// Default config
|
||||||
|
let _config = {
|
||||||
|
rate: 1.1,
|
||||||
|
pitch: 0.9,
|
||||||
|
voiceName: '',
|
||||||
|
streams: { pager: true, tscm: true, bluetooth: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
function _toNumberInRange(value, fallback, min, max) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _normalizeConfig() {
|
||||||
|
_config.rate = _toNumberInRange(_config.rate, 1.1, RATE_MIN, RATE_MAX);
|
||||||
|
_config.pitch = _toNumberInRange(_config.pitch, 0.9, PITCH_MIN, PITCH_MAX);
|
||||||
|
_config.voiceName = typeof _config.voiceName === 'string' ? _config.voiceName : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isSpeechSupported() {
|
||||||
|
return !!(window.speechSynthesis && typeof window.SpeechSynthesisUtterance !== 'undefined');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showVoiceToast(title, message, type) {
|
||||||
|
if (typeof window.showAppToast === 'function') {
|
||||||
|
window.showAppToast(title, message, type || 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _loadConfig() {
|
||||||
|
_muted = localStorage.getItem(STORAGE_KEY) === 'true';
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(CONFIG_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
_config.rate = parsed.rate ?? _config.rate;
|
||||||
|
_config.pitch = parsed.pitch ?? _config.pitch;
|
||||||
|
_config.voiceName = parsed.voiceName ?? _config.voiceName;
|
||||||
|
if (parsed.streams) {
|
||||||
|
Object.assign(_config.streams, parsed.streams);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
_normalizeConfig();
|
||||||
|
_updateMuteButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateMuteButton() {
|
||||||
|
const btn = document.getElementById('voiceMuteBtn');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.classList.toggle('voice-muted', _muted);
|
||||||
|
btn.title = _muted ? 'Unmute voice alerts' : 'Mute voice alerts';
|
||||||
|
btn.style.opacity = _muted ? '0.4' : '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getVoice() {
|
||||||
|
if (!_config.voiceName) return null;
|
||||||
|
const voices = window.speechSynthesis ? speechSynthesis.getVoices() : [];
|
||||||
|
return voices.find(v => v.name === _config.voiceName) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _createUtterance(text) {
|
||||||
|
const utt = new SpeechSynthesisUtterance(text);
|
||||||
|
utt.rate = _toNumberInRange(_config.rate, 1.1, RATE_MIN, RATE_MAX);
|
||||||
|
utt.pitch = _toNumberInRange(_config.pitch, 0.9, PITCH_MIN, PITCH_MAX);
|
||||||
|
const voice = _getVoice();
|
||||||
|
if (voice) utt.voice = voice;
|
||||||
|
return utt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function speak(text, priority) {
|
||||||
|
if (priority === undefined) priority = PRIORITY.MEDIUM;
|
||||||
|
if (!_enabled || _muted) return;
|
||||||
|
if (!window.speechSynthesis) return;
|
||||||
|
if (priority === PRIORITY.LOW && _speaking) return;
|
||||||
|
if (priority === PRIORITY.HIGH && _speaking) {
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
_queue = [];
|
||||||
|
_speaking = false;
|
||||||
|
}
|
||||||
|
_queue.push({ text, priority });
|
||||||
|
if (!_speaking) _dequeue();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _dequeue() {
|
||||||
|
if (_queue.length === 0) { _speaking = false; return; }
|
||||||
|
_speaking = true;
|
||||||
|
const item = _queue.shift();
|
||||||
|
const utt = _createUtterance(item.text);
|
||||||
|
utt.onend = () => { _speaking = false; _dequeue(); };
|
||||||
|
utt.onerror = () => { _speaking = false; _dequeue(); };
|
||||||
|
window.speechSynthesis.speak(utt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMute() {
|
||||||
|
_muted = !_muted;
|
||||||
|
localStorage.setItem(STORAGE_KEY, _muted ? 'true' : 'false');
|
||||||
|
_updateMuteButton();
|
||||||
|
if (_muted && window.speechSynthesis) window.speechSynthesis.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _openStream(url, handler, key) {
|
||||||
|
if (_sources[key]) return;
|
||||||
|
const es = new EventSource(url);
|
||||||
|
es.onmessage = handler;
|
||||||
|
es.onerror = () => { es.close(); delete _sources[key]; };
|
||||||
|
_sources[key] = es;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _startStreams() {
|
||||||
|
if (!_enabled) return;
|
||||||
|
|
||||||
|
// Pager stream
|
||||||
|
if (_config.streams.pager) {
|
||||||
|
_openStream('/stream', (ev) => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(ev.data);
|
||||||
|
if (d.address && d.message) {
|
||||||
|
speak(`Pager message to ${d.address}: ${String(d.message).slice(0, 60)}`, PRIORITY.MEDIUM);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}, 'pager');
|
||||||
|
}
|
||||||
|
|
||||||
|
// TSCM stream
|
||||||
|
if (_config.streams.tscm) {
|
||||||
|
_openStream('/tscm/sweep/stream', (ev) => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(ev.data);
|
||||||
|
if (d.threat_level && d.description) {
|
||||||
|
speak(`TSCM alert: ${d.threat_level} — ${d.description}`, PRIORITY.HIGH);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}, 'tscm');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bluetooth stream — tracker detection only
|
||||||
|
if (_config.streams.bluetooth) {
|
||||||
|
_openStream('/api/bluetooth/stream', (ev) => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(ev.data);
|
||||||
|
if (d.service_data && d.service_data.tracker_type) {
|
||||||
|
speak(`Tracker detected: ${d.service_data.tracker_type}`, PRIORITY.HIGH);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}, 'bluetooth');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stopStreams() {
|
||||||
|
Object.values(_sources).forEach(es => { try { es.close(); } catch (_) {} });
|
||||||
|
_sources = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
_loadConfig();
|
||||||
|
if (_isSpeechSupported()) {
|
||||||
|
// Prime voices list early so user-triggered test calls are less likely to be silent.
|
||||||
|
speechSynthesis.getVoices();
|
||||||
|
}
|
||||||
|
_startStreams();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEnabled(val) {
|
||||||
|
_enabled = val;
|
||||||
|
if (!val) {
|
||||||
|
_stopStreams();
|
||||||
|
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
||||||
|
} else {
|
||||||
|
_startStreams();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config API (used by Ops Center voice config panel) ─────────────
|
||||||
|
|
||||||
|
function getConfig() {
|
||||||
|
return JSON.parse(JSON.stringify(_config));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConfig(cfg) {
|
||||||
|
if (cfg.rate !== undefined) _config.rate = _toNumberInRange(cfg.rate, _config.rate, RATE_MIN, RATE_MAX);
|
||||||
|
if (cfg.pitch !== undefined) _config.pitch = _toNumberInRange(cfg.pitch, _config.pitch, PITCH_MIN, PITCH_MAX);
|
||||||
|
if (cfg.voiceName !== undefined) _config.voiceName = cfg.voiceName;
|
||||||
|
if (cfg.streams) Object.assign(_config.streams, cfg.streams);
|
||||||
|
_normalizeConfig();
|
||||||
|
localStorage.setItem(CONFIG_KEY, JSON.stringify(_config));
|
||||||
|
// Restart streams to apply per-stream toggle changes
|
||||||
|
_stopStreams();
|
||||||
|
_startStreams();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvailableVoices() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (!window.speechSynthesis) { resolve([]); return; }
|
||||||
|
let voices = speechSynthesis.getVoices();
|
||||||
|
if (voices.length > 0) { resolve(voices); return; }
|
||||||
|
speechSynthesis.onvoiceschanged = () => {
|
||||||
|
resolve(speechSynthesis.getVoices());
|
||||||
|
};
|
||||||
|
// Timeout fallback
|
||||||
|
setTimeout(() => resolve(speechSynthesis.getVoices()), 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function testVoice(text) {
|
||||||
|
if (!_isSpeechSupported()) {
|
||||||
|
_showVoiceToast('Voice Unavailable', 'This browser does not support speech synthesis.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make the test immediate and recover from a paused/stalled synthesis engine.
|
||||||
|
try {
|
||||||
|
speechSynthesis.getVoices();
|
||||||
|
if (speechSynthesis.paused) speechSynthesis.resume();
|
||||||
|
speechSynthesis.cancel();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
const utt = _createUtterance(text || 'Voice alert test. All systems nominal.');
|
||||||
|
let started = false;
|
||||||
|
utt.onstart = () => { started = true; };
|
||||||
|
utt.onerror = () => {
|
||||||
|
_showVoiceToast('Voice Test Failed', 'Speech synthesis failed to start. Check browser audio output.', 'warning');
|
||||||
|
};
|
||||||
|
speechSynthesis.speak(utt);
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (!started && !speechSynthesis.speaking && !speechSynthesis.pending) {
|
||||||
|
_showVoiceToast('No Voice Output', 'Test speech did not play. Verify browser audio and selected voice.', 'warning');
|
||||||
|
}
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init, speak, toggleMute, setEnabled, getConfig, setConfig, getAvailableVoices, testVoice, PRIORITY };
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.VoiceAlerts = VoiceAlerts;
|
||||||
@@ -1,549 +0,0 @@
|
|||||||
/**
|
|
||||||
* Analytics Dashboard Module
|
|
||||||
* Cross-mode summary, sparklines, alerts, correlations, target view, and replay.
|
|
||||||
*/
|
|
||||||
const Analytics = (function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
let refreshTimer = null;
|
|
||||||
let replayTimer = null;
|
|
||||||
let replaySessions = [];
|
|
||||||
let replayEvents = [];
|
|
||||||
let replayIndex = 0;
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
refresh();
|
|
||||||
loadReplaySessions();
|
|
||||||
if (!refreshTimer) {
|
|
||||||
refreshTimer = setInterval(refresh, 5000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function destroy() {
|
|
||||||
if (refreshTimer) {
|
|
||||||
clearInterval(refreshTimer);
|
|
||||||
refreshTimer = null;
|
|
||||||
}
|
|
||||||
pauseReplay();
|
|
||||||
}
|
|
||||||
|
|
||||||
function refresh() {
|
|
||||||
Promise.all([
|
|
||||||
fetch('/analytics/summary').then(r => r.json()).catch(() => null),
|
|
||||||
fetch('/analytics/activity').then(r => r.json()).catch(() => null),
|
|
||||||
fetch('/analytics/insights').then(r => r.json()).catch(() => null),
|
|
||||||
fetch('/analytics/patterns').then(r => r.json()).catch(() => null),
|
|
||||||
fetch('/alerts/events?limit=20').then(r => r.json()).catch(() => null),
|
|
||||||
fetch('/correlation').then(r => r.json()).catch(() => null),
|
|
||||||
fetch('/analytics/geofences').then(r => r.json()).catch(() => null),
|
|
||||||
]).then(([summary, activity, insights, patterns, alerts, correlations, geofences]) => {
|
|
||||||
if (summary) renderSummary(summary);
|
|
||||||
if (activity) renderSparklines(activity.sparklines || {});
|
|
||||||
if (insights) renderInsights(insights);
|
|
||||||
if (patterns) renderPatterns(patterns.patterns || []);
|
|
||||||
if (alerts) renderAlerts(alerts.events || []);
|
|
||||||
if (correlations) renderCorrelations(correlations);
|
|
||||||
if (geofences) renderGeofences(geofences.zones || []);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSummary(data) {
|
|
||||||
const counts = data.counts || {};
|
|
||||||
_setText('analyticsCountAdsb', counts.adsb || 0);
|
|
||||||
_setText('analyticsCountAis', counts.ais || 0);
|
|
||||||
_setText('analyticsCountWifi', counts.wifi || 0);
|
|
||||||
_setText('analyticsCountBt', counts.bluetooth || 0);
|
|
||||||
_setText('analyticsCountDsc', counts.dsc || 0);
|
|
||||||
_setText('analyticsCountAcars', counts.acars || 0);
|
|
||||||
_setText('analyticsCountVdl2', counts.vdl2 || 0);
|
|
||||||
_setText('analyticsCountAprs', counts.aprs || 0);
|
|
||||||
_setText('analyticsCountMesh', counts.meshtastic || 0);
|
|
||||||
|
|
||||||
const health = data.health || {};
|
|
||||||
const container = document.getElementById('analyticsHealth');
|
|
||||||
if (container) {
|
|
||||||
let html = '';
|
|
||||||
const modeLabels = {
|
|
||||||
pager: 'Pager', sensor: '433MHz', adsb: 'ADS-B', ais: 'AIS',
|
|
||||||
acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', wifi: 'WiFi',
|
|
||||||
bluetooth: 'BT', dsc: 'DSC', meshtastic: 'Mesh'
|
|
||||||
};
|
|
||||||
for (const [mode, info] of Object.entries(health)) {
|
|
||||||
if (mode === 'sdr_devices') continue;
|
|
||||||
const running = info && info.running;
|
|
||||||
const label = modeLabels[mode] || mode;
|
|
||||||
html += '<div class="health-item"><span class="health-dot' + (running ? ' running' : '') + '"></span>' + _esc(label) + '</div>';
|
|
||||||
}
|
|
||||||
container.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
const squawks = data.squawks || [];
|
|
||||||
const sqSection = document.getElementById('analyticsSquawkSection');
|
|
||||||
const sqList = document.getElementById('analyticsSquawkList');
|
|
||||||
if (sqSection && sqList) {
|
|
||||||
if (squawks.length > 0) {
|
|
||||||
sqSection.style.display = '';
|
|
||||||
sqList.innerHTML = squawks.map(s =>
|
|
||||||
'<div class="squawk-item"><strong>' + _esc(s.squawk) + '</strong> ' +
|
|
||||||
_esc(s.meaning) + ' - ' + _esc(s.callsign || s.icao) + '</div>'
|
|
||||||
).join('');
|
|
||||||
} else {
|
|
||||||
sqSection.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSparklines(sparklines) {
|
|
||||||
const map = {
|
|
||||||
adsb: 'analyticsSparkAdsb',
|
|
||||||
ais: 'analyticsSparkAis',
|
|
||||||
wifi: 'analyticsSparkWifi',
|
|
||||||
bluetooth: 'analyticsSparkBt',
|
|
||||||
dsc: 'analyticsSparkDsc',
|
|
||||||
acars: 'analyticsSparkAcars',
|
|
||||||
vdl2: 'analyticsSparkVdl2',
|
|
||||||
aprs: 'analyticsSparkAprs',
|
|
||||||
meshtastic: 'analyticsSparkMesh',
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const [mode, elId] of Object.entries(map)) {
|
|
||||||
const el = document.getElementById(elId);
|
|
||||||
if (!el) continue;
|
|
||||||
const data = sparklines[mode] || [];
|
|
||||||
if (data.length < 2) {
|
|
||||||
el.innerHTML = '';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const max = Math.max(...data, 1);
|
|
||||||
const w = 100;
|
|
||||||
const h = 24;
|
|
||||||
const step = w / (data.length - 1);
|
|
||||||
const points = data.map((v, i) =>
|
|
||||||
(i * step).toFixed(1) + ',' + (h - (v / max) * (h - 2)).toFixed(1)
|
|
||||||
).join(' ');
|
|
||||||
el.innerHTML = '<svg viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none"><polyline points="' + points + '"/></svg>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderInsights(data) {
|
|
||||||
const cards = data.cards || [];
|
|
||||||
const topChanges = data.top_changes || [];
|
|
||||||
const cardsEl = document.getElementById('analyticsInsights');
|
|
||||||
const changesEl = document.getElementById('analyticsTopChanges');
|
|
||||||
|
|
||||||
if (cardsEl) {
|
|
||||||
if (!cards.length) {
|
|
||||||
cardsEl.innerHTML = '<div class="analytics-empty">No insight data available</div>';
|
|
||||||
} else {
|
|
||||||
cardsEl.innerHTML = cards.map(c => {
|
|
||||||
const sev = _esc(c.severity || 'low');
|
|
||||||
const title = _esc(c.title || 'Insight');
|
|
||||||
const value = _esc(c.value || '--');
|
|
||||||
const label = _esc(c.label || '');
|
|
||||||
const detail = _esc(c.detail || '');
|
|
||||||
return '<div class="analytics-insight-card ' + sev + '">' +
|
|
||||||
'<div class="insight-title">' + title + '</div>' +
|
|
||||||
'<div class="insight-value">' + value + '</div>' +
|
|
||||||
'<div class="insight-label">' + label + '</div>' +
|
|
||||||
'<div class="insight-detail">' + detail + '</div>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changesEl) {
|
|
||||||
if (!topChanges.length) {
|
|
||||||
changesEl.innerHTML = '<div class="analytics-empty">No change signals yet</div>';
|
|
||||||
} else {
|
|
||||||
changesEl.innerHTML = topChanges.map(item => {
|
|
||||||
const mode = _esc(item.mode_label || item.mode || '');
|
|
||||||
const deltaRaw = Number(item.delta || 0);
|
|
||||||
const trendClass = deltaRaw > 0 ? 'up' : (deltaRaw < 0 ? 'down' : 'flat');
|
|
||||||
const delta = _esc(item.signed_delta || String(deltaRaw));
|
|
||||||
const recentAvg = _esc(item.recent_avg);
|
|
||||||
const prevAvg = _esc(item.previous_avg);
|
|
||||||
return '<div class="analytics-change-row">' +
|
|
||||||
'<span class="mode">' + mode + '</span>' +
|
|
||||||
'<span class="delta ' + trendClass + '">' + delta + '</span>' +
|
|
||||||
'<span class="avg">avg ' + recentAvg + ' vs ' + prevAvg + '</span>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPatterns(patterns) {
|
|
||||||
const container = document.getElementById('analyticsPatternList');
|
|
||||||
if (!container) return;
|
|
||||||
if (!patterns || patterns.length === 0) {
|
|
||||||
container.innerHTML = '<div class="analytics-empty">No recurring patterns detected</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const modeLabels = {
|
|
||||||
adsb: 'ADS-B', ais: 'AIS', wifi: 'WiFi', bluetooth: 'Bluetooth',
|
|
||||||
dsc: 'DSC', acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', meshtastic: 'Meshtastic',
|
|
||||||
};
|
|
||||||
|
|
||||||
const sorted = patterns
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => (b.confidence || 0) - (a.confidence || 0))
|
|
||||||
.slice(0, 20);
|
|
||||||
|
|
||||||
container.innerHTML = sorted.map(p => {
|
|
||||||
const confidencePct = Math.round((Number(p.confidence || 0)) * 100);
|
|
||||||
const mode = modeLabels[p.mode] || (p.mode || '--').toUpperCase();
|
|
||||||
const period = _humanPeriod(Number(p.period_seconds || 0));
|
|
||||||
const occurrences = Number(p.occurrences || 0);
|
|
||||||
const deviceId = _shortId(p.device_id || '--');
|
|
||||||
return '<div class="analytics-pattern-item">' +
|
|
||||||
'<div class="pattern-main">' +
|
|
||||||
'<span class="pattern-mode">' + _esc(mode) + '</span>' +
|
|
||||||
'<span class="pattern-device">' + _esc(deviceId) + '</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="pattern-meta">' +
|
|
||||||
'<span>Period: ' + _esc(period) + '</span>' +
|
|
||||||
'<span>Hits: ' + _esc(occurrences) + '</span>' +
|
|
||||||
'<span class="pattern-confidence">' + _esc(confidencePct) + '%</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAlerts(events) {
|
|
||||||
const container = document.getElementById('analyticsAlertFeed');
|
|
||||||
if (!container) return;
|
|
||||||
if (!events || events.length === 0) {
|
|
||||||
container.innerHTML = '<div class="analytics-empty">No recent alerts</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.innerHTML = events.slice(0, 20).map(e => {
|
|
||||||
const sev = e.severity || 'medium';
|
|
||||||
const title = e.title || e.event_type || 'Alert';
|
|
||||||
const time = e.created_at ? new Date(e.created_at).toLocaleTimeString() : '';
|
|
||||||
return '<div class="analytics-alert-item">' +
|
|
||||||
'<span class="alert-severity ' + _esc(sev) + '">' + _esc(sev) + '</span>' +
|
|
||||||
'<span>' + _esc(title) + '</span>' +
|
|
||||||
'<span style="margin-left:auto;color:var(--text-dim)">' + _esc(time) + '</span>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCorrelations(data) {
|
|
||||||
const container = document.getElementById('analyticsCorrelations');
|
|
||||||
if (!container) return;
|
|
||||||
const pairs = (data && data.correlations) || [];
|
|
||||||
if (pairs.length === 0) {
|
|
||||||
container.innerHTML = '<div class="analytics-empty">No correlations detected</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.innerHTML = pairs.slice(0, 20).map(p => {
|
|
||||||
const conf = Math.round((p.confidence || 0) * 100);
|
|
||||||
return '<div class="analytics-correlation-pair">' +
|
|
||||||
'<span>' + _esc(p.wifi_mac || '') + '</span>' +
|
|
||||||
'<span style="color:var(--text-dim)">↔</span>' +
|
|
||||||
'<span>' + _esc(p.bt_mac || '') + '</span>' +
|
|
||||||
'<div class="confidence-bar"><div class="confidence-fill" style="width:' + conf + '%"></div></div>' +
|
|
||||||
'<span style="color:var(--text-dim)">' + conf + '%</span>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderGeofences(zones) {
|
|
||||||
const container = document.getElementById('analyticsGeofenceList');
|
|
||||||
if (!container) return;
|
|
||||||
if (!zones || zones.length === 0) {
|
|
||||||
container.innerHTML = '<div class="analytics-empty">No geofence zones defined</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
container.innerHTML = zones.map(z =>
|
|
||||||
'<div class="geofence-zone-item">' +
|
|
||||||
'<span class="zone-name">' + _esc(z.name) + '</span>' +
|
|
||||||
'<span class="zone-radius">' + z.radius_m + 'm</span>' +
|
|
||||||
'<button class="zone-delete" onclick="Analytics.deleteGeofence(' + z.id + ')">DEL</button>' +
|
|
||||||
'</div>'
|
|
||||||
).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function addGeofence() {
|
|
||||||
const name = prompt('Zone name:');
|
|
||||||
if (!name) return;
|
|
||||||
const lat = parseFloat(prompt('Latitude:', '0'));
|
|
||||||
const lon = parseFloat(prompt('Longitude:', '0'));
|
|
||||||
const radius = parseFloat(prompt('Radius (meters):', '1000'));
|
|
||||||
if (isNaN(lat) || isNaN(lon) || isNaN(radius)) {
|
|
||||||
alert('Invalid input');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fetch('/analytics/geofences', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name, lat, lon, radius_m: radius }),
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(() => refresh());
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteGeofence(id) {
|
|
||||||
if (!confirm('Delete this geofence zone?')) return;
|
|
||||||
fetch('/analytics/geofences/' + id, { method: 'DELETE' })
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(() => refresh());
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportData(mode) {
|
|
||||||
const m = mode || (document.getElementById('exportMode') || {}).value || 'adsb';
|
|
||||||
const f = (document.getElementById('exportFormat') || {}).value || 'json';
|
|
||||||
window.open('/analytics/export/' + encodeURIComponent(m) + '?format=' + encodeURIComponent(f), '_blank');
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchTarget() {
|
|
||||||
const input = document.getElementById('analyticsTargetQuery');
|
|
||||||
const summaryEl = document.getElementById('analyticsTargetSummary');
|
|
||||||
const q = (input && input.value || '').trim();
|
|
||||||
if (!q) {
|
|
||||||
if (summaryEl) summaryEl.textContent = 'Enter a search value to correlate entities';
|
|
||||||
renderTargetResults([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch('/analytics/target?q=' + encodeURIComponent(q) + '&limit=120')
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
const results = data.results || [];
|
|
||||||
if (summaryEl) {
|
|
||||||
const modeCounts = data.mode_counts || {};
|
|
||||||
const bits = Object.entries(modeCounts).map(([mode, count]) => `${mode}: ${count}`).join(' | ');
|
|
||||||
summaryEl.textContent = `${results.length} results${bits ? ' | ' + bits : ''}`;
|
|
||||||
}
|
|
||||||
renderTargetResults(results);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (summaryEl) summaryEl.textContent = 'Search failed';
|
|
||||||
if (typeof reportActionableError === 'function') {
|
|
||||||
reportActionableError('Target View Search', err, { onRetry: searchTarget });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTargetResults(results) {
|
|
||||||
const container = document.getElementById('analyticsTargetResults');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
if (!results || !results.length) {
|
|
||||||
container.innerHTML = '<div class="analytics-empty">No matching entities</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = results.map((item) => {
|
|
||||||
const title = _esc(item.title || item.id || 'Entity');
|
|
||||||
const subtitle = _esc(item.subtitle || '');
|
|
||||||
const mode = _esc(item.mode || 'unknown');
|
|
||||||
const confidence = item.confidence != null ? `Confidence ${_esc(Math.round(Number(item.confidence) * 100))}%` : '';
|
|
||||||
const lastSeen = _esc(item.last_seen || '');
|
|
||||||
return '<div class="analytics-target-item">' +
|
|
||||||
'<div class="title"><span class="mode">' + mode + '</span><span>' + title + '</span></div>' +
|
|
||||||
'<div class="meta"><span>' + subtitle + '</span>' +
|
|
||||||
(lastSeen ? '<span>Last seen ' + lastSeen + '</span>' : '') +
|
|
||||||
(confidence ? '<span>' + confidence + '</span>' : '') +
|
|
||||||
'</div>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadReplaySessions() {
|
|
||||||
const select = document.getElementById('analyticsReplaySelect');
|
|
||||||
if (!select) return;
|
|
||||||
|
|
||||||
fetch('/recordings?limit=60')
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
replaySessions = (data.recordings || []).filter((rec) => Number(rec.event_count || 0) > 0);
|
|
||||||
|
|
||||||
if (!replaySessions.length) {
|
|
||||||
select.innerHTML = '<option value="">No recordings</option>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
select.innerHTML = replaySessions.map((rec) => {
|
|
||||||
const label = `${rec.mode} | ${(rec.label || 'session')} | ${new Date(rec.started_at).toLocaleString()}`;
|
|
||||||
return `<option value="${_esc(rec.id)}">${_esc(label)}</option>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
const pendingReplay = localStorage.getItem('analyticsReplaySession');
|
|
||||||
if (pendingReplay && replaySessions.some((rec) => rec.id === pendingReplay)) {
|
|
||||||
select.value = pendingReplay;
|
|
||||||
localStorage.removeItem('analyticsReplaySession');
|
|
||||||
loadReplay();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (typeof reportActionableError === 'function') {
|
|
||||||
reportActionableError('Load Replay Sessions', err, { onRetry: loadReplaySessions });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadReplay() {
|
|
||||||
pauseReplay();
|
|
||||||
replayEvents = [];
|
|
||||||
replayIndex = 0;
|
|
||||||
|
|
||||||
const select = document.getElementById('analyticsReplaySelect');
|
|
||||||
const meta = document.getElementById('analyticsReplayMeta');
|
|
||||||
const timeline = document.getElementById('analyticsReplayTimeline');
|
|
||||||
if (!select || !meta || !timeline) return;
|
|
||||||
|
|
||||||
const id = select.value;
|
|
||||||
if (!id) {
|
|
||||||
meta.textContent = 'Select a recording';
|
|
||||||
timeline.innerHTML = '<div class="analytics-empty">No recording selected</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
meta.textContent = 'Loading replay events...';
|
|
||||||
|
|
||||||
fetch('/recordings/' + encodeURIComponent(id) + '/events?limit=600')
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
replayEvents = data.events || [];
|
|
||||||
replayIndex = 0;
|
|
||||||
if (!replayEvents.length) {
|
|
||||||
meta.textContent = 'No events found in selected recording';
|
|
||||||
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rec = replaySessions.find((s) => s.id === id);
|
|
||||||
const mode = rec ? rec.mode : (data.recording && data.recording.mode) || 'unknown';
|
|
||||||
meta.textContent = `${replayEvents.length} events loaded | mode ${mode}`;
|
|
||||||
renderReplayWindow();
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
meta.textContent = 'Replay load failed';
|
|
||||||
if (typeof reportActionableError === 'function') {
|
|
||||||
reportActionableError('Load Replay', err, { onRetry: loadReplay });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function playReplay() {
|
|
||||||
if (!replayEvents.length) {
|
|
||||||
loadReplay();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (replayTimer) return;
|
|
||||||
|
|
||||||
replayTimer = setInterval(() => {
|
|
||||||
if (replayIndex >= replayEvents.length - 1) {
|
|
||||||
pauseReplay();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
replayIndex += 1;
|
|
||||||
renderReplayWindow();
|
|
||||||
}, 260);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pauseReplay() {
|
|
||||||
if (replayTimer) {
|
|
||||||
clearInterval(replayTimer);
|
|
||||||
replayTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stepReplay() {
|
|
||||||
if (!replayEvents.length) {
|
|
||||||
loadReplay();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
pauseReplay();
|
|
||||||
replayIndex = Math.min(replayIndex + 1, replayEvents.length - 1);
|
|
||||||
renderReplayWindow();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderReplayWindow() {
|
|
||||||
const timeline = document.getElementById('analyticsReplayTimeline');
|
|
||||||
const meta = document.getElementById('analyticsReplayMeta');
|
|
||||||
if (!timeline || !meta) return;
|
|
||||||
|
|
||||||
const total = replayEvents.length;
|
|
||||||
if (!total) {
|
|
||||||
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = Math.max(0, replayIndex - 15);
|
|
||||||
const end = Math.min(total, replayIndex + 20);
|
|
||||||
const windowed = replayEvents.slice(start, end);
|
|
||||||
|
|
||||||
timeline.innerHTML = windowed.map((row, i) => {
|
|
||||||
const absolute = start + i;
|
|
||||||
const active = absolute === replayIndex;
|
|
||||||
const eventType = _esc(row.event_type || 'event');
|
|
||||||
const mode = _esc(row.mode || '--');
|
|
||||||
const ts = _esc(row.timestamp ? new Date(row.timestamp).toLocaleTimeString() : '--');
|
|
||||||
const detail = summarizeReplayEvent(row.event || {});
|
|
||||||
return '<div class="analytics-replay-item" style="opacity:' + (active ? '1' : '0.65') + ';">' +
|
|
||||||
'<div class="title"><span class="mode">' + mode + '</span><span>' + eventType + '</span></div>' +
|
|
||||||
'<div class="meta"><span>' + ts + '</span><span>' + _esc(detail) + '</span></div>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
meta.textContent = `Event ${replayIndex + 1}/${total}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeReplayEvent(event) {
|
|
||||||
if (!event || typeof event !== 'object') return 'No details';
|
|
||||||
if (event.callsign) return `Callsign ${event.callsign}`;
|
|
||||||
if (event.icao) return `ICAO ${event.icao}`;
|
|
||||||
if (event.ssid) return `SSID ${event.ssid}`;
|
|
||||||
if (event.bssid) return `BSSID ${event.bssid}`;
|
|
||||||
if (event.address) return `Address ${event.address}`;
|
|
||||||
if (event.name) return `Name ${event.name}`;
|
|
||||||
const keys = Object.keys(event);
|
|
||||||
if (!keys.length) return 'No fields';
|
|
||||||
return `${keys[0]}=${String(event[keys[0]]).slice(0, 40)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _setText(id, val) {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (el) el.textContent = val;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _esc(s) {
|
|
||||||
if (typeof s !== 'string') s = String(s == null ? '' : s);
|
|
||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
function _shortId(value) {
|
|
||||||
const text = String(value || '');
|
|
||||||
if (text.length <= 18) return text;
|
|
||||||
return text.slice(0, 8) + '...' + text.slice(-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _humanPeriod(seconds) {
|
|
||||||
if (!isFinite(seconds) || seconds <= 0) return '--';
|
|
||||||
if (seconds < 60) return Math.round(seconds) + 's';
|
|
||||||
const mins = seconds / 60;
|
|
||||||
if (mins < 60) return mins.toFixed(mins < 10 ? 1 : 0) + 'm';
|
|
||||||
const hours = mins / 60;
|
|
||||||
return hours.toFixed(hours < 10 ? 1 : 0) + 'h';
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
init,
|
|
||||||
destroy,
|
|
||||||
refresh,
|
|
||||||
addGeofence,
|
|
||||||
deleteGeofence,
|
|
||||||
exportData,
|
|
||||||
searchTarget,
|
|
||||||
loadReplay,
|
|
||||||
playReplay,
|
|
||||||
pauseReplay,
|
|
||||||
stepReplay,
|
|
||||||
loadReplaySessions,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -946,17 +946,32 @@ const BluetoothMode = (function() {
|
|||||||
|
|
||||||
async function stopScan() {
|
async function stopScan() {
|
||||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
|
const timeoutMs = isAgentMode ? 8000 : 2200;
|
||||||
|
const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
||||||
|
const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
||||||
|
|
||||||
|
// Optimistic UI teardown keeps mode changes responsive.
|
||||||
|
setScanning(false);
|
||||||
|
stopEventStream();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isAgentMode) {
|
if (isAgentMode) {
|
||||||
await fetch(`/controller/agents/${currentAgent}/bluetooth/stop`, { method: 'POST' });
|
await fetch(`/controller/agents/${currentAgent}/bluetooth/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
...(controller ? { signal: controller.signal } : {}),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await fetch('/api/bluetooth/scan/stop', { method: 'POST' });
|
await fetch('/api/bluetooth/scan/stop', {
|
||||||
|
method: 'POST',
|
||||||
|
...(controller ? { signal: controller.signal } : {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
setScanning(false);
|
|
||||||
stopEventStream();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to stop scan:', err);
|
console.error('Failed to stop scan:', err);
|
||||||
|
} finally {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+364
-38
@@ -31,12 +31,20 @@ const BtLocate = (function() {
|
|||||||
let movementHeadMarker = null;
|
let movementHeadMarker = null;
|
||||||
let strongestMarker = null;
|
let strongestMarker = null;
|
||||||
let confidenceCircle = null;
|
let confidenceCircle = null;
|
||||||
let heatmapEnabled = true;
|
let heatmapEnabled = false;
|
||||||
let movementEnabled = true;
|
let movementEnabled = true;
|
||||||
let autoFollowEnabled = true;
|
let autoFollowEnabled = true;
|
||||||
let smoothingEnabled = true;
|
let smoothingEnabled = true;
|
||||||
let lastRenderedDetectionKey = null;
|
let lastRenderedDetectionKey = null;
|
||||||
let pendingHeatSync = false;
|
let pendingHeatSync = false;
|
||||||
|
let mapStabilizeTimer = null;
|
||||||
|
let modeActive = false;
|
||||||
|
let queuedDetection = null;
|
||||||
|
let queuedDetectionOptions = null;
|
||||||
|
let queuedDetectionTimer = null;
|
||||||
|
let lastDetectionRenderAt = 0;
|
||||||
|
let startRequestInFlight = false;
|
||||||
|
let crosshairResetTimer = null;
|
||||||
|
|
||||||
const MAX_HEAT_POINTS = 1200;
|
const MAX_HEAT_POINTS = 1200;
|
||||||
const MAX_TRAIL_POINTS = 1200;
|
const MAX_TRAIL_POINTS = 1200;
|
||||||
@@ -44,6 +52,9 @@ const BtLocate = (function() {
|
|||||||
const OUTLIER_HARD_JUMP_METERS = 2000;
|
const OUTLIER_HARD_JUMP_METERS = 2000;
|
||||||
const OUTLIER_SOFT_JUMP_METERS = 450;
|
const OUTLIER_SOFT_JUMP_METERS = 450;
|
||||||
const OUTLIER_MAX_SPEED_MPS = 50;
|
const OUTLIER_MAX_SPEED_MPS = 50;
|
||||||
|
const MAP_STABILIZE_INTERVAL_MS = 220;
|
||||||
|
const MAP_STABILIZE_ATTEMPTS = 8;
|
||||||
|
const MIN_DETECTION_RENDER_MS = 220;
|
||||||
const OVERLAY_STORAGE_KEYS = {
|
const OVERLAY_STORAGE_KEYS = {
|
||||||
heatmap: 'btLocateHeatmapEnabled',
|
heatmap: 'btLocateHeatmapEnabled',
|
||||||
movement: 'btLocateMovementEnabled',
|
movement: 'btLocateMovementEnabled',
|
||||||
@@ -63,6 +74,20 @@ const BtLocate = (function() {
|
|||||||
1.0: '#ef4444',
|
1.0: '#ef4444',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const BT_LOCATE_DEBUG = (() => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams(window.location.search || '');
|
||||||
|
return params.get('btlocate_debug') === '1' ||
|
||||||
|
localStorage.getItem('btLocateDebug') === 'true';
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function debugLog() {
|
||||||
|
if (!BT_LOCATE_DEBUG) return;
|
||||||
|
console.log.apply(console, arguments);
|
||||||
|
}
|
||||||
|
|
||||||
function getMapContainer() {
|
function getMapContainer() {
|
||||||
if (!map || typeof map.getContainer !== 'function') return null;
|
if (!map || typeof map.getContainer !== 'function') return null;
|
||||||
@@ -81,7 +106,71 @@ const BtLocate = (function() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusUrl() {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams(window.location.search || '');
|
||||||
|
const debugFlag = params.get('btlocate_debug') === '1' ||
|
||||||
|
localStorage.getItem('btLocateDebug') === 'true';
|
||||||
|
return debugFlag ? '/bt_locate/status?debug=1' : '/bt_locate/status';
|
||||||
|
} catch (_) {
|
||||||
|
return '/bt_locate/status';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceLocation(lat, lon) {
|
||||||
|
const nLat = Number(lat);
|
||||||
|
const nLon = Number(lon);
|
||||||
|
if (!isFinite(nLat) || !isFinite(nLon)) return null;
|
||||||
|
if (nLat < -90 || nLat > 90 || nLon < -180 || nLon > 180) return null;
|
||||||
|
return { lat: nLat, lon: nLon };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFallbackLocation() {
|
||||||
|
try {
|
||||||
|
if (typeof ObserverLocation !== 'undefined' && ObserverLocation.getShared) {
|
||||||
|
const shared = ObserverLocation.getShared();
|
||||||
|
const normalized = coerceLocation(shared?.lat, shared?.lon);
|
||||||
|
if (normalized) return normalized;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('observerLocation');
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
const normalized = coerceLocation(parsed?.lat, parsed?.lon);
|
||||||
|
if (normalized) return normalized;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const normalized = coerceLocation(
|
||||||
|
localStorage.getItem('observerLat'),
|
||||||
|
localStorage.getItem('observerLon')
|
||||||
|
);
|
||||||
|
if (normalized) return normalized;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
return coerceLocation(window.INTERCEPT_DEFAULT_LAT, window.INTERCEPT_DEFAULT_LON);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStartButtonBusy(busy) {
|
||||||
|
const startBtn = document.getElementById('btLocateStartBtn');
|
||||||
|
if (!startBtn) return;
|
||||||
|
if (busy) {
|
||||||
|
if (!startBtn.dataset.defaultLabel) {
|
||||||
|
startBtn.dataset.defaultLabel = startBtn.textContent || 'Start Locate';
|
||||||
|
}
|
||||||
|
startBtn.disabled = true;
|
||||||
|
startBtn.textContent = 'Starting...';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startBtn.disabled = false;
|
||||||
|
startBtn.textContent = startBtn.dataset.defaultLabel || 'Start Locate';
|
||||||
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
|
modeActive = true;
|
||||||
loadOverlayPreferences();
|
loadOverlayPreferences();
|
||||||
syncOverlayControls();
|
syncOverlayControls();
|
||||||
|
|
||||||
@@ -99,6 +188,7 @@ const BtLocate = (function() {
|
|||||||
Settings.createTileLayer().addTo(map);
|
Settings.createTileLayer().addTo(map);
|
||||||
}
|
}
|
||||||
flushPendingHeatSync();
|
flushPendingHeatSync();
|
||||||
|
scheduleMapStabilization(10);
|
||||||
}, 150);
|
}, 150);
|
||||||
}
|
}
|
||||||
checkStatus();
|
checkStatus();
|
||||||
@@ -113,15 +203,23 @@ const BtLocate = (function() {
|
|||||||
zoom: 2,
|
zoom: 2,
|
||||||
zoomControl: true,
|
zoomControl: true,
|
||||||
});
|
});
|
||||||
|
let tileLayer = null;
|
||||||
// Use tile provider from user settings
|
// Use tile provider from user settings
|
||||||
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
|
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
|
||||||
Settings.createTileLayer().addTo(map);
|
tileLayer = Settings.createTileLayer();
|
||||||
|
tileLayer.addTo(map);
|
||||||
Settings.registerMap(map);
|
Settings.registerMap(map);
|
||||||
} else {
|
} else {
|
||||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
tileLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
attribution: '© OSM © CARTO'
|
attribution: '© OSM © CARTO'
|
||||||
}).addTo(map);
|
});
|
||||||
|
tileLayer.addTo(map);
|
||||||
|
}
|
||||||
|
if (tileLayer && typeof tileLayer.on === 'function') {
|
||||||
|
tileLayer.on('load', () => {
|
||||||
|
scheduleMapStabilization(8);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
ensureHeatLayer();
|
ensureHeatLayer();
|
||||||
syncMovementLayer();
|
syncMovementLayer();
|
||||||
@@ -129,10 +227,11 @@ const BtLocate = (function() {
|
|||||||
map.on('resize moveend zoomend', () => {
|
map.on('resize moveend zoomend', () => {
|
||||||
flushPendingHeatSync();
|
flushPendingHeatSync();
|
||||||
});
|
});
|
||||||
setTimeout(() => {
|
requestAnimationFrame(() => {
|
||||||
safeInvalidateMap();
|
safeInvalidateMap();
|
||||||
flushPendingHeatSync();
|
flushPendingHeatSync();
|
||||||
}, 100);
|
scheduleMapStabilization();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init RSSI chart canvas
|
// Init RSSI chart canvas
|
||||||
@@ -146,7 +245,7 @@ const BtLocate = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function checkStatus() {
|
function checkStatus() {
|
||||||
fetch('/bt_locate/status')
|
fetch(statusUrl())
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.active) {
|
if (data.active) {
|
||||||
@@ -160,8 +259,21 @@ const BtLocate = (function() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeMacInput(value) {
|
||||||
|
const raw = (value || '').trim().toUpperCase().replace(/-/g, ':');
|
||||||
|
if (!raw) return '';
|
||||||
|
const compact = raw.replace(/[^0-9A-F]/g, '');
|
||||||
|
if (compact.length === 12) {
|
||||||
|
return compact.match(/.{1,2}/g).join(':');
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
function start() {
|
function start() {
|
||||||
const mac = document.getElementById('btLocateMac')?.value.trim();
|
if (startRequestInFlight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mac = normalizeMacInput(document.getElementById('btLocateMac')?.value);
|
||||||
const namePattern = document.getElementById('btLocateNamePattern')?.value.trim();
|
const namePattern = document.getElementById('btLocateNamePattern')?.value.trim();
|
||||||
const irk = document.getElementById('btLocateIrk')?.value.trim();
|
const irk = document.getElementById('btLocateIrk')?.value.trim();
|
||||||
|
|
||||||
@@ -177,14 +289,13 @@ const BtLocate = (function() {
|
|||||||
if (handoffData?.last_known_rssi) body.last_known_rssi = handoffData.last_known_rssi;
|
if (handoffData?.last_known_rssi) body.last_known_rssi = handoffData.last_known_rssi;
|
||||||
|
|
||||||
// Include user location as fallback when GPS unavailable
|
// Include user location as fallback when GPS unavailable
|
||||||
const userLat = localStorage.getItem('observerLat');
|
const fallbackLocation = resolveFallbackLocation();
|
||||||
const userLon = localStorage.getItem('observerLon');
|
if (fallbackLocation) {
|
||||||
if (userLat !== null && userLon !== null) {
|
body.fallback_lat = fallbackLocation.lat;
|
||||||
body.fallback_lat = parseFloat(userLat);
|
body.fallback_lon = fallbackLocation.lon;
|
||||||
body.fallback_lon = parseFloat(userLon);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[BtLocate] Starting with body:', body);
|
debugLog('[BtLocate] Starting with body:', body);
|
||||||
|
|
||||||
if (!body.mac_address && !body.name_pattern && !body.irk_hex &&
|
if (!body.mac_address && !body.name_pattern && !body.irk_hex &&
|
||||||
!body.device_id && !body.device_key && !body.fingerprint_id) {
|
!body.device_id && !body.device_key && !body.fingerprint_id) {
|
||||||
@@ -192,12 +303,27 @@ const BtLocate = (function() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startRequestInFlight = true;
|
||||||
|
setStartButtonBusy(true);
|
||||||
|
|
||||||
fetch('/bt_locate/start', {
|
fetch('/bt_locate/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(async (r) => {
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
data = await r.json();
|
||||||
|
} catch (_) {
|
||||||
|
data = {};
|
||||||
|
}
|
||||||
|
if (!r.ok || data.status !== 'started') {
|
||||||
|
const message = data.error || data.message || ('HTTP ' + r.status);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.status === 'started') {
|
if (data.status === 'started') {
|
||||||
sessionStartedAt = data.session?.started_at ? new Date(data.session.started_at).getTime() : Date.now();
|
sessionStartedAt = data.session?.started_at ? new Date(data.session.started_at).getTime() : Date.now();
|
||||||
@@ -209,23 +335,38 @@ const BtLocate = (function() {
|
|||||||
updateScanStatus(data.session);
|
updateScanStatus(data.session);
|
||||||
// Restore any existing trail (e.g. from a stop/start cycle)
|
// Restore any existing trail (e.g. from a stop/start cycle)
|
||||||
restoreTrail();
|
restoreTrail();
|
||||||
|
pollStatus();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => console.error('[BtLocate] Start error:', err));
|
.catch(err => {
|
||||||
|
console.error('[BtLocate] Start error:', err);
|
||||||
|
alert('BT Locate failed to start: ' + (err?.message || 'Unknown error'));
|
||||||
|
showIdleUI();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
startRequestInFlight = false;
|
||||||
|
setStartButtonBusy(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function stop() {
|
function stop() {
|
||||||
|
// Update UI immediately — don't wait for the backend response.
|
||||||
|
if (queuedDetectionTimer) {
|
||||||
|
clearTimeout(queuedDetectionTimer);
|
||||||
|
queuedDetectionTimer = null;
|
||||||
|
}
|
||||||
|
queuedDetection = null;
|
||||||
|
queuedDetectionOptions = null;
|
||||||
|
showIdleUI();
|
||||||
|
disconnectSSE();
|
||||||
|
stopAudio();
|
||||||
|
// Notify backend asynchronously.
|
||||||
fetch('/bt_locate/stop', { method: 'POST' })
|
fetch('/bt_locate/stop', { method: 'POST' })
|
||||||
.then(r => r.json())
|
|
||||||
.then(() => {
|
|
||||||
showIdleUI();
|
|
||||||
disconnectSSE();
|
|
||||||
stopAudio();
|
|
||||||
})
|
|
||||||
.catch(err => console.error('[BtLocate] Stop error:', err));
|
.catch(err => console.error('[BtLocate] Stop error:', err));
|
||||||
}
|
}
|
||||||
|
|
||||||
function showActiveUI() {
|
function showActiveUI() {
|
||||||
|
setStartButtonBusy(false);
|
||||||
const startBtn = document.getElementById('btLocateStartBtn');
|
const startBtn = document.getElementById('btLocateStartBtn');
|
||||||
const stopBtn = document.getElementById('btLocateStopBtn');
|
const stopBtn = document.getElementById('btLocateStopBtn');
|
||||||
if (startBtn) startBtn.style.display = 'none';
|
if (startBtn) startBtn.style.display = 'none';
|
||||||
@@ -234,6 +375,14 @@ const BtLocate = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showIdleUI() {
|
function showIdleUI() {
|
||||||
|
startRequestInFlight = false;
|
||||||
|
setStartButtonBusy(false);
|
||||||
|
if (queuedDetectionTimer) {
|
||||||
|
clearTimeout(queuedDetectionTimer);
|
||||||
|
queuedDetectionTimer = null;
|
||||||
|
}
|
||||||
|
queuedDetection = null;
|
||||||
|
queuedDetectionOptions = null;
|
||||||
const startBtn = document.getElementById('btLocateStartBtn');
|
const startBtn = document.getElementById('btLocateStartBtn');
|
||||||
const stopBtn = document.getElementById('btLocateStopBtn');
|
const stopBtn = document.getElementById('btLocateStopBtn');
|
||||||
if (startBtn) startBtn.style.display = 'inline-block';
|
if (startBtn) startBtn.style.display = 'inline-block';
|
||||||
@@ -263,13 +412,13 @@ const BtLocate = (function() {
|
|||||||
|
|
||||||
function connectSSE() {
|
function connectSSE() {
|
||||||
if (eventSource) eventSource.close();
|
if (eventSource) eventSource.close();
|
||||||
console.log('[BtLocate] Connecting SSE stream');
|
debugLog('[BtLocate] Connecting SSE stream');
|
||||||
eventSource = new EventSource('/bt_locate/stream');
|
eventSource = new EventSource('/bt_locate/stream');
|
||||||
|
|
||||||
eventSource.addEventListener('detection', function(e) {
|
eventSource.addEventListener('detection', function(e) {
|
||||||
try {
|
try {
|
||||||
const event = JSON.parse(e.data);
|
const event = JSON.parse(e.data);
|
||||||
console.log('[BtLocate] Detection event:', event);
|
debugLog('[BtLocate] Detection event:', event);
|
||||||
handleDetection(event);
|
handleDetection(event);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[BtLocate] Parse error:', err);
|
console.error('[BtLocate] Parse error:', err);
|
||||||
@@ -282,7 +431,7 @@ const BtLocate = (function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
eventSource.onerror = function() {
|
eventSource.onerror = function() {
|
||||||
console.warn('[BtLocate] SSE error, polling fallback active');
|
debugLog('[BtLocate] SSE error, polling fallback active');
|
||||||
if (eventSource && eventSource.readyState === EventSource.CLOSED) {
|
if (eventSource && eventSource.readyState === EventSource.CLOSED) {
|
||||||
eventSource = null;
|
eventSource = null;
|
||||||
}
|
}
|
||||||
@@ -290,6 +439,7 @@ const BtLocate = (function() {
|
|||||||
|
|
||||||
// Start polling fallback (catches data even if SSE fails)
|
// Start polling fallback (catches data even if SSE fails)
|
||||||
startPolling();
|
startPolling();
|
||||||
|
pollStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function disconnectSSE() {
|
function disconnectSSE() {
|
||||||
@@ -337,7 +487,7 @@ const BtLocate = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pollStatus() {
|
function pollStatus() {
|
||||||
fetch('/bt_locate/status')
|
fetch(statusUrl())
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (!data.active) {
|
if (!data.active) {
|
||||||
@@ -434,7 +584,42 @@ const BtLocate = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function flushQueuedDetection() {
|
||||||
|
if (!queuedDetection) return;
|
||||||
|
const event = queuedDetection;
|
||||||
|
const options = queuedDetectionOptions || {};
|
||||||
|
queuedDetection = null;
|
||||||
|
queuedDetectionOptions = null;
|
||||||
|
queuedDetectionTimer = null;
|
||||||
|
renderDetection(event, options);
|
||||||
|
}
|
||||||
|
|
||||||
function handleDetection(event, options = {}) {
|
function handleDetection(event, options = {}) {
|
||||||
|
if (!modeActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
if (options.force || (now - lastDetectionRenderAt) >= MIN_DETECTION_RENDER_MS) {
|
||||||
|
if (queuedDetectionTimer) {
|
||||||
|
clearTimeout(queuedDetectionTimer);
|
||||||
|
queuedDetectionTimer = null;
|
||||||
|
}
|
||||||
|
queuedDetection = null;
|
||||||
|
queuedDetectionOptions = null;
|
||||||
|
renderDetection(event, options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep only the freshest event while throttled.
|
||||||
|
queuedDetection = event;
|
||||||
|
queuedDetectionOptions = options;
|
||||||
|
if (!queuedDetectionTimer) {
|
||||||
|
queuedDetectionTimer = setTimeout(flushQueuedDetection, MIN_DETECTION_RENDER_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetection(event, options = {}) {
|
||||||
|
lastDetectionRenderAt = Date.now();
|
||||||
const d = event?.data || event;
|
const d = event?.data || event;
|
||||||
if (!d) return;
|
if (!d) return;
|
||||||
const detectionKey = buildDetectionKey(d);
|
const detectionKey = buildDetectionKey(d);
|
||||||
@@ -460,7 +645,7 @@ const BtLocate = (function() {
|
|||||||
try {
|
try {
|
||||||
mapPointAdded = addMapMarker(d, { suppressFollow: options.suppressFollow === true });
|
mapPointAdded = addMapMarker(d, { suppressFollow: options.suppressFollow === true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[BtLocate] Map update skipped:', error);
|
debugLog('[BtLocate] Map update skipped:', error);
|
||||||
mapPointAdded = false;
|
mapPointAdded = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -518,12 +703,40 @@ const BtLocate = (function() {
|
|||||||
if (gpsCountEl) gpsCountEl.textContent = gpsPoints || 0;
|
if (gpsCountEl) gpsCountEl.textContent = gpsPoints || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function triggerCrosshairAnimation(lat, lon) {
|
||||||
|
if (!map) return;
|
||||||
|
const overlay = document.getElementById('btLocateCrosshairOverlay');
|
||||||
|
if (!overlay) return;
|
||||||
|
const size = map.getSize();
|
||||||
|
const point = map.latLngToContainerPoint([lat, lon]);
|
||||||
|
const targetX = Math.max(0, Math.min(size.x, point.x));
|
||||||
|
const targetY = Math.max(0, Math.min(size.y, point.y));
|
||||||
|
const startX = size.x + 8;
|
||||||
|
const startY = size.y + 8;
|
||||||
|
const duration = 1500;
|
||||||
|
overlay.style.setProperty('--btl-crosshair-x-start', `${startX}px`);
|
||||||
|
overlay.style.setProperty('--btl-crosshair-y-start', `${startY}px`);
|
||||||
|
overlay.style.setProperty('--btl-crosshair-x-end', `${targetX}px`);
|
||||||
|
overlay.style.setProperty('--btl-crosshair-y-end', `${targetY}px`);
|
||||||
|
overlay.style.setProperty('--btl-crosshair-duration', `${duration}ms`);
|
||||||
|
overlay.classList.remove('active');
|
||||||
|
void overlay.offsetWidth;
|
||||||
|
overlay.classList.add('active');
|
||||||
|
if (crosshairResetTimer) clearTimeout(crosshairResetTimer);
|
||||||
|
crosshairResetTimer = setTimeout(() => {
|
||||||
|
overlay.classList.remove('active');
|
||||||
|
crosshairResetTimer = null;
|
||||||
|
}, duration + 100);
|
||||||
|
}
|
||||||
|
|
||||||
function addMapMarker(point, options = {}) {
|
function addMapMarker(point, options = {}) {
|
||||||
if (!map || point.lat == null || point.lon == null) return false;
|
if (!map || point.lat == null || point.lon == null) return false;
|
||||||
const lat = Number(point.lat);
|
const lat = Number(point.lat);
|
||||||
const lon = Number(point.lon);
|
const lon = Number(point.lon);
|
||||||
if (!isFinite(lat) || !isFinite(lon)) return false;
|
if (!isFinite(lat) || !isFinite(lon)) return false;
|
||||||
if (!shouldAcceptMapPoint(point, lat, lon)) return false;
|
if (!shouldAcceptMapPoint(point, lat, lon)) return false;
|
||||||
|
const suppressFollow = options.suppressFollow === true;
|
||||||
|
const bulkLoad = options.bulkLoad === true;
|
||||||
|
|
||||||
const trailPoint = normalizeTrailPoint(point, lat, lon);
|
const trailPoint = normalizeTrailPoint(point, lat, lon);
|
||||||
const band = (trailPoint.proximity_band || 'FAR').toLowerCase();
|
const band = (trailPoint.proximity_band || 'FAR').toLowerCase();
|
||||||
@@ -550,6 +763,7 @@ const BtLocate = (function() {
|
|||||||
'Time: ' + formatPointTimestamp(trailPoint.timestamp) +
|
'Time: ' + formatPointTimestamp(trailPoint.timestamp) +
|
||||||
'</div>'
|
'</div>'
|
||||||
);
|
);
|
||||||
|
marker.on('click', () => triggerCrosshairAnimation(lat, lon));
|
||||||
|
|
||||||
trailPoints.push(trailPoint);
|
trailPoints.push(trailPoint);
|
||||||
mapMarkers.push(marker);
|
mapMarkers.push(marker);
|
||||||
@@ -563,13 +777,17 @@ const BtLocate = (function() {
|
|||||||
if (heatPoints.length > MAX_HEAT_POINTS) {
|
if (heatPoints.length > MAX_HEAT_POINTS) {
|
||||||
heatPoints.splice(0, heatPoints.length - MAX_HEAT_POINTS);
|
heatPoints.splice(0, heatPoints.length - MAX_HEAT_POINTS);
|
||||||
}
|
}
|
||||||
|
if (bulkLoad) {
|
||||||
|
pendingHeatSync = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
syncHeatLayer();
|
syncHeatLayer();
|
||||||
|
|
||||||
if (!isMapRenderable()) {
|
if (!isMapRenderable()) {
|
||||||
safeInvalidateMap();
|
safeInvalidateMap();
|
||||||
}
|
}
|
||||||
const canFollowMap = isMapRenderable();
|
const canFollowMap = isMapRenderable();
|
||||||
if (autoFollowEnabled && !options.suppressFollow && canFollowMap) {
|
if (autoFollowEnabled && !suppressFollow && canFollowMap) {
|
||||||
if (!gpsLocked) {
|
if (!gpsLocked) {
|
||||||
gpsLocked = true;
|
gpsLocked = true;
|
||||||
map.setView([lat, lon], Math.max(map.getZoom(), 16));
|
map.setView([lat, lon], Math.max(map.getZoom(), 16));
|
||||||
@@ -645,8 +863,13 @@ const BtLocate = (function() {
|
|||||||
|
|
||||||
const gpsTrail = Array.isArray(trail.gps_trail) ? trail.gps_trail : [];
|
const gpsTrail = Array.isArray(trail.gps_trail) ? trail.gps_trail : [];
|
||||||
const allTrail = Array.isArray(trail.trail) ? trail.trail : [];
|
const allTrail = Array.isArray(trail.trail) ? trail.trail : [];
|
||||||
|
const recentGpsTrail = gpsTrail.slice(-MAX_TRAIL_POINTS);
|
||||||
|
|
||||||
gpsTrail.forEach(p => addMapMarker(p, { suppressFollow: true }));
|
recentGpsTrail.forEach(p => addMapMarker(p, {
|
||||||
|
suppressFollow: true,
|
||||||
|
bulkLoad: true,
|
||||||
|
}));
|
||||||
|
syncHeatLayer();
|
||||||
|
|
||||||
if (allTrail.length > 0) {
|
if (allTrail.length > 0) {
|
||||||
rssiHistory = allTrail.map(p => p.rssi).filter(v => typeof v === 'number' && isFinite(v)).slice(-MAX_RSSI_POINTS);
|
rssiHistory = allTrail.map(p => p.rssi).filter(v => typeof v === 'number' && isFinite(v)).slice(-MAX_RSSI_POINTS);
|
||||||
@@ -659,7 +882,7 @@ const BtLocate = (function() {
|
|||||||
drawRssiChart();
|
drawRssiChart();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateStats(allTrail.length, gpsTrail.length);
|
updateStats(allTrail.length, recentGpsTrail.length);
|
||||||
|
|
||||||
if (trailPoints.length > 0 && map) {
|
if (trailPoints.length > 0 && map) {
|
||||||
const latestGps = trailPoints[trailPoints.length - 1];
|
const latestGps = trailPoints[trailPoints.length - 1];
|
||||||
@@ -675,6 +898,7 @@ const BtLocate = (function() {
|
|||||||
syncStrongestMarker();
|
syncStrongestMarker();
|
||||||
updateConfidenceLayer();
|
updateConfidenceLayer();
|
||||||
updateMovementStats();
|
updateMovementStats();
|
||||||
|
scheduleMapStabilization(12);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -853,7 +1077,7 @@ const BtLocate = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureHeatLayer() {
|
function ensureHeatLayer() {
|
||||||
if (!map || typeof L === 'undefined' || typeof L.heatLayer !== 'function') return;
|
if (!map || !heatmapEnabled || typeof L === 'undefined' || typeof L.heatLayer !== 'function') return;
|
||||||
if (!heatLayer) {
|
if (!heatLayer) {
|
||||||
heatLayer = L.heatLayer([], HEAT_LAYER_OPTIONS);
|
heatLayer = L.heatLayer([], HEAT_LAYER_OPTIONS);
|
||||||
}
|
}
|
||||||
@@ -861,9 +1085,19 @@ const BtLocate = (function() {
|
|||||||
|
|
||||||
function syncHeatLayer() {
|
function syncHeatLayer() {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
if (!heatmapEnabled) {
|
||||||
|
if (heatLayer && map.hasLayer(heatLayer)) {
|
||||||
|
map.removeLayer(heatLayer);
|
||||||
|
}
|
||||||
|
pendingHeatSync = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
ensureHeatLayer();
|
ensureHeatLayer();
|
||||||
if (!heatLayer) return;
|
if (!heatLayer) return;
|
||||||
if (!isMapContainerVisible()) {
|
if (!modeActive || !isMapContainerVisible()) {
|
||||||
|
if (map.hasLayer(heatLayer)) {
|
||||||
|
map.removeLayer(heatLayer);
|
||||||
|
}
|
||||||
pendingHeatSync = true;
|
pendingHeatSync = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -874,6 +1108,13 @@ const BtLocate = (function() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!Array.isArray(heatPoints) || heatPoints.length === 0) {
|
||||||
|
if (map.hasLayer(heatLayer)) {
|
||||||
|
map.removeLayer(heatLayer);
|
||||||
|
}
|
||||||
|
pendingHeatSync = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
heatLayer.setLatLngs(heatPoints);
|
heatLayer.setLatLngs(heatPoints);
|
||||||
if (heatmapEnabled) {
|
if (heatmapEnabled) {
|
||||||
@@ -889,10 +1130,52 @@ const BtLocate = (function() {
|
|||||||
if (map.hasLayer(heatLayer)) {
|
if (map.hasLayer(heatLayer)) {
|
||||||
map.removeLayer(heatLayer);
|
map.removeLayer(heatLayer);
|
||||||
}
|
}
|
||||||
console.warn('[BtLocate] Heatmap redraw deferred:', error);
|
debugLog('[BtLocate] Heatmap redraw deferred:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setActiveMode(active) {
|
||||||
|
modeActive = !!active;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
if (!modeActive) {
|
||||||
|
stopMapStabilization();
|
||||||
|
if (queuedDetectionTimer) {
|
||||||
|
clearTimeout(queuedDetectionTimer);
|
||||||
|
queuedDetectionTimer = null;
|
||||||
|
}
|
||||||
|
queuedDetection = null;
|
||||||
|
queuedDetectionOptions = null;
|
||||||
|
// Pause BT Locate frontend work when mode is hidden.
|
||||||
|
disconnectSSE();
|
||||||
|
if (heatLayer && map.hasLayer(heatLayer)) {
|
||||||
|
map.removeLayer(heatLayer);
|
||||||
|
}
|
||||||
|
pendingHeatSync = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!modeActive) return;
|
||||||
|
safeInvalidateMap();
|
||||||
|
flushPendingHeatSync();
|
||||||
|
syncHeatLayer();
|
||||||
|
syncMovementLayer();
|
||||||
|
syncStrongestMarker();
|
||||||
|
updateConfidenceLayer();
|
||||||
|
scheduleMapStabilization(8);
|
||||||
|
checkStatus();
|
||||||
|
}, 80);
|
||||||
|
|
||||||
|
// A second pass after layout settles (sidebar/visual transitions).
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!modeActive) return;
|
||||||
|
safeInvalidateMap();
|
||||||
|
flushPendingHeatSync();
|
||||||
|
syncHeatLayer();
|
||||||
|
}, 260);
|
||||||
|
}
|
||||||
|
|
||||||
function isMapRenderable() {
|
function isMapRenderable() {
|
||||||
if (!map || !isMapContainerVisible()) return false;
|
if (!map || !isMapContainerVisible()) return false;
|
||||||
if (typeof map.getSize === 'function') {
|
if (typeof map.getSize === 'function') {
|
||||||
@@ -908,6 +1191,45 @@ const BtLocate = (function() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stopMapStabilization() {
|
||||||
|
if (mapStabilizeTimer) {
|
||||||
|
clearInterval(mapStabilizeTimer);
|
||||||
|
mapStabilizeTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleMapStabilization(attempts = MAP_STABILIZE_ATTEMPTS) {
|
||||||
|
if (!map) return;
|
||||||
|
stopMapStabilization();
|
||||||
|
let remaining = Math.max(1, Number(attempts) || MAP_STABILIZE_ATTEMPTS);
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (!map) {
|
||||||
|
stopMapStabilization();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (safeInvalidateMap()) {
|
||||||
|
flushPendingHeatSync();
|
||||||
|
syncMovementLayer();
|
||||||
|
syncStrongestMarker();
|
||||||
|
updateConfidenceLayer();
|
||||||
|
if (isMapRenderable()) {
|
||||||
|
stopMapStabilization();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remaining -= 1;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
stopMapStabilization();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tick();
|
||||||
|
if (map && !mapStabilizeTimer && !isMapRenderable()) {
|
||||||
|
mapStabilizeTimer = setInterval(tick, MAP_STABILIZE_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function flushPendingHeatSync() {
|
function flushPendingHeatSync() {
|
||||||
if (!pendingHeatSync) return;
|
if (!pendingHeatSync) return;
|
||||||
syncHeatLayer();
|
syncHeatLayer();
|
||||||
@@ -1306,7 +1628,7 @@ const BtLocate = (function() {
|
|||||||
if (typeof showNotification === 'function') {
|
if (typeof showNotification === 'function') {
|
||||||
showNotification(title, message);
|
showNotification(title, message);
|
||||||
} else {
|
} else {
|
||||||
console.log('[BtLocate] ' + title + ': ' + message);
|
debugLog('[BtLocate] ' + title + ': ' + message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1397,7 +1719,7 @@ const BtLocate = (function() {
|
|||||||
// Resume must happen within a user gesture handler
|
// Resume must happen within a user gesture handler
|
||||||
const ctx = audioCtx;
|
const ctx = audioCtx;
|
||||||
ctx.resume().then(() => {
|
ctx.resume().then(() => {
|
||||||
console.log('[BtLocate] AudioContext state:', ctx.state);
|
debugLog('[BtLocate] AudioContext state:', ctx.state);
|
||||||
// Confirmation beep so user knows audio is working
|
// Confirmation beep so user knows audio is working
|
||||||
playTone(600, 0.08);
|
playTone(600, 0.08);
|
||||||
});
|
});
|
||||||
@@ -1418,14 +1740,14 @@ const BtLocate = (function() {
|
|||||||
btn.classList.toggle('active', btn.dataset.env === env);
|
btn.classList.toggle('active', btn.dataset.env === env);
|
||||||
});
|
});
|
||||||
// Push to running session if active
|
// Push to running session if active
|
||||||
fetch('/bt_locate/status').then(r => r.json()).then(data => {
|
fetch(statusUrl()).then(r => r.json()).then(data => {
|
||||||
if (data.active) {
|
if (data.active) {
|
||||||
fetch('/bt_locate/environment', {
|
fetch('/bt_locate/environment', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ environment: env }),
|
body: JSON.stringify({ environment: env }),
|
||||||
}).then(r => r.json()).then(res => {
|
}).then(r => r.json()).then(res => {
|
||||||
console.log('[BtLocate] Environment updated:', res);
|
debugLog('[BtLocate] Environment updated:', res);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
@@ -1442,7 +1764,7 @@ const BtLocate = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handoff(deviceInfo) {
|
function handoff(deviceInfo) {
|
||||||
console.log('[BtLocate] Handoff received:', deviceInfo);
|
debugLog('[BtLocate] Handoff received:', deviceInfo);
|
||||||
handoffData = deviceInfo;
|
handoffData = deviceInfo;
|
||||||
|
|
||||||
// Populate fields
|
// Populate fields
|
||||||
@@ -1566,10 +1888,12 @@ const BtLocate = (function() {
|
|||||||
syncStrongestMarker();
|
syncStrongestMarker();
|
||||||
updateConfidenceLayer();
|
updateConfidenceLayer();
|
||||||
}
|
}
|
||||||
|
scheduleMapStabilization(8);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
init,
|
init,
|
||||||
|
setActiveMode,
|
||||||
start,
|
start,
|
||||||
stop,
|
stop,
|
||||||
handoff,
|
handoff,
|
||||||
@@ -1587,3 +1911,5 @@ const BtLocate = (function() {
|
|||||||
fetchPairedIrks,
|
fetchPairedIrks,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
window.BtLocate = BtLocate;
|
||||||
|
|||||||
@@ -1,852 +0,0 @@
|
|||||||
/**
|
|
||||||
* Intercept - DMR / Digital Voice Mode
|
|
||||||
* Decoding DMR, P25, NXDN, D-STAR digital voice protocols
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ============== STATE ==============
|
|
||||||
let isDmrRunning = false;
|
|
||||||
let dmrEventSource = null;
|
|
||||||
let dmrCallCount = 0;
|
|
||||||
let dmrSyncCount = 0;
|
|
||||||
let dmrCallHistory = [];
|
|
||||||
let dmrCurrentProtocol = '--';
|
|
||||||
let dmrModeLabel = 'dmr'; // Protocol label for device reservation
|
|
||||||
let dmrHasAudio = false;
|
|
||||||
|
|
||||||
// ============== BOOKMARKS ==============
|
|
||||||
let dmrBookmarks = [];
|
|
||||||
const DMR_BOOKMARKS_KEY = 'dmrBookmarks';
|
|
||||||
const DMR_SETTINGS_KEY = 'dmrSettings';
|
|
||||||
const DMR_BOOKMARK_PROTOCOLS = new Set(['auto', 'dmr', 'p25', 'nxdn', 'dstar', 'provoice']);
|
|
||||||
|
|
||||||
// ============== SYNTHESIZER STATE ==============
|
|
||||||
let dmrSynthCanvas = null;
|
|
||||||
let dmrSynthCtx = null;
|
|
||||||
let dmrSynthBars = [];
|
|
||||||
let dmrSynthAnimationId = null;
|
|
||||||
let dmrSynthInitialized = false;
|
|
||||||
let dmrActivityLevel = 0;
|
|
||||||
let dmrActivityTarget = 0;
|
|
||||||
let dmrEventType = 'idle';
|
|
||||||
let dmrLastEventTime = 0;
|
|
||||||
const DMR_BAR_COUNT = 48;
|
|
||||||
const DMR_DECAY_RATE = 0.015;
|
|
||||||
const DMR_BURST_SYNC = 0.6;
|
|
||||||
const DMR_BURST_CALL = 0.85;
|
|
||||||
const DMR_BURST_VOICE = 0.95;
|
|
||||||
|
|
||||||
// ============== TOOLS CHECK ==============
|
|
||||||
|
|
||||||
function checkDmrTools() {
|
|
||||||
fetch('/dmr/tools')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
const warning = document.getElementById('dmrToolsWarning');
|
|
||||||
const warningText = document.getElementById('dmrToolsWarningText');
|
|
||||||
if (!warning) return;
|
|
||||||
|
|
||||||
const selectedType = (typeof getSelectedSDRType === 'function')
|
|
||||||
? getSelectedSDRType()
|
|
||||||
: 'rtlsdr';
|
|
||||||
const missing = [];
|
|
||||||
if (!data.dsd) missing.push('dsd (Digital Speech Decoder)');
|
|
||||||
if (selectedType === 'rtlsdr') {
|
|
||||||
if (!data.rtl_fm) missing.push('rtl_fm (RTL-SDR)');
|
|
||||||
} else if (!data.rx_fm) {
|
|
||||||
missing.push('rx_fm (SoapySDR demodulator)');
|
|
||||||
}
|
|
||||||
if (!data.ffmpeg) missing.push('ffmpeg (audio output — optional)');
|
|
||||||
|
|
||||||
if (missing.length > 0) {
|
|
||||||
warning.style.display = 'block';
|
|
||||||
if (warningText) warningText.textContent = missing.join(', ');
|
|
||||||
} else {
|
|
||||||
warning.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update audio panel availability
|
|
||||||
updateDmrAudioStatus(data.ffmpeg ? 'OFF' : 'UNAVAILABLE');
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============== START / STOP ==============
|
|
||||||
|
|
||||||
function startDmr() {
|
|
||||||
const frequency = parseFloat(document.getElementById('dmrFrequency')?.value || 462.5625);
|
|
||||||
const protocol = document.getElementById('dmrProtocol')?.value || 'auto';
|
|
||||||
const gain = parseInt(document.getElementById('dmrGain')?.value || 40);
|
|
||||||
const ppm = parseInt(document.getElementById('dmrPPM')?.value || 0);
|
|
||||||
const relaxCrc = document.getElementById('dmrRelaxCrc')?.checked || false;
|
|
||||||
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
|
|
||||||
const sdrType = (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr';
|
|
||||||
|
|
||||||
// Use protocol name for device reservation so panel shows "D-STAR", "P25", etc.
|
|
||||||
dmrModeLabel = protocol !== 'auto' ? protocol : 'dmr';
|
|
||||||
|
|
||||||
// Check device availability before starting
|
|
||||||
if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability(dmrModeLabel)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save settings to localStorage for persistence
|
|
||||||
try {
|
|
||||||
localStorage.setItem(DMR_SETTINGS_KEY, JSON.stringify({
|
|
||||||
frequency, protocol, gain, ppm, relaxCrc
|
|
||||||
}));
|
|
||||||
} catch (e) { /* localStorage unavailable */ }
|
|
||||||
|
|
||||||
fetch('/dmr/start', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ frequency, protocol, gain, device, ppm, relaxCrc, sdr_type: sdrType })
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.status === 'started') {
|
|
||||||
isDmrRunning = true;
|
|
||||||
dmrCallCount = 0;
|
|
||||||
dmrSyncCount = 0;
|
|
||||||
dmrCallHistory = [];
|
|
||||||
updateDmrUI();
|
|
||||||
connectDmrSSE();
|
|
||||||
dmrEventType = 'idle';
|
|
||||||
dmrActivityTarget = 0.1;
|
|
||||||
dmrLastEventTime = Date.now();
|
|
||||||
if (!dmrSynthInitialized) initDmrSynthesizer();
|
|
||||||
updateDmrSynthStatus();
|
|
||||||
const statusEl = document.getElementById('dmrStatus');
|
|
||||||
if (statusEl) statusEl.textContent = 'DECODING';
|
|
||||||
if (typeof reserveDevice === 'function') {
|
|
||||||
reserveDevice(parseInt(device), dmrModeLabel);
|
|
||||||
}
|
|
||||||
// Start audio if available
|
|
||||||
dmrHasAudio = !!data.has_audio;
|
|
||||||
if (dmrHasAudio) startDmrAudio();
|
|
||||||
updateDmrAudioStatus(dmrHasAudio ? 'STREAMING' : 'UNAVAILABLE');
|
|
||||||
if (typeof showNotification === 'function') {
|
|
||||||
showNotification('Digital Voice', `Decoding ${frequency} MHz (${protocol.toUpperCase()})`);
|
|
||||||
}
|
|
||||||
} else if (data.status === 'error' && data.message === 'Already running') {
|
|
||||||
// Backend has an active session the frontend lost track of — resync
|
|
||||||
isDmrRunning = true;
|
|
||||||
updateDmrUI();
|
|
||||||
connectDmrSSE();
|
|
||||||
if (!dmrSynthInitialized) initDmrSynthesizer();
|
|
||||||
dmrEventType = 'idle';
|
|
||||||
dmrActivityTarget = 0.1;
|
|
||||||
dmrLastEventTime = Date.now();
|
|
||||||
updateDmrSynthStatus();
|
|
||||||
const statusEl = document.getElementById('dmrStatus');
|
|
||||||
if (statusEl) statusEl.textContent = 'DECODING';
|
|
||||||
if (typeof showNotification === 'function') {
|
|
||||||
showNotification('DMR', 'Reconnected to active session');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (typeof showNotification === 'function') {
|
|
||||||
showNotification('Error', data.message || 'Failed to start DMR');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => console.error('[DMR] Start error:', err));
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopDmr() {
|
|
||||||
stopDmrAudio();
|
|
||||||
fetch('/dmr/stop', { method: 'POST' })
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(() => {
|
|
||||||
isDmrRunning = false;
|
|
||||||
if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; }
|
|
||||||
updateDmrUI();
|
|
||||||
dmrEventType = 'stopped';
|
|
||||||
dmrActivityTarget = 0;
|
|
||||||
updateDmrSynthStatus();
|
|
||||||
updateDmrAudioStatus('OFF');
|
|
||||||
const statusEl = document.getElementById('dmrStatus');
|
|
||||||
if (statusEl) statusEl.textContent = 'STOPPED';
|
|
||||||
if (typeof releaseDevice === 'function') {
|
|
||||||
releaseDevice(dmrModeLabel);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => console.error('[DMR] Stop error:', err));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============== SSE STREAMING ==============
|
|
||||||
|
|
||||||
function connectDmrSSE() {
|
|
||||||
if (dmrEventSource) dmrEventSource.close();
|
|
||||||
dmrEventSource = new EventSource('/dmr/stream');
|
|
||||||
|
|
||||||
dmrEventSource.onmessage = function(event) {
|
|
||||||
const msg = JSON.parse(event.data);
|
|
||||||
handleDmrMessage(msg);
|
|
||||||
};
|
|
||||||
|
|
||||||
dmrEventSource.onerror = function() {
|
|
||||||
if (isDmrRunning) {
|
|
||||||
setTimeout(connectDmrSSE, 2000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDmrMessage(msg) {
|
|
||||||
if (dmrSynthInitialized) dmrSynthPulse(msg.type);
|
|
||||||
|
|
||||||
if (msg.type === 'sync') {
|
|
||||||
dmrCurrentProtocol = msg.protocol || '--';
|
|
||||||
const protocolEl = document.getElementById('dmrActiveProtocol');
|
|
||||||
if (protocolEl) protocolEl.textContent = dmrCurrentProtocol;
|
|
||||||
const mainProtocolEl = document.getElementById('dmrMainProtocol');
|
|
||||||
if (mainProtocolEl) mainProtocolEl.textContent = dmrCurrentProtocol;
|
|
||||||
dmrSyncCount++;
|
|
||||||
const syncCountEl = document.getElementById('dmrSyncCount');
|
|
||||||
if (syncCountEl) syncCountEl.textContent = dmrSyncCount;
|
|
||||||
} else if (msg.type === 'call') {
|
|
||||||
dmrCallCount++;
|
|
||||||
const countEl = document.getElementById('dmrCallCount');
|
|
||||||
if (countEl) countEl.textContent = dmrCallCount;
|
|
||||||
const mainCountEl = document.getElementById('dmrMainCallCount');
|
|
||||||
if (mainCountEl) mainCountEl.textContent = dmrCallCount;
|
|
||||||
|
|
||||||
// Update current call display
|
|
||||||
const slotInfo = msg.slot != null ? `
|
|
||||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
|
||||||
<span style="color: var(--text-muted);">Slot</span>
|
|
||||||
<span style="color: var(--accent-orange); font-family: var(--font-mono);">${msg.slot}</span>
|
|
||||||
</div>` : '';
|
|
||||||
const callEl = document.getElementById('dmrCurrentCall');
|
|
||||||
if (callEl) {
|
|
||||||
callEl.innerHTML = `
|
|
||||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
|
||||||
<span style="color: var(--text-muted);">Talkgroup</span>
|
|
||||||
<span style="color: var(--accent-green); font-weight: bold; font-family: var(--font-mono);">${msg.talkgroup}</span>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
|
||||||
<span style="color: var(--text-muted);">Source ID</span>
|
|
||||||
<span style="color: var(--accent-cyan); font-family: var(--font-mono);">${msg.source_id}</span>
|
|
||||||
</div>${slotInfo}
|
|
||||||
<div style="display: flex; justify-content: space-between;">
|
|
||||||
<span style="color: var(--text-muted);">Time</span>
|
|
||||||
<span style="color: var(--text-primary);">${msg.timestamp}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to history
|
|
||||||
dmrCallHistory.unshift({
|
|
||||||
talkgroup: msg.talkgroup,
|
|
||||||
source_id: msg.source_id,
|
|
||||||
protocol: dmrCurrentProtocol,
|
|
||||||
time: msg.timestamp,
|
|
||||||
});
|
|
||||||
if (dmrCallHistory.length > 50) dmrCallHistory.length = 50;
|
|
||||||
renderDmrHistory();
|
|
||||||
|
|
||||||
} else if (msg.type === 'slot') {
|
|
||||||
// Update slot info in current call
|
|
||||||
} else if (msg.type === 'raw') {
|
|
||||||
// Raw DSD output — triggers synthesizer activity via dmrSynthPulse
|
|
||||||
} else if (msg.type === 'heartbeat') {
|
|
||||||
// Decoder is alive and listening — keep synthesizer in listening state
|
|
||||||
if (isDmrRunning && dmrSynthInitialized) {
|
|
||||||
if (dmrEventType === 'idle' || dmrEventType === 'raw') {
|
|
||||||
dmrEventType = 'raw';
|
|
||||||
dmrActivityTarget = Math.max(dmrActivityTarget, 0.15);
|
|
||||||
dmrLastEventTime = Date.now();
|
|
||||||
updateDmrSynthStatus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (msg.type === 'status') {
|
|
||||||
const statusEl = document.getElementById('dmrStatus');
|
|
||||||
if (msg.text === 'started') {
|
|
||||||
if (statusEl) statusEl.textContent = 'DECODING';
|
|
||||||
} else if (msg.text === 'crashed') {
|
|
||||||
isDmrRunning = false;
|
|
||||||
stopDmrAudio();
|
|
||||||
updateDmrUI();
|
|
||||||
dmrEventType = 'stopped';
|
|
||||||
dmrActivityTarget = 0;
|
|
||||||
updateDmrSynthStatus();
|
|
||||||
updateDmrAudioStatus('OFF');
|
|
||||||
if (statusEl) statusEl.textContent = 'CRASHED';
|
|
||||||
if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel);
|
|
||||||
const detail = msg.detail || `Decoder exited (code ${msg.exit_code})`;
|
|
||||||
if (typeof showNotification === 'function') {
|
|
||||||
showNotification('DMR Error', detail);
|
|
||||||
}
|
|
||||||
} else if (msg.text === 'stopped') {
|
|
||||||
isDmrRunning = false;
|
|
||||||
stopDmrAudio();
|
|
||||||
updateDmrUI();
|
|
||||||
dmrEventType = 'stopped';
|
|
||||||
dmrActivityTarget = 0;
|
|
||||||
updateDmrSynthStatus();
|
|
||||||
updateDmrAudioStatus('OFF');
|
|
||||||
if (statusEl) statusEl.textContent = 'STOPPED';
|
|
||||||
if (typeof releaseDevice === 'function') releaseDevice(dmrModeLabel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============== UI ==============
|
|
||||||
|
|
||||||
function updateDmrUI() {
|
|
||||||
const startBtn = document.getElementById('startDmrBtn');
|
|
||||||
const stopBtn = document.getElementById('stopDmrBtn');
|
|
||||||
if (startBtn) startBtn.style.display = isDmrRunning ? 'none' : 'block';
|
|
||||||
if (stopBtn) stopBtn.style.display = isDmrRunning ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDmrHistory() {
|
|
||||||
const container = document.getElementById('dmrHistoryBody');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const historyCountEl = document.getElementById('dmrHistoryCount');
|
|
||||||
if (historyCountEl) historyCountEl.textContent = `${dmrCallHistory.length} calls`;
|
|
||||||
|
|
||||||
if (dmrCallHistory.length === 0) {
|
|
||||||
container.innerHTML = '<tr><td colspan="4" style="padding: 10px; text-align: center; color: var(--text-muted);">No calls recorded</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = dmrCallHistory.slice(0, 20).map(call => `
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 3px 6px; font-family: var(--font-mono);">${call.time}</td>
|
|
||||||
<td style="padding: 3px 6px; color: var(--accent-green);">${call.talkgroup}</td>
|
|
||||||
<td style="padding: 3px 6px; color: var(--accent-cyan);">${call.source_id}</td>
|
|
||||||
<td style="padding: 3px 6px;">${call.protocol}</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============== SYNTHESIZER ==============
|
|
||||||
|
|
||||||
function initDmrSynthesizer() {
|
|
||||||
dmrSynthCanvas = document.getElementById('dmrSynthCanvas');
|
|
||||||
if (!dmrSynthCanvas) return;
|
|
||||||
|
|
||||||
// Use the canvas element's own rendered size for the backing buffer
|
|
||||||
const rect = dmrSynthCanvas.getBoundingClientRect();
|
|
||||||
const w = Math.round(rect.width) || 600;
|
|
||||||
const h = Math.round(rect.height) || 70;
|
|
||||||
dmrSynthCanvas.width = w;
|
|
||||||
dmrSynthCanvas.height = h;
|
|
||||||
|
|
||||||
dmrSynthCtx = dmrSynthCanvas.getContext('2d');
|
|
||||||
|
|
||||||
dmrSynthBars = [];
|
|
||||||
for (let i = 0; i < DMR_BAR_COUNT; i++) {
|
|
||||||
dmrSynthBars[i] = { height: 2, targetHeight: 2, velocity: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
dmrActivityLevel = 0;
|
|
||||||
dmrActivityTarget = 0;
|
|
||||||
dmrEventType = isDmrRunning ? 'idle' : 'stopped';
|
|
||||||
dmrSynthInitialized = true;
|
|
||||||
|
|
||||||
updateDmrSynthStatus();
|
|
||||||
|
|
||||||
if (dmrSynthAnimationId) cancelAnimationFrame(dmrSynthAnimationId);
|
|
||||||
drawDmrSynthesizer();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawDmrSynthesizer() {
|
|
||||||
if (!dmrSynthCtx || !dmrSynthCanvas) return;
|
|
||||||
|
|
||||||
const width = dmrSynthCanvas.width;
|
|
||||||
const height = dmrSynthCanvas.height;
|
|
||||||
const barWidth = (width / DMR_BAR_COUNT) - 2;
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
// Clear canvas
|
|
||||||
dmrSynthCtx.fillStyle = 'rgba(0, 0, 0, 0.3)';
|
|
||||||
dmrSynthCtx.fillRect(0, 0, width, height);
|
|
||||||
|
|
||||||
// Decay activity toward target. Window must exceed the backend
|
|
||||||
// heartbeat interval (3s) so the status doesn't flip-flop between
|
|
||||||
// LISTENING and IDLE on every heartbeat cycle.
|
|
||||||
const timeSinceEvent = now - dmrLastEventTime;
|
|
||||||
if (timeSinceEvent > 5000) {
|
|
||||||
// No events for 5s — decay target toward idle
|
|
||||||
dmrActivityTarget = Math.max(0, dmrActivityTarget - DMR_DECAY_RATE);
|
|
||||||
if (dmrActivityTarget < 0.1 && dmrEventType !== 'stopped') {
|
|
||||||
dmrEventType = 'idle';
|
|
||||||
updateDmrSynthStatus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Smooth approach to target
|
|
||||||
dmrActivityLevel += (dmrActivityTarget - dmrActivityLevel) * 0.08;
|
|
||||||
|
|
||||||
// Determine effective activity (idle breathing when stopped/idle)
|
|
||||||
let effectiveActivity = dmrActivityLevel;
|
|
||||||
if (dmrEventType === 'stopped') {
|
|
||||||
effectiveActivity = 0;
|
|
||||||
} else if (effectiveActivity < 0.1 && isDmrRunning) {
|
|
||||||
// Visible idle breathing — shows decoder is alive and listening
|
|
||||||
effectiveActivity = 0.12 + Math.sin(now / 1000) * 0.06;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ripple timing for sync events
|
|
||||||
const syncRippleAge = (dmrEventType === 'sync' && timeSinceEvent < 500) ? 1 - (timeSinceEvent / 500) : 0;
|
|
||||||
// Voice ripple overlay
|
|
||||||
const voiceRipple = (dmrEventType === 'voice') ? Math.sin(now / 60) * 0.15 : 0;
|
|
||||||
|
|
||||||
// Update bar targets and physics
|
|
||||||
for (let i = 0; i < DMR_BAR_COUNT; i++) {
|
|
||||||
const time = now / 200;
|
|
||||||
const wave1 = Math.sin(time + i * 0.3) * 0.2;
|
|
||||||
const wave2 = Math.sin(time * 1.7 + i * 0.5) * 0.15;
|
|
||||||
const randomAmount = 0.05 + effectiveActivity * 0.25;
|
|
||||||
const random = (Math.random() - 0.5) * randomAmount;
|
|
||||||
|
|
||||||
// Bell curve — center bars taller
|
|
||||||
const centerDist = Math.abs(i - DMR_BAR_COUNT / 2) / (DMR_BAR_COUNT / 2);
|
|
||||||
const centerBoost = 1 - centerDist * 0.5;
|
|
||||||
|
|
||||||
// Sync ripple: center-outward wave burst
|
|
||||||
let rippleBoost = 0;
|
|
||||||
if (syncRippleAge > 0) {
|
|
||||||
const ripplePos = (1 - syncRippleAge) * DMR_BAR_COUNT / 2;
|
|
||||||
const distFromRipple = Math.abs(i - DMR_BAR_COUNT / 2) - ripplePos;
|
|
||||||
rippleBoost = Math.max(0, 1 - Math.abs(distFromRipple) / 4) * syncRippleAge * 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseHeight = 0.1 + effectiveActivity * 0.55;
|
|
||||||
dmrSynthBars[i].targetHeight = Math.max(2,
|
|
||||||
(baseHeight + wave1 + wave2 + random + rippleBoost + voiceRipple) *
|
|
||||||
effectiveActivity * centerBoost * height
|
|
||||||
);
|
|
||||||
|
|
||||||
// Spring physics
|
|
||||||
const springStrength = effectiveActivity > 0.3 ? 0.15 : 0.1;
|
|
||||||
const diff = dmrSynthBars[i].targetHeight - dmrSynthBars[i].height;
|
|
||||||
dmrSynthBars[i].velocity += diff * springStrength;
|
|
||||||
dmrSynthBars[i].velocity *= 0.78;
|
|
||||||
dmrSynthBars[i].height += dmrSynthBars[i].velocity;
|
|
||||||
dmrSynthBars[i].height = Math.max(2, Math.min(height - 4, dmrSynthBars[i].height));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw bars
|
|
||||||
for (let i = 0; i < DMR_BAR_COUNT; i++) {
|
|
||||||
const x = i * (barWidth + 2) + 1;
|
|
||||||
const barHeight = dmrSynthBars[i].height;
|
|
||||||
const y = (height - barHeight) / 2;
|
|
||||||
|
|
||||||
// HSL color by event type
|
|
||||||
let hue, saturation, lightness;
|
|
||||||
if (dmrEventType === 'voice' && timeSinceEvent < 3000) {
|
|
||||||
hue = 30; // Orange
|
|
||||||
saturation = 85;
|
|
||||||
lightness = 40 + (barHeight / height) * 25;
|
|
||||||
} else if (dmrEventType === 'call' && timeSinceEvent < 3000) {
|
|
||||||
hue = 120; // Green
|
|
||||||
saturation = 80;
|
|
||||||
lightness = 35 + (barHeight / height) * 30;
|
|
||||||
} else if (dmrEventType === 'sync' && timeSinceEvent < 2000) {
|
|
||||||
hue = 185; // Cyan
|
|
||||||
saturation = 85;
|
|
||||||
lightness = 38 + (barHeight / height) * 25;
|
|
||||||
} else if (dmrEventType === 'stopped') {
|
|
||||||
hue = 220;
|
|
||||||
saturation = 20;
|
|
||||||
lightness = 18 + (barHeight / height) * 8;
|
|
||||||
} else {
|
|
||||||
// Idle / decayed
|
|
||||||
hue = 210;
|
|
||||||
saturation = 40;
|
|
||||||
lightness = 25 + (barHeight / height) * 15;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vertical gradient per bar
|
|
||||||
const gradient = dmrSynthCtx.createLinearGradient(x, y, x, y + barHeight);
|
|
||||||
gradient.addColorStop(0, `hsla(${hue}, ${saturation}%, ${lightness + 18}%, 0.85)`);
|
|
||||||
gradient.addColorStop(0.5, `hsla(${hue}, ${saturation}%, ${lightness}%, 1)`);
|
|
||||||
gradient.addColorStop(1, `hsla(${hue}, ${saturation}%, ${lightness + 18}%, 0.85)`);
|
|
||||||
|
|
||||||
dmrSynthCtx.fillStyle = gradient;
|
|
||||||
dmrSynthCtx.fillRect(x, y, barWidth, barHeight);
|
|
||||||
|
|
||||||
// Glow on tall bars
|
|
||||||
if (barHeight > height * 0.5 && effectiveActivity > 0.4) {
|
|
||||||
dmrSynthCtx.shadowColor = `hsla(${hue}, ${saturation}%, 60%, 0.5)`;
|
|
||||||
dmrSynthCtx.shadowBlur = 8;
|
|
||||||
dmrSynthCtx.fillRect(x, y, barWidth, barHeight);
|
|
||||||
dmrSynthCtx.shadowBlur = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Center line
|
|
||||||
dmrSynthCtx.strokeStyle = 'rgba(0, 212, 255, 0.15)';
|
|
||||||
dmrSynthCtx.lineWidth = 1;
|
|
||||||
dmrSynthCtx.beginPath();
|
|
||||||
dmrSynthCtx.moveTo(0, height / 2);
|
|
||||||
dmrSynthCtx.lineTo(width, height / 2);
|
|
||||||
dmrSynthCtx.stroke();
|
|
||||||
|
|
||||||
dmrSynthAnimationId = requestAnimationFrame(drawDmrSynthesizer);
|
|
||||||
}
|
|
||||||
|
|
||||||
function dmrSynthPulse(type) {
|
|
||||||
dmrLastEventTime = Date.now();
|
|
||||||
|
|
||||||
if (type === 'sync') {
|
|
||||||
dmrActivityTarget = Math.max(dmrActivityTarget, DMR_BURST_SYNC);
|
|
||||||
dmrEventType = 'sync';
|
|
||||||
} else if (type === 'call') {
|
|
||||||
dmrActivityTarget = DMR_BURST_CALL;
|
|
||||||
dmrEventType = 'call';
|
|
||||||
} else if (type === 'voice') {
|
|
||||||
dmrActivityTarget = DMR_BURST_VOICE;
|
|
||||||
dmrEventType = 'voice';
|
|
||||||
} else if (type === 'slot' || type === 'nac') {
|
|
||||||
dmrActivityTarget = Math.max(dmrActivityTarget, 0.5);
|
|
||||||
} else if (type === 'raw') {
|
|
||||||
// Any DSD output means the decoder is alive and processing
|
|
||||||
dmrActivityTarget = Math.max(dmrActivityTarget, 0.25);
|
|
||||||
if (dmrEventType === 'idle') dmrEventType = 'raw';
|
|
||||||
}
|
|
||||||
// keepalive and status don't change visuals
|
|
||||||
|
|
||||||
updateDmrSynthStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateDmrSynthStatus() {
|
|
||||||
const el = document.getElementById('dmrSynthStatus');
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
const labels = {
|
|
||||||
stopped: 'STOPPED',
|
|
||||||
idle: 'IDLE',
|
|
||||||
raw: 'LISTENING',
|
|
||||||
sync: 'SYNC',
|
|
||||||
call: 'CALL',
|
|
||||||
voice: 'VOICE'
|
|
||||||
};
|
|
||||||
const colors = {
|
|
||||||
stopped: 'var(--text-muted)',
|
|
||||||
idle: 'var(--text-muted)',
|
|
||||||
raw: '#607d8b',
|
|
||||||
sync: '#00e5ff',
|
|
||||||
call: '#4caf50',
|
|
||||||
voice: '#ff9800'
|
|
||||||
};
|
|
||||||
|
|
||||||
el.textContent = labels[dmrEventType] || 'IDLE';
|
|
||||||
el.style.color = colors[dmrEventType] || 'var(--text-muted)';
|
|
||||||
}
|
|
||||||
|
|
||||||
function resizeDmrSynthesizer() {
|
|
||||||
if (!dmrSynthCanvas) return;
|
|
||||||
const rect = dmrSynthCanvas.getBoundingClientRect();
|
|
||||||
if (rect.width > 0) {
|
|
||||||
dmrSynthCanvas.width = Math.round(rect.width);
|
|
||||||
dmrSynthCanvas.height = Math.round(rect.height) || 70;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopDmrSynthesizer() {
|
|
||||||
if (dmrSynthAnimationId) {
|
|
||||||
cancelAnimationFrame(dmrSynthAnimationId);
|
|
||||||
dmrSynthAnimationId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('resize', resizeDmrSynthesizer);
|
|
||||||
|
|
||||||
// ============== AUDIO ==============
|
|
||||||
|
|
||||||
function startDmrAudio() {
|
|
||||||
const audioPlayer = document.getElementById('dmrAudioPlayer');
|
|
||||||
if (!audioPlayer) return;
|
|
||||||
const streamUrl = `/dmr/audio/stream?t=${Date.now()}`;
|
|
||||||
audioPlayer.src = streamUrl;
|
|
||||||
const volSlider = document.getElementById('dmrAudioVolume');
|
|
||||||
if (volSlider) audioPlayer.volume = volSlider.value / 100;
|
|
||||||
|
|
||||||
audioPlayer.onplaying = () => updateDmrAudioStatus('STREAMING');
|
|
||||||
audioPlayer.onerror = () => {
|
|
||||||
// Retry if decoder is still running (stream may have dropped)
|
|
||||||
if (isDmrRunning && dmrHasAudio) {
|
|
||||||
console.warn('[DMR] Audio stream error, retrying in 2s...');
|
|
||||||
updateDmrAudioStatus('RECONNECTING');
|
|
||||||
setTimeout(() => {
|
|
||||||
if (isDmrRunning && dmrHasAudio) startDmrAudio();
|
|
||||||
}, 2000);
|
|
||||||
} else {
|
|
||||||
updateDmrAudioStatus('OFF');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
audioPlayer.play().catch(e => {
|
|
||||||
console.warn('[DMR] Audio autoplay blocked:', e);
|
|
||||||
if (typeof showNotification === 'function') {
|
|
||||||
showNotification('Audio Ready', 'Click the page or interact to enable audio playback');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopDmrAudio() {
|
|
||||||
const audioPlayer = document.getElementById('dmrAudioPlayer');
|
|
||||||
if (audioPlayer) {
|
|
||||||
audioPlayer.pause();
|
|
||||||
audioPlayer.src = '';
|
|
||||||
}
|
|
||||||
dmrHasAudio = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setDmrAudioVolume(value) {
|
|
||||||
const audioPlayer = document.getElementById('dmrAudioPlayer');
|
|
||||||
if (audioPlayer) audioPlayer.volume = value / 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateDmrAudioStatus(status) {
|
|
||||||
const el = document.getElementById('dmrAudioStatus');
|
|
||||||
if (!el) return;
|
|
||||||
el.textContent = status;
|
|
||||||
const colors = {
|
|
||||||
'OFF': 'var(--text-muted)',
|
|
||||||
'STREAMING': 'var(--accent-green)',
|
|
||||||
'ERROR': 'var(--accent-red)',
|
|
||||||
'UNAVAILABLE': 'var(--text-muted)',
|
|
||||||
};
|
|
||||||
el.style.color = colors[status] || 'var(--text-muted)';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============== SETTINGS PERSISTENCE ==============
|
|
||||||
|
|
||||||
function restoreDmrSettings() {
|
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem(DMR_SETTINGS_KEY);
|
|
||||||
if (!saved) return;
|
|
||||||
const s = JSON.parse(saved);
|
|
||||||
const freqEl = document.getElementById('dmrFrequency');
|
|
||||||
const protoEl = document.getElementById('dmrProtocol');
|
|
||||||
const gainEl = document.getElementById('dmrGain');
|
|
||||||
const ppmEl = document.getElementById('dmrPPM');
|
|
||||||
const crcEl = document.getElementById('dmrRelaxCrc');
|
|
||||||
if (freqEl && s.frequency != null) freqEl.value = s.frequency;
|
|
||||||
if (protoEl && s.protocol) protoEl.value = s.protocol;
|
|
||||||
if (gainEl && s.gain != null) gainEl.value = s.gain;
|
|
||||||
if (ppmEl && s.ppm != null) ppmEl.value = s.ppm;
|
|
||||||
if (crcEl && s.relaxCrc != null) crcEl.checked = s.relaxCrc;
|
|
||||||
} catch (e) { /* localStorage unavailable */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============== BOOKMARKS ==============
|
|
||||||
|
|
||||||
function loadDmrBookmarks() {
|
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem(DMR_BOOKMARKS_KEY);
|
|
||||||
const parsed = saved ? JSON.parse(saved) : [];
|
|
||||||
if (!Array.isArray(parsed)) {
|
|
||||||
dmrBookmarks = [];
|
|
||||||
} else {
|
|
||||||
dmrBookmarks = parsed
|
|
||||||
.map((entry) => {
|
|
||||||
const freq = Number(entry?.freq);
|
|
||||||
if (!Number.isFinite(freq) || freq <= 0) return null;
|
|
||||||
const protocol = sanitizeDmrBookmarkProtocol(entry?.protocol);
|
|
||||||
const rawLabel = String(entry?.label || '').trim();
|
|
||||||
const label = rawLabel || `${freq.toFixed(4)} MHz`;
|
|
||||||
return {
|
|
||||||
freq,
|
|
||||||
protocol,
|
|
||||||
label,
|
|
||||||
added: entry?.added,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
dmrBookmarks = [];
|
|
||||||
}
|
|
||||||
renderDmrBookmarks();
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveDmrBookmarks() {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(DMR_BOOKMARKS_KEY, JSON.stringify(dmrBookmarks));
|
|
||||||
} catch (e) { /* localStorage unavailable */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeDmrBookmarkProtocol(protocol) {
|
|
||||||
const value = String(protocol || 'auto').toLowerCase();
|
|
||||||
return DMR_BOOKMARK_PROTOCOLS.has(value) ? value : 'auto';
|
|
||||||
}
|
|
||||||
|
|
||||||
function addDmrBookmark() {
|
|
||||||
const freqInput = document.getElementById('dmrBookmarkFreq');
|
|
||||||
const labelInput = document.getElementById('dmrBookmarkLabel');
|
|
||||||
if (!freqInput) return;
|
|
||||||
|
|
||||||
const freq = parseFloat(freqInput.value);
|
|
||||||
if (isNaN(freq) || freq <= 0) {
|
|
||||||
if (typeof showNotification === 'function') {
|
|
||||||
showNotification('Invalid Frequency', 'Enter a valid frequency');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const protocol = sanitizeDmrBookmarkProtocol(document.getElementById('dmrProtocol')?.value || 'auto');
|
|
||||||
const label = (labelInput?.value || '').trim() || `${freq.toFixed(4)} MHz`;
|
|
||||||
|
|
||||||
// Duplicate check
|
|
||||||
if (dmrBookmarks.some(b => b.freq === freq && b.protocol === protocol)) {
|
|
||||||
if (typeof showNotification === 'function') {
|
|
||||||
showNotification('Duplicate', 'This frequency/protocol is already bookmarked');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dmrBookmarks.push({ freq, protocol, label, added: new Date().toISOString() });
|
|
||||||
saveDmrBookmarks();
|
|
||||||
renderDmrBookmarks();
|
|
||||||
freqInput.value = '';
|
|
||||||
if (labelInput) labelInput.value = '';
|
|
||||||
|
|
||||||
if (typeof showNotification === 'function') {
|
|
||||||
showNotification('Bookmark Added', `${freq.toFixed(4)} MHz saved`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addCurrentDmrFreqBookmark() {
|
|
||||||
const freqEl = document.getElementById('dmrFrequency');
|
|
||||||
const freqInput = document.getElementById('dmrBookmarkFreq');
|
|
||||||
if (freqEl && freqInput) {
|
|
||||||
freqInput.value = freqEl.value;
|
|
||||||
}
|
|
||||||
addDmrBookmark();
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeDmrBookmark(index) {
|
|
||||||
dmrBookmarks.splice(index, 1);
|
|
||||||
saveDmrBookmarks();
|
|
||||||
renderDmrBookmarks();
|
|
||||||
}
|
|
||||||
|
|
||||||
function dmrQuickTune(freq, protocol) {
|
|
||||||
const freqEl = document.getElementById('dmrFrequency');
|
|
||||||
const protoEl = document.getElementById('dmrProtocol');
|
|
||||||
if (freqEl && Number.isFinite(freq)) freqEl.value = freq;
|
|
||||||
if (protoEl) protoEl.value = sanitizeDmrBookmarkProtocol(protocol);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDmrBookmarks() {
|
|
||||||
const container = document.getElementById('dmrBookmarksList');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
container.replaceChildren();
|
|
||||||
|
|
||||||
if (dmrBookmarks.length === 0) {
|
|
||||||
const emptyEl = document.createElement('div');
|
|
||||||
emptyEl.style.color = 'var(--text-muted)';
|
|
||||||
emptyEl.style.textAlign = 'center';
|
|
||||||
emptyEl.style.padding = '10px';
|
|
||||||
emptyEl.style.fontSize = '11px';
|
|
||||||
emptyEl.textContent = 'No bookmarks saved';
|
|
||||||
container.appendChild(emptyEl);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dmrBookmarks.forEach((b, i) => {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.style.display = 'flex';
|
|
||||||
row.style.justifyContent = 'space-between';
|
|
||||||
row.style.alignItems = 'center';
|
|
||||||
row.style.padding = '4px 6px';
|
|
||||||
row.style.background = 'rgba(0,0,0,0.2)';
|
|
||||||
row.style.borderRadius = '3px';
|
|
||||||
row.style.marginBottom = '3px';
|
|
||||||
|
|
||||||
const tuneBtn = document.createElement('button');
|
|
||||||
tuneBtn.type = 'button';
|
|
||||||
tuneBtn.style.cursor = 'pointer';
|
|
||||||
tuneBtn.style.color = 'var(--accent-cyan)';
|
|
||||||
tuneBtn.style.fontSize = '11px';
|
|
||||||
tuneBtn.style.flex = '1';
|
|
||||||
tuneBtn.style.background = 'none';
|
|
||||||
tuneBtn.style.border = 'none';
|
|
||||||
tuneBtn.style.textAlign = 'left';
|
|
||||||
tuneBtn.style.padding = '0';
|
|
||||||
tuneBtn.textContent = b.label;
|
|
||||||
tuneBtn.title = `${b.freq.toFixed(4)} MHz (${b.protocol.toUpperCase()})`;
|
|
||||||
tuneBtn.addEventListener('click', () => dmrQuickTune(b.freq, b.protocol));
|
|
||||||
|
|
||||||
const protocolEl = document.createElement('span');
|
|
||||||
protocolEl.style.color = 'var(--text-muted)';
|
|
||||||
protocolEl.style.fontSize = '9px';
|
|
||||||
protocolEl.style.margin = '0 6px';
|
|
||||||
protocolEl.textContent = b.protocol.toUpperCase();
|
|
||||||
|
|
||||||
const deleteBtn = document.createElement('button');
|
|
||||||
deleteBtn.type = 'button';
|
|
||||||
deleteBtn.style.background = 'none';
|
|
||||||
deleteBtn.style.border = 'none';
|
|
||||||
deleteBtn.style.color = 'var(--accent-red)';
|
|
||||||
deleteBtn.style.cursor = 'pointer';
|
|
||||||
deleteBtn.style.fontSize = '12px';
|
|
||||||
deleteBtn.style.padding = '0 4px';
|
|
||||||
deleteBtn.textContent = '\u00d7';
|
|
||||||
deleteBtn.addEventListener('click', () => removeDmrBookmark(i));
|
|
||||||
|
|
||||||
row.appendChild(tuneBtn);
|
|
||||||
row.appendChild(protocolEl);
|
|
||||||
row.appendChild(deleteBtn);
|
|
||||||
container.appendChild(row);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============== STATUS SYNC ==============
|
|
||||||
|
|
||||||
function checkDmrStatus() {
|
|
||||||
fetch('/dmr/status')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.running && !isDmrRunning) {
|
|
||||||
// Backend is running but frontend lost track — resync
|
|
||||||
isDmrRunning = true;
|
|
||||||
updateDmrUI();
|
|
||||||
connectDmrSSE();
|
|
||||||
if (!dmrSynthInitialized) initDmrSynthesizer();
|
|
||||||
dmrEventType = 'idle';
|
|
||||||
dmrActivityTarget = 0.1;
|
|
||||||
dmrLastEventTime = Date.now();
|
|
||||||
updateDmrSynthStatus();
|
|
||||||
const statusEl = document.getElementById('dmrStatus');
|
|
||||||
if (statusEl) statusEl.textContent = 'DECODING';
|
|
||||||
} else if (!data.running && isDmrRunning) {
|
|
||||||
// Backend stopped but frontend didn't know
|
|
||||||
isDmrRunning = false;
|
|
||||||
if (dmrEventSource) { dmrEventSource.close(); dmrEventSource = null; }
|
|
||||||
updateDmrUI();
|
|
||||||
dmrEventType = 'stopped';
|
|
||||||
dmrActivityTarget = 0;
|
|
||||||
updateDmrSynthStatus();
|
|
||||||
const statusEl = document.getElementById('dmrStatus');
|
|
||||||
if (statusEl) statusEl.textContent = 'STOPPED';
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============== INIT ==============
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
restoreDmrSettings();
|
|
||||||
loadDmrBookmarks();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============== EXPORTS ==============
|
|
||||||
|
|
||||||
window.startDmr = startDmr;
|
|
||||||
window.stopDmr = stopDmr;
|
|
||||||
window.checkDmrTools = checkDmrTools;
|
|
||||||
window.checkDmrStatus = checkDmrStatus;
|
|
||||||
window.initDmrSynthesizer = initDmrSynthesizer;
|
|
||||||
window.setDmrAudioVolume = setDmrAudioVolume;
|
|
||||||
window.addDmrBookmark = addDmrBookmark;
|
|
||||||
window.addCurrentDmrFreqBookmark = addCurrentDmrFreqBookmark;
|
|
||||||
window.removeDmrBookmark = removeDmrBookmark;
|
|
||||||
window.dmrQuickTune = dmrQuickTune;
|
|
||||||
+671
-28
@@ -9,6 +9,9 @@ const GPS = (function() {
|
|||||||
let lastPosition = null;
|
let lastPosition = null;
|
||||||
let lastSky = null;
|
let lastSky = null;
|
||||||
let skyPollTimer = null;
|
let skyPollTimer = null;
|
||||||
|
let themeObserver = null;
|
||||||
|
let skyRenderer = null;
|
||||||
|
let skyRendererInitAttempted = false;
|
||||||
|
|
||||||
// Constellation color map
|
// Constellation color map
|
||||||
const CONST_COLORS = {
|
const CONST_COLORS = {
|
||||||
@@ -21,18 +24,43 @@ const GPS = (function() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
|
initSkyRenderer();
|
||||||
drawEmptySkyView();
|
drawEmptySkyView();
|
||||||
connect();
|
if (!connected) connect();
|
||||||
|
|
||||||
// Redraw sky view when theme changes
|
// Redraw sky view when theme changes
|
||||||
const observer = new MutationObserver(() => {
|
if (!themeObserver) {
|
||||||
if (lastSky) {
|
themeObserver = new MutationObserver(() => {
|
||||||
drawSkyView(lastSky.satellites || []);
|
if (skyRenderer && typeof skyRenderer.requestRender === 'function') {
|
||||||
} else {
|
skyRenderer.requestRender();
|
||||||
drawEmptySkyView();
|
}
|
||||||
}
|
if (lastSky) {
|
||||||
});
|
drawSkyView(lastSky.satellites || []);
|
||||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
} else {
|
||||||
|
drawEmptySkyView();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastPosition) updatePositionUI(lastPosition);
|
||||||
|
if (lastSky) updateSkyUI(lastSky);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSkyRenderer() {
|
||||||
|
if (skyRendererInitAttempted) return;
|
||||||
|
skyRendererInitAttempted = true;
|
||||||
|
|
||||||
|
const canvas = document.getElementById('gpsSkyCanvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const overlay = document.getElementById('gpsSkyOverlay');
|
||||||
|
try {
|
||||||
|
skyRenderer = createWebGlSkyRenderer(canvas, overlay);
|
||||||
|
} catch (err) {
|
||||||
|
skyRenderer = null;
|
||||||
|
console.warn('GPS sky WebGL renderer failed, falling back to 2D', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
@@ -253,41 +281,61 @@ const GPS = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ========================
|
// ========================
|
||||||
// Sky View Polar Plot
|
// Sky View Globe (WebGL with 2D fallback)
|
||||||
// ========================
|
// ========================
|
||||||
|
|
||||||
function drawEmptySkyView() {
|
function drawEmptySkyView() {
|
||||||
|
if (!skyRendererInitAttempted) {
|
||||||
|
initSkyRenderer();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skyRenderer) {
|
||||||
|
skyRenderer.setSatellites([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const canvas = document.getElementById('gpsSkyCanvas');
|
const canvas = document.getElementById('gpsSkyCanvas');
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
drawSkyViewBase(canvas);
|
drawSkyViewBase2D(canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawSkyView(satellites) {
|
function drawSkyView(satellites) {
|
||||||
|
if (!skyRendererInitAttempted) {
|
||||||
|
initSkyRenderer();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sats = Array.isArray(satellites) ? satellites : [];
|
||||||
|
|
||||||
|
if (skyRenderer) {
|
||||||
|
skyRenderer.setSatellites(sats);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const canvas = document.getElementById('gpsSkyCanvas');
|
const canvas = document.getElementById('gpsSkyCanvas');
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
|
drawSkyViewBase2D(canvas);
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
const w = canvas.width;
|
const w = canvas.width;
|
||||||
const h = canvas.height;
|
const h = canvas.height;
|
||||||
const cx = w / 2;
|
const cx = w / 2;
|
||||||
const cy = h / 2;
|
const cy = h / 2;
|
||||||
const r = Math.min(cx, cy) - 24;
|
const r = Math.min(cx, cy) - 24;
|
||||||
|
|
||||||
drawSkyViewBase(canvas);
|
sats.forEach(sat => {
|
||||||
|
|
||||||
// Plot satellites
|
|
||||||
satellites.forEach(sat => {
|
|
||||||
if (sat.elevation == null || sat.azimuth == null) return;
|
if (sat.elevation == null || sat.azimuth == null) return;
|
||||||
|
|
||||||
const elRad = (90 - sat.elevation) / 90;
|
const elRad = (90 - sat.elevation) / 90;
|
||||||
const azRad = (sat.azimuth - 90) * Math.PI / 180; // N = up
|
const azRad = (sat.azimuth - 90) * Math.PI / 180;
|
||||||
const px = cx + r * elRad * Math.cos(azRad);
|
const px = cx + r * elRad * Math.cos(azRad);
|
||||||
const py = cy + r * elRad * Math.sin(azRad);
|
const py = cy + r * elRad * Math.sin(azRad);
|
||||||
|
|
||||||
const color = CONST_COLORS[sat.constellation] || CONST_COLORS['GPS'];
|
const color = CONST_COLORS[sat.constellation] || CONST_COLORS.GPS;
|
||||||
const dotSize = sat.used ? 6 : 4;
|
const dotSize = sat.used ? 6 : 4;
|
||||||
|
|
||||||
// Draw dot
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
|
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
|
||||||
if (sat.used) {
|
if (sat.used) {
|
||||||
@@ -299,14 +347,12 @@ const GPS = (function() {
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
// PRN label
|
|
||||||
ctx.fillStyle = color;
|
ctx.fillStyle = color;
|
||||||
ctx.font = '8px Roboto Condensed, monospace';
|
ctx.font = '8px Roboto Condensed, monospace';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'bottom';
|
ctx.textBaseline = 'bottom';
|
||||||
ctx.fillText(sat.prn, px, py - dotSize - 2);
|
ctx.fillText(sat.prn, px, py - dotSize - 2);
|
||||||
|
|
||||||
// SNR value
|
|
||||||
if (sat.snr != null) {
|
if (sat.snr != null) {
|
||||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||||
ctx.font = '7px Roboto Condensed, monospace';
|
ctx.font = '7px Roboto Condensed, monospace';
|
||||||
@@ -316,8 +362,10 @@ const GPS = (function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawSkyViewBase(canvas) {
|
function drawSkyViewBase2D(canvas) {
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
const w = canvas.width;
|
const w = canvas.width;
|
||||||
const h = canvas.height;
|
const h = canvas.height;
|
||||||
const cx = w / 2;
|
const cx = w / 2;
|
||||||
@@ -332,11 +380,9 @@ const GPS = (function() {
|
|||||||
const dimColor = cs.getPropertyValue('--text-dim').trim() || '#555';
|
const dimColor = cs.getPropertyValue('--text-dim').trim() || '#555';
|
||||||
const secondaryColor = cs.getPropertyValue('--text-secondary').trim() || '#888';
|
const secondaryColor = cs.getPropertyValue('--text-secondary').trim() || '#888';
|
||||||
|
|
||||||
// Background
|
|
||||||
ctx.fillStyle = bgColor;
|
ctx.fillStyle = bgColor;
|
||||||
ctx.fillRect(0, 0, w, h);
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
// Elevation rings (0, 30, 60, 90)
|
|
||||||
ctx.strokeStyle = gridColor;
|
ctx.strokeStyle = gridColor;
|
||||||
ctx.lineWidth = 0.5;
|
ctx.lineWidth = 0.5;
|
||||||
[90, 60, 30].forEach(el => {
|
[90, 60, 30].forEach(el => {
|
||||||
@@ -344,7 +390,7 @@ const GPS = (function() {
|
|||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(cx, cy, gr, 0, Math.PI * 2);
|
ctx.arc(cx, cy, gr, 0, Math.PI * 2);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
// Label
|
|
||||||
ctx.fillStyle = dimColor;
|
ctx.fillStyle = dimColor;
|
||||||
ctx.font = '9px Roboto Condensed, monospace';
|
ctx.font = '9px Roboto Condensed, monospace';
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
@@ -352,14 +398,12 @@ const GPS = (function() {
|
|||||||
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
|
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Horizon circle
|
|
||||||
ctx.strokeStyle = gridColor;
|
ctx.strokeStyle = gridColor;
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Cardinal directions
|
|
||||||
ctx.fillStyle = secondaryColor;
|
ctx.fillStyle = secondaryColor;
|
||||||
ctx.font = 'bold 11px Roboto Condensed, monospace';
|
ctx.font = 'bold 11px Roboto Condensed, monospace';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
@@ -369,7 +413,6 @@ const GPS = (function() {
|
|||||||
ctx.fillText('E', cx + r + 12, cy);
|
ctx.fillText('E', cx + r + 12, cy);
|
||||||
ctx.fillText('W', cx - r - 12, cy);
|
ctx.fillText('W', cx - r - 12, cy);
|
||||||
|
|
||||||
// Crosshairs
|
|
||||||
ctx.strokeStyle = gridColor;
|
ctx.strokeStyle = gridColor;
|
||||||
ctx.lineWidth = 0.5;
|
ctx.lineWidth = 0.5;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -379,13 +422,604 @@ const GPS = (function() {
|
|||||||
ctx.lineTo(cx + r, cy);
|
ctx.lineTo(cx + r, cy);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Zenith dot
|
|
||||||
ctx.fillStyle = dimColor;
|
ctx.fillStyle = dimColor;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(cx, cy, 2, 0, Math.PI * 2);
|
ctx.arc(cx, cy, 2, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createWebGlSkyRenderer(canvas, overlay) {
|
||||||
|
const gl = canvas.getContext('webgl', { antialias: true, alpha: false, depth: true });
|
||||||
|
if (!gl) return null;
|
||||||
|
|
||||||
|
const lineProgram = createProgram(
|
||||||
|
gl,
|
||||||
|
[
|
||||||
|
'attribute vec3 aPosition;',
|
||||||
|
'uniform mat4 uMVP;',
|
||||||
|
'void main(void) {',
|
||||||
|
' gl_Position = uMVP * vec4(aPosition, 1.0);',
|
||||||
|
'}',
|
||||||
|
].join('\n'),
|
||||||
|
[
|
||||||
|
'precision mediump float;',
|
||||||
|
'uniform vec4 uColor;',
|
||||||
|
'void main(void) {',
|
||||||
|
' gl_FragColor = uColor;',
|
||||||
|
'}',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pointProgram = createProgram(
|
||||||
|
gl,
|
||||||
|
[
|
||||||
|
'attribute vec3 aPosition;',
|
||||||
|
'attribute vec4 aColor;',
|
||||||
|
'attribute float aSize;',
|
||||||
|
'attribute float aUsed;',
|
||||||
|
'uniform mat4 uMVP;',
|
||||||
|
'uniform float uDevicePixelRatio;',
|
||||||
|
'uniform vec3 uCameraDir;',
|
||||||
|
'varying vec4 vColor;',
|
||||||
|
'varying float vUsed;',
|
||||||
|
'varying float vFacing;',
|
||||||
|
'void main(void) {',
|
||||||
|
' vec3 normPos = normalize(aPosition);',
|
||||||
|
' vFacing = dot(normPos, normalize(uCameraDir));',
|
||||||
|
' gl_Position = uMVP * vec4(aPosition, 1.0);',
|
||||||
|
' gl_PointSize = aSize * uDevicePixelRatio;',
|
||||||
|
' vColor = aColor;',
|
||||||
|
' vUsed = aUsed;',
|
||||||
|
'}',
|
||||||
|
].join('\n'),
|
||||||
|
[
|
||||||
|
'precision mediump float;',
|
||||||
|
'varying vec4 vColor;',
|
||||||
|
'varying float vUsed;',
|
||||||
|
'varying float vFacing;',
|
||||||
|
'void main(void) {',
|
||||||
|
' if (vFacing <= 0.0) discard;',
|
||||||
|
' vec2 c = gl_PointCoord * 2.0 - 1.0;',
|
||||||
|
' float d = dot(c, c);',
|
||||||
|
' if (d > 1.0) discard;',
|
||||||
|
' if (vUsed < 0.5 && d < 0.45) discard;',
|
||||||
|
' float edge = smoothstep(1.0, 0.75, d);',
|
||||||
|
' gl_FragColor = vec4(vColor.rgb, vColor.a * edge);',
|
||||||
|
'}',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!lineProgram || !pointProgram) return null;
|
||||||
|
|
||||||
|
const lineLoc = {
|
||||||
|
position: gl.getAttribLocation(lineProgram, 'aPosition'),
|
||||||
|
mvp: gl.getUniformLocation(lineProgram, 'uMVP'),
|
||||||
|
color: gl.getUniformLocation(lineProgram, 'uColor'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const pointLoc = {
|
||||||
|
position: gl.getAttribLocation(pointProgram, 'aPosition'),
|
||||||
|
color: gl.getAttribLocation(pointProgram, 'aColor'),
|
||||||
|
size: gl.getAttribLocation(pointProgram, 'aSize'),
|
||||||
|
used: gl.getAttribLocation(pointProgram, 'aUsed'),
|
||||||
|
mvp: gl.getUniformLocation(pointProgram, 'uMVP'),
|
||||||
|
dpr: gl.getUniformLocation(pointProgram, 'uDevicePixelRatio'),
|
||||||
|
cameraDir: gl.getUniformLocation(pointProgram, 'uCameraDir'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const gridVertices = buildSkyGridVertices();
|
||||||
|
const horizonVertices = buildSkyRingVertices(0, 4);
|
||||||
|
|
||||||
|
const gridBuffer = gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, gridBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, gridVertices, gl.STATIC_DRAW);
|
||||||
|
|
||||||
|
const horizonBuffer = gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, horizonBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, horizonVertices, gl.STATIC_DRAW);
|
||||||
|
|
||||||
|
const satPosBuffer = gl.createBuffer();
|
||||||
|
const satColorBuffer = gl.createBuffer();
|
||||||
|
const satSizeBuffer = gl.createBuffer();
|
||||||
|
const satUsedBuffer = gl.createBuffer();
|
||||||
|
|
||||||
|
let satCount = 0;
|
||||||
|
let satLabels = [];
|
||||||
|
let cssWidth = 0;
|
||||||
|
let cssHeight = 0;
|
||||||
|
let devicePixelRatio = 1;
|
||||||
|
let mvpMatrix = identityMat4();
|
||||||
|
let cameraDir = [0, 1, 0];
|
||||||
|
let yaw = 0.8;
|
||||||
|
let pitch = 0.6;
|
||||||
|
let distance = 2.7;
|
||||||
|
let rafId = null;
|
||||||
|
let destroyed = false;
|
||||||
|
let activePointerId = null;
|
||||||
|
let lastPointerX = 0;
|
||||||
|
let lastPointerY = 0;
|
||||||
|
|
||||||
|
const resizeObserver = (typeof ResizeObserver !== 'undefined')
|
||||||
|
? new ResizeObserver(() => {
|
||||||
|
requestRender();
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
if (resizeObserver) resizeObserver.observe(canvas);
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerdown', onPointerDown);
|
||||||
|
canvas.addEventListener('pointermove', onPointerMove);
|
||||||
|
canvas.addEventListener('pointerup', onPointerUp);
|
||||||
|
canvas.addEventListener('pointercancel', onPointerUp);
|
||||||
|
canvas.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
|
||||||
|
requestRender();
|
||||||
|
|
||||||
|
function onPointerDown(evt) {
|
||||||
|
activePointerId = evt.pointerId;
|
||||||
|
lastPointerX = evt.clientX;
|
||||||
|
lastPointerY = evt.clientY;
|
||||||
|
if (canvas.setPointerCapture) canvas.setPointerCapture(evt.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(evt) {
|
||||||
|
if (activePointerId == null || evt.pointerId !== activePointerId) return;
|
||||||
|
|
||||||
|
const dx = evt.clientX - lastPointerX;
|
||||||
|
const dy = evt.clientY - lastPointerY;
|
||||||
|
lastPointerX = evt.clientX;
|
||||||
|
lastPointerY = evt.clientY;
|
||||||
|
|
||||||
|
yaw += dx * 0.01;
|
||||||
|
pitch += dy * 0.01;
|
||||||
|
pitch = Math.max(0.1, Math.min(1.45, pitch));
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(evt) {
|
||||||
|
if (activePointerId == null || evt.pointerId !== activePointerId) return;
|
||||||
|
if (canvas.releasePointerCapture) {
|
||||||
|
try {
|
||||||
|
canvas.releasePointerCapture(evt.pointerId);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
activePointerId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWheel(evt) {
|
||||||
|
evt.preventDefault();
|
||||||
|
distance += evt.deltaY * 0.002;
|
||||||
|
distance = Math.max(2.0, Math.min(5.0, distance));
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSatellites(satellites) {
|
||||||
|
const positions = [];
|
||||||
|
const colors = [];
|
||||||
|
const sizes = [];
|
||||||
|
const usedFlags = [];
|
||||||
|
const labels = [];
|
||||||
|
|
||||||
|
(satellites || []).forEach(sat => {
|
||||||
|
if (sat.elevation == null || sat.azimuth == null) return;
|
||||||
|
|
||||||
|
const xyz = skyToCartesian(sat.azimuth, sat.elevation);
|
||||||
|
const hex = CONST_COLORS[sat.constellation] || CONST_COLORS.GPS;
|
||||||
|
const rgb = hexToRgb01(hex);
|
||||||
|
|
||||||
|
positions.push(xyz[0], xyz[1], xyz[2]);
|
||||||
|
colors.push(rgb[0], rgb[1], rgb[2], sat.used ? 1 : 0.85);
|
||||||
|
sizes.push(sat.used ? 8 : 7);
|
||||||
|
usedFlags.push(sat.used ? 1 : 0);
|
||||||
|
|
||||||
|
labels.push({
|
||||||
|
text: String(sat.prn),
|
||||||
|
point: xyz,
|
||||||
|
color: hex,
|
||||||
|
used: !!sat.used,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
satLabels = labels;
|
||||||
|
satCount = positions.length / 3;
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, satPosBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.DYNAMIC_DRAW);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, satColorBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(colors), gl.DYNAMIC_DRAW);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, satSizeBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(sizes), gl.DYNAMIC_DRAW);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, satUsedBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(usedFlags), gl.DYNAMIC_DRAW);
|
||||||
|
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestRender() {
|
||||||
|
if (destroyed || rafId != null) return;
|
||||||
|
rafId = requestAnimationFrame(render);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
rafId = null;
|
||||||
|
if (destroyed) return;
|
||||||
|
|
||||||
|
resizeCanvas();
|
||||||
|
updateCameraMatrices();
|
||||||
|
|
||||||
|
const palette = getThemePalette();
|
||||||
|
|
||||||
|
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||||
|
gl.clearColor(palette.bg[0], palette.bg[1], palette.bg[2], 1);
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||||
|
|
||||||
|
gl.enable(gl.DEPTH_TEST);
|
||||||
|
gl.depthFunc(gl.LEQUAL);
|
||||||
|
gl.enable(gl.BLEND);
|
||||||
|
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||||
|
|
||||||
|
gl.useProgram(lineProgram);
|
||||||
|
gl.uniformMatrix4fv(lineLoc.mvp, false, mvpMatrix);
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, gridBuffer);
|
||||||
|
gl.enableVertexAttribArray(lineLoc.position);
|
||||||
|
gl.vertexAttribPointer(lineLoc.position, 3, gl.FLOAT, false, 0, 0);
|
||||||
|
gl.uniform4fv(lineLoc.color, palette.grid);
|
||||||
|
gl.drawArrays(gl.LINES, 0, gridVertices.length / 3);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, horizonBuffer);
|
||||||
|
gl.vertexAttribPointer(lineLoc.position, 3, gl.FLOAT, false, 0, 0);
|
||||||
|
gl.uniform4fv(lineLoc.color, palette.horizon);
|
||||||
|
gl.drawArrays(gl.LINES, 0, horizonVertices.length / 3);
|
||||||
|
|
||||||
|
if (satCount > 0) {
|
||||||
|
gl.useProgram(pointProgram);
|
||||||
|
gl.uniformMatrix4fv(pointLoc.mvp, false, mvpMatrix);
|
||||||
|
gl.uniform1f(pointLoc.dpr, devicePixelRatio);
|
||||||
|
gl.uniform3fv(pointLoc.cameraDir, new Float32Array(cameraDir));
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, satPosBuffer);
|
||||||
|
gl.enableVertexAttribArray(pointLoc.position);
|
||||||
|
gl.vertexAttribPointer(pointLoc.position, 3, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, satColorBuffer);
|
||||||
|
gl.enableVertexAttribArray(pointLoc.color);
|
||||||
|
gl.vertexAttribPointer(pointLoc.color, 4, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, satSizeBuffer);
|
||||||
|
gl.enableVertexAttribArray(pointLoc.size);
|
||||||
|
gl.vertexAttribPointer(pointLoc.size, 1, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, satUsedBuffer);
|
||||||
|
gl.enableVertexAttribArray(pointLoc.used);
|
||||||
|
gl.vertexAttribPointer(pointLoc.used, 1, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
gl.drawArrays(gl.POINTS, 0, satCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawOverlayLabels();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeCanvas() {
|
||||||
|
cssWidth = Math.max(1, Math.floor(canvas.clientWidth || 400));
|
||||||
|
cssHeight = Math.max(1, Math.floor(canvas.clientHeight || 400));
|
||||||
|
devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
|
||||||
|
const renderWidth = Math.floor(cssWidth * devicePixelRatio);
|
||||||
|
const renderHeight = Math.floor(cssHeight * devicePixelRatio);
|
||||||
|
if (canvas.width !== renderWidth || canvas.height !== renderHeight) {
|
||||||
|
canvas.width = renderWidth;
|
||||||
|
canvas.height = renderHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCameraMatrices() {
|
||||||
|
const cosPitch = Math.cos(pitch);
|
||||||
|
const eye = [
|
||||||
|
distance * Math.sin(yaw) * cosPitch,
|
||||||
|
distance * Math.sin(pitch),
|
||||||
|
distance * Math.cos(yaw) * cosPitch,
|
||||||
|
];
|
||||||
|
|
||||||
|
const eyeLen = Math.hypot(eye[0], eye[1], eye[2]) || 1;
|
||||||
|
cameraDir = [eye[0] / eyeLen, eye[1] / eyeLen, eye[2] / eyeLen];
|
||||||
|
|
||||||
|
const view = mat4LookAt(eye, [0, 0, 0], [0, 1, 0]);
|
||||||
|
const proj = mat4Perspective(degToRad(48), Math.max(cssWidth / cssHeight, 0.01), 0.1, 20);
|
||||||
|
mvpMatrix = mat4Multiply(proj, view);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawOverlayLabels() {
|
||||||
|
if (!overlay) return;
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
const cardinals = [
|
||||||
|
{ text: 'N', point: [0, 0, 1] },
|
||||||
|
{ text: 'E', point: [1, 0, 0] },
|
||||||
|
{ text: 'S', point: [0, 0, -1] },
|
||||||
|
{ text: 'W', point: [-1, 0, 0] },
|
||||||
|
{ text: 'Z', point: [0, 1, 0] },
|
||||||
|
];
|
||||||
|
|
||||||
|
cardinals.forEach(entry => {
|
||||||
|
addLabel(fragment, entry.text, entry.point, 'gps-sky-label gps-sky-label-cardinal');
|
||||||
|
});
|
||||||
|
|
||||||
|
satLabels.forEach(sat => {
|
||||||
|
const cls = 'gps-sky-label gps-sky-label-sat' + (sat.used ? '' : ' unused');
|
||||||
|
addLabel(fragment, sat.text, sat.point, cls, sat.color);
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.replaceChildren(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLabel(fragment, text, point, className, color) {
|
||||||
|
const facing = point[0] * cameraDir[0] + point[1] * cameraDir[1] + point[2] * cameraDir[2];
|
||||||
|
if (facing <= 0.02) return;
|
||||||
|
|
||||||
|
const projected = projectPoint(point, mvpMatrix, cssWidth, cssHeight);
|
||||||
|
if (!projected) return;
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = className;
|
||||||
|
label.textContent = text;
|
||||||
|
label.style.left = projected.x.toFixed(1) + 'px';
|
||||||
|
label.style.top = projected.y.toFixed(1) + 'px';
|
||||||
|
if (color) label.style.color = color;
|
||||||
|
fragment.appendChild(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThemePalette() {
|
||||||
|
const cs = getComputedStyle(document.documentElement);
|
||||||
|
const bg = parseCssColor(cs.getPropertyValue('--bg-card').trim(), '#0d1117');
|
||||||
|
const grid = parseCssColor(cs.getPropertyValue('--border-color').trim(), '#3a4254');
|
||||||
|
const accent = parseCssColor(cs.getPropertyValue('--accent-cyan').trim(), '#4aa3ff');
|
||||||
|
|
||||||
|
return {
|
||||||
|
bg: bg,
|
||||||
|
grid: [grid[0], grid[1], grid[2], 0.42],
|
||||||
|
horizon: [accent[0], accent[1], accent[2], 0.56],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
destroyed = true;
|
||||||
|
if (rafId != null) cancelAnimationFrame(rafId);
|
||||||
|
canvas.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
canvas.removeEventListener('pointermove', onPointerMove);
|
||||||
|
canvas.removeEventListener('pointerup', onPointerUp);
|
||||||
|
canvas.removeEventListener('pointercancel', onPointerUp);
|
||||||
|
canvas.removeEventListener('wheel', onWheel);
|
||||||
|
if (resizeObserver) {
|
||||||
|
try {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (overlay) overlay.replaceChildren();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
setSatellites: setSatellites,
|
||||||
|
requestRender: requestRender,
|
||||||
|
destroy: destroy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSkyGridVertices() {
|
||||||
|
const vertices = [];
|
||||||
|
|
||||||
|
[15, 30, 45, 60, 75].forEach(el => {
|
||||||
|
appendLineStrip(vertices, buildRingPoints(el, 6));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let az = 0; az < 360; az += 30) {
|
||||||
|
appendLineStrip(vertices, buildMeridianPoints(az, 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Float32Array(vertices);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSkyRingVertices(elevation, stepAz) {
|
||||||
|
const vertices = [];
|
||||||
|
appendLineStrip(vertices, buildRingPoints(elevation, stepAz));
|
||||||
|
return new Float32Array(vertices);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRingPoints(elevation, stepAz) {
|
||||||
|
const points = [];
|
||||||
|
for (let az = 0; az <= 360; az += stepAz) {
|
||||||
|
points.push(skyToCartesian(az, elevation));
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMeridianPoints(azimuth, stepEl) {
|
||||||
|
const points = [];
|
||||||
|
for (let el = 0; el <= 90; el += stepEl) {
|
||||||
|
points.push(skyToCartesian(azimuth, el));
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLineStrip(target, points) {
|
||||||
|
for (let i = 1; i < points.length; i += 1) {
|
||||||
|
const a = points[i - 1];
|
||||||
|
const b = points[i];
|
||||||
|
target.push(a[0], a[1], a[2], b[0], b[1], b[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function skyToCartesian(azimuthDeg, elevationDeg) {
|
||||||
|
const az = degToRad(azimuthDeg);
|
||||||
|
const el = degToRad(elevationDeg);
|
||||||
|
const cosEl = Math.cos(el);
|
||||||
|
return [
|
||||||
|
cosEl * Math.sin(az),
|
||||||
|
Math.sin(el),
|
||||||
|
cosEl * Math.cos(az),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function degToRad(deg) {
|
||||||
|
return deg * Math.PI / 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProgram(gl, vertexSource, fragmentSource) {
|
||||||
|
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, vertexSource);
|
||||||
|
const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
|
||||||
|
if (!vertexShader || !fragmentShader) return null;
|
||||||
|
|
||||||
|
const program = gl.createProgram();
|
||||||
|
gl.attachShader(program, vertexShader);
|
||||||
|
gl.attachShader(program, fragmentShader);
|
||||||
|
gl.linkProgram(program);
|
||||||
|
|
||||||
|
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||||
|
console.warn('WebGL program link failed:', gl.getProgramInfoLog(program));
|
||||||
|
gl.deleteProgram(program);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileShader(gl, type, source) {
|
||||||
|
const shader = gl.createShader(type);
|
||||||
|
gl.shaderSource(shader, source);
|
||||||
|
gl.compileShader(shader);
|
||||||
|
|
||||||
|
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||||
|
console.warn('WebGL shader compile failed:', gl.getShaderInfoLog(shader));
|
||||||
|
gl.deleteShader(shader);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return shader;
|
||||||
|
}
|
||||||
|
|
||||||
|
function identityMat4() {
|
||||||
|
return new Float32Array([
|
||||||
|
1, 0, 0, 0,
|
||||||
|
0, 1, 0, 0,
|
||||||
|
0, 0, 1, 0,
|
||||||
|
0, 0, 0, 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mat4Perspective(fovy, aspect, near, far) {
|
||||||
|
const f = 1 / Math.tan(fovy / 2);
|
||||||
|
const nf = 1 / (near - far);
|
||||||
|
|
||||||
|
return new Float32Array([
|
||||||
|
f / aspect, 0, 0, 0,
|
||||||
|
0, f, 0, 0,
|
||||||
|
0, 0, (far + near) * nf, -1,
|
||||||
|
0, 0, (2 * far * near) * nf, 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mat4LookAt(eye, center, up) {
|
||||||
|
const zx = eye[0] - center[0];
|
||||||
|
const zy = eye[1] - center[1];
|
||||||
|
const zz = eye[2] - center[2];
|
||||||
|
const zLen = Math.hypot(zx, zy, zz) || 1;
|
||||||
|
const znx = zx / zLen;
|
||||||
|
const zny = zy / zLen;
|
||||||
|
const znz = zz / zLen;
|
||||||
|
|
||||||
|
const xx = up[1] * znz - up[2] * zny;
|
||||||
|
const xy = up[2] * znx - up[0] * znz;
|
||||||
|
const xz = up[0] * zny - up[1] * znx;
|
||||||
|
const xLen = Math.hypot(xx, xy, xz) || 1;
|
||||||
|
const xnx = xx / xLen;
|
||||||
|
const xny = xy / xLen;
|
||||||
|
const xnz = xz / xLen;
|
||||||
|
|
||||||
|
const ynx = zny * xnz - znz * xny;
|
||||||
|
const yny = znz * xnx - znx * xnz;
|
||||||
|
const ynz = znx * xny - zny * xnx;
|
||||||
|
|
||||||
|
return new Float32Array([
|
||||||
|
xnx, ynx, znx, 0,
|
||||||
|
xny, yny, zny, 0,
|
||||||
|
xnz, ynz, znz, 0,
|
||||||
|
-(xnx * eye[0] + xny * eye[1] + xnz * eye[2]),
|
||||||
|
-(ynx * eye[0] + yny * eye[1] + ynz * eye[2]),
|
||||||
|
-(znx * eye[0] + zny * eye[1] + znz * eye[2]),
|
||||||
|
1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mat4Multiply(a, b) {
|
||||||
|
const out = new Float32Array(16);
|
||||||
|
for (let col = 0; col < 4; col += 1) {
|
||||||
|
for (let row = 0; row < 4; row += 1) {
|
||||||
|
out[col * 4 + row] =
|
||||||
|
a[row] * b[col * 4] +
|
||||||
|
a[4 + row] * b[col * 4 + 1] +
|
||||||
|
a[8 + row] * b[col * 4 + 2] +
|
||||||
|
a[12 + row] * b[col * 4 + 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectPoint(point, matrix, width, height) {
|
||||||
|
const x = point[0];
|
||||||
|
const y = point[1];
|
||||||
|
const z = point[2];
|
||||||
|
|
||||||
|
const clipX = matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12];
|
||||||
|
const clipY = matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13];
|
||||||
|
const clipW = matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15];
|
||||||
|
if (clipW <= 0.0001) return null;
|
||||||
|
|
||||||
|
const ndcX = clipX / clipW;
|
||||||
|
const ndcY = clipY / clipW;
|
||||||
|
if (Math.abs(ndcX) > 1.2 || Math.abs(ndcY) > 1.2) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: (ndcX * 0.5 + 0.5) * width,
|
||||||
|
y: (1 - (ndcY * 0.5 + 0.5)) * height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCssColor(raw, fallbackHex) {
|
||||||
|
const value = (raw || '').trim();
|
||||||
|
|
||||||
|
if (value.startsWith('#')) {
|
||||||
|
return hexToRgb01(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = value.match(/rgba?\(([^)]+)\)/i);
|
||||||
|
if (match) {
|
||||||
|
const parts = match[1].split(',').map(part => parseFloat(part.trim()));
|
||||||
|
if (parts.length >= 3 && parts.every(n => Number.isFinite(n))) {
|
||||||
|
return [parts[0] / 255, parts[1] / 255, parts[2] / 255];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hexToRgb01(fallbackHex || '#0d1117');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb01(hex) {
|
||||||
|
let clean = (hex || '').trim().replace('#', '');
|
||||||
|
if (clean.length === 3) {
|
||||||
|
clean = clean.split('').map(ch => ch + ch).join('');
|
||||||
|
}
|
||||||
|
if (!/^[0-9a-fA-F]{6}$/.test(clean)) {
|
||||||
|
return [0, 0, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = parseInt(clean, 16);
|
||||||
|
return [
|
||||||
|
((num >> 16) & 255) / 255,
|
||||||
|
((num >> 8) & 255) / 255,
|
||||||
|
(num & 255) / 255,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// ========================
|
// ========================
|
||||||
// Signal Strength Bars
|
// Signal Strength Bars
|
||||||
// ========================
|
// ========================
|
||||||
@@ -442,6 +1076,15 @@ const GPS = (function() {
|
|||||||
function destroy() {
|
function destroy() {
|
||||||
unsubscribeFromStream();
|
unsubscribeFromStream();
|
||||||
stopSkyPolling();
|
stopSkyPolling();
|
||||||
|
if (themeObserver) {
|
||||||
|
themeObserver.disconnect();
|
||||||
|
themeObserver = null;
|
||||||
|
}
|
||||||
|
if (skyRenderer) {
|
||||||
|
skyRenderer.destroy();
|
||||||
|
skyRenderer = null;
|
||||||
|
}
|
||||||
|
skyRendererInitAttempted = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -269,12 +269,10 @@ const SpyStations = (function() {
|
|||||||
*/
|
*/
|
||||||
function tuneToStation(stationId, freqKhz) {
|
function tuneToStation(stationId, freqKhz) {
|
||||||
const freqMhz = freqKhz / 1000;
|
const freqMhz = freqKhz / 1000;
|
||||||
sessionStorage.setItem('tuneFrequency', freqMhz.toString());
|
|
||||||
|
|
||||||
// Find the station and determine mode
|
// Find the station and determine mode
|
||||||
const station = stations.find(s => s.id === stationId);
|
const station = stations.find(s => s.id === stationId);
|
||||||
const tuneMode = station ? getModeFromStation(station.mode) : 'usb';
|
const tuneMode = station ? getModeFromStation(station.mode) : 'usb';
|
||||||
sessionStorage.setItem('tuneMode', tuneMode);
|
|
||||||
|
|
||||||
const stationName = station ? station.name : 'Station';
|
const stationName = station ? station.name : 'Station';
|
||||||
|
|
||||||
@@ -282,12 +280,18 @@ const SpyStations = (function() {
|
|||||||
showNotification('Tuning to ' + stationName, formatFrequency(freqKhz) + ' (' + tuneMode.toUpperCase() + ')');
|
showNotification('Tuning to ' + stationName, formatFrequency(freqKhz) + ' (' + tuneMode.toUpperCase() + ')');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch to listening post mode
|
// Switch to spectrum waterfall mode and tune after mode init.
|
||||||
if (typeof selectMode === 'function') {
|
if (typeof switchMode === 'function') {
|
||||||
selectMode('listening');
|
switchMode('waterfall');
|
||||||
} else if (typeof switchMode === 'function') {
|
} else if (typeof selectMode === 'function') {
|
||||||
switchMode('listening');
|
selectMode('waterfall');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (typeof Waterfall !== 'undefined' && typeof Waterfall.quickTune === 'function') {
|
||||||
|
Waterfall.quickTune(freqMhz, tuneMode);
|
||||||
|
}
|
||||||
|
}, 220);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -305,7 +309,7 @@ const SpyStations = (function() {
|
|||||||
* Check if we arrived from another page with a tune request
|
* Check if we arrived from another page with a tune request
|
||||||
*/
|
*/
|
||||||
function checkTuneFrequency() {
|
function checkTuneFrequency() {
|
||||||
// This is for the listening post to check - spy stations sets, listening post reads
|
// Reserved for cross-mode tune handoff behavior.
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -445,7 +449,7 @@ const SpyStations = (function() {
|
|||||||
<div class="signal-details-section">
|
<div class="signal-details-section">
|
||||||
<div class="signal-details-title">How to Listen</div>
|
<div class="signal-details-title">How to Listen</div>
|
||||||
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
|
<p style="color: var(--text-secondary); font-size: 12px; line-height: 1.6;">
|
||||||
Click "Tune In" on any station to open the Listening Post with the frequency pre-configured.
|
Click "Tune In" on any station to open Spectrum Waterfall with the frequency pre-configured.
|
||||||
Most number stations use USB (Upper Sideband) mode. You'll need an SDR capable of receiving
|
Most number stations use USB (Upper Sideband) mode. You'll need an SDR capable of receiving
|
||||||
HF frequencies (typically 3-30 MHz) and an appropriate antenna.
|
HF frequencies (typically 3-30 MHz) and an appropriate antenna.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+141
-23
@@ -15,13 +15,21 @@ const SSTVGeneral = (function() {
|
|||||||
let sstvGeneralScopeCtx = null;
|
let sstvGeneralScopeCtx = null;
|
||||||
let sstvGeneralScopeAnim = null;
|
let sstvGeneralScopeAnim = null;
|
||||||
let sstvGeneralScopeHistory = [];
|
let sstvGeneralScopeHistory = [];
|
||||||
|
let sstvGeneralScopeWaveBuffer = [];
|
||||||
|
let sstvGeneralScopeDisplayWave = [];
|
||||||
const SSTV_GENERAL_SCOPE_LEN = 200;
|
const SSTV_GENERAL_SCOPE_LEN = 200;
|
||||||
|
const SSTV_GENERAL_SCOPE_WAVE_BUFFER_LEN = 2048;
|
||||||
|
const SSTV_GENERAL_SCOPE_WAVE_INPUT_SMOOTH_ALPHA = 0.55;
|
||||||
|
const SSTV_GENERAL_SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA = 0.22;
|
||||||
|
const SSTV_GENERAL_SCOPE_WAVE_IDLE_DECAY = 0.96;
|
||||||
let sstvGeneralScopeRms = 0;
|
let sstvGeneralScopeRms = 0;
|
||||||
let sstvGeneralScopePeak = 0;
|
let sstvGeneralScopePeak = 0;
|
||||||
let sstvGeneralScopeTargetRms = 0;
|
let sstvGeneralScopeTargetRms = 0;
|
||||||
let sstvGeneralScopeTargetPeak = 0;
|
let sstvGeneralScopeTargetPeak = 0;
|
||||||
let sstvGeneralScopeMsgBurst = 0;
|
let sstvGeneralScopeMsgBurst = 0;
|
||||||
let sstvGeneralScopeTone = null;
|
let sstvGeneralScopeTone = null;
|
||||||
|
let sstvGeneralScopeLastWaveAt = 0;
|
||||||
|
let sstvGeneralScopeLastInputSample = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the SSTV General mode
|
* Initialize the SSTV General mode
|
||||||
@@ -205,20 +213,64 @@ const SSTVGeneral = (function() {
|
|||||||
/**
|
/**
|
||||||
* Initialize signal scope canvas
|
* Initialize signal scope canvas
|
||||||
*/
|
*/
|
||||||
|
function resizeSstvGeneralScopeCanvas(canvas) {
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const width = Math.max(1, Math.floor(rect.width * dpr));
|
||||||
|
const height = Math.max(1, Math.floor(rect.height * dpr));
|
||||||
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySstvGeneralScopeData(scopeData) {
|
||||||
|
if (!scopeData || typeof scopeData !== 'object') return;
|
||||||
|
|
||||||
|
sstvGeneralScopeTargetRms = Number(scopeData.rms) || 0;
|
||||||
|
sstvGeneralScopeTargetPeak = Number(scopeData.peak) || 0;
|
||||||
|
if (scopeData.tone !== undefined) {
|
||||||
|
sstvGeneralScopeTone = scopeData.tone;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(scopeData.waveform) && scopeData.waveform.length) {
|
||||||
|
for (const packedSample of scopeData.waveform) {
|
||||||
|
const sample = Number(packedSample);
|
||||||
|
if (!Number.isFinite(sample)) continue;
|
||||||
|
const normalized = Math.max(-127, Math.min(127, sample)) / 127;
|
||||||
|
sstvGeneralScopeLastInputSample += (normalized - sstvGeneralScopeLastInputSample) * SSTV_GENERAL_SCOPE_WAVE_INPUT_SMOOTH_ALPHA;
|
||||||
|
sstvGeneralScopeWaveBuffer.push(sstvGeneralScopeLastInputSample);
|
||||||
|
}
|
||||||
|
if (sstvGeneralScopeWaveBuffer.length > SSTV_GENERAL_SCOPE_WAVE_BUFFER_LEN) {
|
||||||
|
sstvGeneralScopeWaveBuffer.splice(0, sstvGeneralScopeWaveBuffer.length - SSTV_GENERAL_SCOPE_WAVE_BUFFER_LEN);
|
||||||
|
}
|
||||||
|
sstvGeneralScopeLastWaveAt = performance.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initSstvGeneralScope() {
|
function initSstvGeneralScope() {
|
||||||
const canvas = document.getElementById('sstvGeneralScopeCanvas');
|
const canvas = document.getElementById('sstvGeneralScopeCanvas');
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
canvas.width = rect.width * (window.devicePixelRatio || 1);
|
if (sstvGeneralScopeAnim) {
|
||||||
canvas.height = rect.height * (window.devicePixelRatio || 1);
|
cancelAnimationFrame(sstvGeneralScopeAnim);
|
||||||
|
sstvGeneralScopeAnim = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeSstvGeneralScopeCanvas(canvas);
|
||||||
sstvGeneralScopeCtx = canvas.getContext('2d');
|
sstvGeneralScopeCtx = canvas.getContext('2d');
|
||||||
sstvGeneralScopeHistory = new Array(SSTV_GENERAL_SCOPE_LEN).fill(0);
|
sstvGeneralScopeHistory = new Array(SSTV_GENERAL_SCOPE_LEN).fill(0);
|
||||||
|
sstvGeneralScopeWaveBuffer = [];
|
||||||
|
sstvGeneralScopeDisplayWave = [];
|
||||||
sstvGeneralScopeRms = 0;
|
sstvGeneralScopeRms = 0;
|
||||||
sstvGeneralScopePeak = 0;
|
sstvGeneralScopePeak = 0;
|
||||||
sstvGeneralScopeTargetRms = 0;
|
sstvGeneralScopeTargetRms = 0;
|
||||||
sstvGeneralScopeTargetPeak = 0;
|
sstvGeneralScopeTargetPeak = 0;
|
||||||
sstvGeneralScopeMsgBurst = 0;
|
sstvGeneralScopeMsgBurst = 0;
|
||||||
sstvGeneralScopeTone = null;
|
sstvGeneralScopeTone = null;
|
||||||
|
sstvGeneralScopeLastWaveAt = 0;
|
||||||
|
sstvGeneralScopeLastInputSample = 0;
|
||||||
drawSstvGeneralScope();
|
drawSstvGeneralScope();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,12 +280,14 @@ const SSTVGeneral = (function() {
|
|||||||
function drawSstvGeneralScope() {
|
function drawSstvGeneralScope() {
|
||||||
const ctx = sstvGeneralScopeCtx;
|
const ctx = sstvGeneralScopeCtx;
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
|
resizeSstvGeneralScopeCanvas(ctx.canvas);
|
||||||
const W = ctx.canvas.width;
|
const W = ctx.canvas.width;
|
||||||
const H = ctx.canvas.height;
|
const H = ctx.canvas.height;
|
||||||
const midY = H / 2;
|
const midY = H / 2;
|
||||||
|
|
||||||
// Phosphor persistence
|
// Phosphor persistence
|
||||||
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
|
ctx.fillStyle = 'rgba(5, 5, 16, 0.26)';
|
||||||
ctx.fillRect(0, 0, W, H);
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
// Smooth towards target
|
// Smooth towards target
|
||||||
@@ -256,32 +310,84 @@ const SSTVGeneral = (function() {
|
|||||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Waveform
|
// Envelope
|
||||||
const stepX = W / (SSTV_GENERAL_SCOPE_LEN - 1);
|
const envStepX = W / (SSTV_GENERAL_SCOPE_LEN - 1);
|
||||||
ctx.strokeStyle = '#c080ff';
|
ctx.strokeStyle = 'rgba(168, 110, 255, 0.45)';
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1;
|
||||||
ctx.shadowColor = '#c080ff';
|
|
||||||
ctx.shadowBlur = 4;
|
|
||||||
|
|
||||||
// Upper half
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
||||||
const x = i * stepX;
|
const x = i * envStepX;
|
||||||
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
|
const amp = sstvGeneralScopeHistory[i] * midY * 0.85;
|
||||||
const y = midY - amp;
|
const y = midY - amp;
|
||||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Lower half (mirror)
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
||||||
const x = i * stepX;
|
const x = i * envStepX;
|
||||||
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
|
const amp = sstvGeneralScopeHistory[i] * midY * 0.85;
|
||||||
const y = midY + amp;
|
const y = midY + amp;
|
||||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Actual waveform trace
|
||||||
|
const waveformPointCount = Math.min(Math.max(120, Math.floor(W / 3.2)), 420);
|
||||||
|
if (sstvGeneralScopeWaveBuffer.length > 1) {
|
||||||
|
const waveIsFresh = (performance.now() - sstvGeneralScopeLastWaveAt) < 1000;
|
||||||
|
const sourceLen = sstvGeneralScopeWaveBuffer.length;
|
||||||
|
const sourceWindow = Math.min(sourceLen, 1536);
|
||||||
|
const sourceStart = sourceLen - sourceWindow;
|
||||||
|
|
||||||
|
if (sstvGeneralScopeDisplayWave.length !== waveformPointCount) {
|
||||||
|
sstvGeneralScopeDisplayWave = new Array(waveformPointCount).fill(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < waveformPointCount; i++) {
|
||||||
|
const a = sourceStart + Math.floor((i / waveformPointCount) * sourceWindow);
|
||||||
|
const b = sourceStart + Math.floor(((i + 1) / waveformPointCount) * sourceWindow);
|
||||||
|
const start = Math.max(sourceStart, Math.min(sourceLen - 1, a));
|
||||||
|
const end = Math.max(start + 1, Math.min(sourceLen, b));
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (let j = start; j < end; j++) {
|
||||||
|
sum += sstvGeneralScopeWaveBuffer[j];
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
const targetSample = count > 0 ? (sum / count) : 0;
|
||||||
|
sstvGeneralScopeDisplayWave[i] += (targetSample - sstvGeneralScopeDisplayWave[i]) * SSTV_GENERAL_SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.strokeStyle = waveIsFresh ? '#c080ff' : 'rgba(192, 128, 255, 0.45)';
|
||||||
|
ctx.lineWidth = 1.7;
|
||||||
|
ctx.shadowColor = '#c080ff';
|
||||||
|
ctx.shadowBlur = waveIsFresh ? 6 : 2;
|
||||||
|
|
||||||
|
const stepX = waveformPointCount > 1 ? (W / (waveformPointCount - 1)) : W;
|
||||||
|
ctx.beginPath();
|
||||||
|
const firstY = midY - (sstvGeneralScopeDisplayWave[0] * midY * 0.9);
|
||||||
|
ctx.moveTo(0, firstY);
|
||||||
|
for (let i = 1; i < waveformPointCount - 1; i++) {
|
||||||
|
const x = i * stepX;
|
||||||
|
const y = midY - (sstvGeneralScopeDisplayWave[i] * midY * 0.9);
|
||||||
|
const nx = (i + 1) * stepX;
|
||||||
|
const ny = midY - (sstvGeneralScopeDisplayWave[i + 1] * midY * 0.9);
|
||||||
|
const cx = (x + nx) / 2;
|
||||||
|
const cy = (y + ny) / 2;
|
||||||
|
ctx.quadraticCurveTo(x, y, cx, cy);
|
||||||
|
}
|
||||||
|
const lastX = (waveformPointCount - 1) * stepX;
|
||||||
|
const lastY = midY - (sstvGeneralScopeDisplayWave[waveformPointCount - 1] * midY * 0.9);
|
||||||
|
ctx.lineTo(lastX, lastY);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
if (!waveIsFresh) {
|
||||||
|
for (let i = 0; i < sstvGeneralScopeDisplayWave.length; i++) {
|
||||||
|
sstvGeneralScopeDisplayWave[i] *= SSTV_GENERAL_SCOPE_WAVE_IDLE_DECAY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
ctx.shadowBlur = 0;
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
// Peak indicator
|
// Peak indicator
|
||||||
@@ -317,8 +423,17 @@ const SSTVGeneral = (function() {
|
|||||||
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
|
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
|
||||||
}
|
}
|
||||||
if (statusLabel) {
|
if (statusLabel) {
|
||||||
if (sstvGeneralScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
|
const waveIsFresh = (performance.now() - sstvGeneralScopeLastWaveAt) < 1000;
|
||||||
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
|
if (sstvGeneralScopeRms > 900 && waveIsFresh) {
|
||||||
|
statusLabel.textContent = 'DEMODULATING';
|
||||||
|
statusLabel.style.color = '#c080ff';
|
||||||
|
} else if (sstvGeneralScopeRms > 500) {
|
||||||
|
statusLabel.textContent = 'CARRIER';
|
||||||
|
statusLabel.style.color = '#e0b8ff';
|
||||||
|
} else {
|
||||||
|
statusLabel.textContent = 'QUIET';
|
||||||
|
statusLabel.style.color = '#555';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sstvGeneralScopeAnim = requestAnimationFrame(drawSstvGeneralScope);
|
sstvGeneralScopeAnim = requestAnimationFrame(drawSstvGeneralScope);
|
||||||
@@ -330,6 +445,11 @@ const SSTVGeneral = (function() {
|
|||||||
function stopSstvGeneralScope() {
|
function stopSstvGeneralScope() {
|
||||||
if (sstvGeneralScopeAnim) { cancelAnimationFrame(sstvGeneralScopeAnim); sstvGeneralScopeAnim = null; }
|
if (sstvGeneralScopeAnim) { cancelAnimationFrame(sstvGeneralScopeAnim); sstvGeneralScopeAnim = null; }
|
||||||
sstvGeneralScopeCtx = null;
|
sstvGeneralScopeCtx = null;
|
||||||
|
sstvGeneralScopeWaveBuffer = [];
|
||||||
|
sstvGeneralScopeDisplayWave = [];
|
||||||
|
sstvGeneralScopeHistory = [];
|
||||||
|
sstvGeneralScopeLastWaveAt = 0;
|
||||||
|
sstvGeneralScopeLastInputSample = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -353,9 +473,7 @@ const SSTVGeneral = (function() {
|
|||||||
if (data.type === 'sstv_progress') {
|
if (data.type === 'sstv_progress') {
|
||||||
handleProgress(data);
|
handleProgress(data);
|
||||||
} else if (data.type === 'sstv_scope') {
|
} else if (data.type === 'sstv_scope') {
|
||||||
sstvGeneralScopeTargetRms = data.rms;
|
applySstvGeneralScopeData(data);
|
||||||
sstvGeneralScopeTargetPeak = data.peak;
|
|
||||||
if (data.tone !== undefined) sstvGeneralScopeTone = data.tone;
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse SSE message:', err);
|
console.error('Failed to parse SSE message:', err);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+433
-41
@@ -9,6 +9,20 @@ let websdrMarkers = [];
|
|||||||
let websdrReceivers = [];
|
let websdrReceivers = [];
|
||||||
let websdrInitialized = false;
|
let websdrInitialized = false;
|
||||||
let websdrSpyStationsLoaded = false;
|
let websdrSpyStationsLoaded = false;
|
||||||
|
let websdrMapType = null;
|
||||||
|
let websdrGlobe = null;
|
||||||
|
let websdrGlobePopup = null;
|
||||||
|
let websdrSelectedReceiverIndex = null;
|
||||||
|
let websdrGlobeScriptPromise = null;
|
||||||
|
let websdrResizeObserver = null;
|
||||||
|
let websdrResizeHooked = false;
|
||||||
|
let websdrGlobeFallbackNotified = false;
|
||||||
|
|
||||||
|
const WEBSDR_GLOBE_SCRIPT_URLS = [
|
||||||
|
'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js',
|
||||||
|
'https://cdn.jsdelivr.net/npm/globe.gl@2.33.1/dist/globe.gl.min.js',
|
||||||
|
];
|
||||||
|
const WEBSDR_GLOBE_TEXTURE_URL = '/static/images/globe/earth-dark.jpg';
|
||||||
|
|
||||||
// KiwiSDR audio state
|
// KiwiSDR audio state
|
||||||
let kiwiWebSocket = null;
|
let kiwiWebSocket = null;
|
||||||
@@ -29,54 +43,39 @@ const KIWI_SAMPLE_RATE = 12000;
|
|||||||
|
|
||||||
async function initWebSDR() {
|
async function initWebSDR() {
|
||||||
if (websdrInitialized) {
|
if (websdrInitialized) {
|
||||||
if (websdrMap) {
|
setTimeout(invalidateWebSDRViewport, 100);
|
||||||
setTimeout(() => websdrMap.invalidateSize(), 100);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapEl = document.getElementById('websdrMap');
|
const mapEl = document.getElementById('websdrMap');
|
||||||
if (!mapEl || typeof L === 'undefined') return;
|
if (!mapEl) return;
|
||||||
|
|
||||||
// Calculate minimum zoom so tiles fill the container vertically
|
const globeReady = await ensureWebsdrGlobeLibrary();
|
||||||
const mapHeight = mapEl.clientHeight || 500;
|
if (globeReady && initWebsdrGlobe(mapEl)) {
|
||||||
const minZoom = Math.ceil(Math.log2(mapHeight / 256));
|
websdrMapType = 'globe';
|
||||||
|
} else if (typeof L !== 'undefined' && await initWebsdrLeaflet(mapEl)) {
|
||||||
websdrMap = L.map('websdrMap', {
|
websdrMapType = 'leaflet';
|
||||||
center: [20, 0],
|
if (!websdrGlobeFallbackNotified && typeof showNotification === 'function') {
|
||||||
zoom: Math.max(minZoom, 2),
|
showNotification('WebSDR', '3D globe unavailable, using fallback map');
|
||||||
minZoom: Math.max(minZoom, 2),
|
websdrGlobeFallbackNotified = true;
|
||||||
zoomControl: true,
|
}
|
||||||
maxBounds: [[-85, -360], [85, 360]],
|
|
||||||
maxBoundsViscosity: 1.0,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
|
|
||||||
await Settings.init();
|
|
||||||
Settings.createTileLayer().addTo(websdrMap);
|
|
||||||
Settings.registerMap(websdrMap);
|
|
||||||
} else {
|
} else {
|
||||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
console.error('[WEBSDR] Unable to initialize globe or map renderer');
|
||||||
attribution: '© OpenStreetMap contributors © CARTO',
|
return;
|
||||||
subdomains: 'abcd',
|
|
||||||
maxZoom: 19,
|
|
||||||
className: 'tile-layer-cyan',
|
|
||||||
}).addTo(websdrMap);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match background to tile ocean color so any remaining edge is seamless
|
|
||||||
mapEl.style.background = '#1a1d29';
|
|
||||||
|
|
||||||
websdrInitialized = true;
|
websdrInitialized = true;
|
||||||
|
|
||||||
if (!websdrSpyStationsLoaded) {
|
if (!websdrSpyStationsLoaded) {
|
||||||
loadSpyStationPresets();
|
loadSpyStationPresets();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupWebsdrResizeHandling(mapEl);
|
||||||
|
if (websdrReceivers.length > 0) {
|
||||||
|
plotReceiversOnMap(websdrReceivers);
|
||||||
|
}
|
||||||
[100, 300, 600, 1000].forEach(delay => {
|
[100, 300, 600, 1000].forEach(delay => {
|
||||||
setTimeout(() => {
|
setTimeout(invalidateWebSDRViewport, delay);
|
||||||
if (websdrMap) websdrMap.invalidateSize();
|
|
||||||
}, delay);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +93,8 @@ function searchReceivers(refresh) {
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
websdrReceivers = data.receivers || [];
|
websdrReceivers = data.receivers || [];
|
||||||
|
websdrSelectedReceiverIndex = null;
|
||||||
|
hideWebsdrGlobePopup();
|
||||||
renderReceiverList(websdrReceivers);
|
renderReceiverList(websdrReceivers);
|
||||||
plotReceiversOnMap(websdrReceivers);
|
plotReceiversOnMap(websdrReceivers);
|
||||||
|
|
||||||
@@ -107,6 +108,11 @@ function searchReceivers(refresh) {
|
|||||||
// ============== MAP ==============
|
// ============== MAP ==============
|
||||||
|
|
||||||
function plotReceiversOnMap(receivers) {
|
function plotReceiversOnMap(receivers) {
|
||||||
|
if (websdrMapType === 'globe' && websdrGlobe) {
|
||||||
|
plotReceiversOnGlobe(receivers);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!websdrMap) return;
|
if (!websdrMap) return;
|
||||||
|
|
||||||
websdrMarkers.forEach(m => websdrMap.removeLayer(m));
|
websdrMarkers.forEach(m => websdrMap.removeLayer(m));
|
||||||
@@ -144,6 +150,369 @@ function plotReceiversOnMap(receivers) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureWebsdrGlobeLibrary() {
|
||||||
|
if (typeof window.Globe === 'function') return true;
|
||||||
|
if (!isWebglSupported()) return false;
|
||||||
|
|
||||||
|
if (!websdrGlobeScriptPromise) {
|
||||||
|
websdrGlobeScriptPromise = WEBSDR_GLOBE_SCRIPT_URLS
|
||||||
|
.reduce(
|
||||||
|
(promise, src) => promise.then(() => loadWebsdrScript(src)),
|
||||||
|
Promise.resolve()
|
||||||
|
)
|
||||||
|
.then(() => typeof window.Globe === 'function')
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn('[WEBSDR] Failed to load globe scripts:', error);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = await websdrGlobeScriptPromise;
|
||||||
|
if (!loaded) {
|
||||||
|
websdrGlobeScriptPromise = null;
|
||||||
|
}
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadWebsdrScript(src) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const selector = `script[data-websdr-src="${src}"]`;
|
||||||
|
const existing = document.querySelector(selector);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.dataset.loaded === 'true') {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (existing.dataset.failed === 'true') {
|
||||||
|
existing.remove();
|
||||||
|
} else {
|
||||||
|
existing.addEventListener('load', () => resolve(), { once: true });
|
||||||
|
existing.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)), { once: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = src;
|
||||||
|
script.async = true;
|
||||||
|
script.crossOrigin = 'anonymous';
|
||||||
|
script.dataset.websdrSrc = src;
|
||||||
|
script.onload = () => {
|
||||||
|
script.dataset.loaded = 'true';
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
script.onerror = () => {
|
||||||
|
script.dataset.failed = 'true';
|
||||||
|
reject(new Error(`Failed to load ${src}`));
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWebglSupported() {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
return !!(canvas.getContext('webgl') || canvas.getContext('experimental-webgl'));
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initWebsdrGlobe(mapEl) {
|
||||||
|
if (typeof window.Globe !== 'function' || !isWebglSupported()) return false;
|
||||||
|
|
||||||
|
mapEl.innerHTML = '';
|
||||||
|
mapEl.style.background = 'radial-gradient(circle at 30% 20%, rgba(14, 42, 68, 0.9), rgba(4, 9, 16, 0.95) 58%, rgba(2, 4, 9, 0.98) 100%)';
|
||||||
|
mapEl.style.cursor = 'grab';
|
||||||
|
|
||||||
|
websdrGlobe = window.Globe()(mapEl)
|
||||||
|
.backgroundColor('rgba(0,0,0,0)')
|
||||||
|
.globeImageUrl(WEBSDR_GLOBE_TEXTURE_URL)
|
||||||
|
.showAtmosphere(true)
|
||||||
|
.atmosphereColor('#3bb9ff')
|
||||||
|
.atmosphereAltitude(0.17)
|
||||||
|
.pointRadius('radius')
|
||||||
|
.pointAltitude('altitude')
|
||||||
|
.pointColor('color')
|
||||||
|
.pointsTransitionDuration(250)
|
||||||
|
.pointLabel(point => point.label || '')
|
||||||
|
.onPointHover(point => {
|
||||||
|
mapEl.style.cursor = point ? 'pointer' : 'grab';
|
||||||
|
})
|
||||||
|
.onPointClick((point, event) => {
|
||||||
|
if (!point) return;
|
||||||
|
showWebsdrGlobePopup(point, event);
|
||||||
|
});
|
||||||
|
|
||||||
|
const controls = websdrGlobe.controls();
|
||||||
|
if (controls) {
|
||||||
|
controls.autoRotate = true;
|
||||||
|
controls.autoRotateSpeed = 0.25;
|
||||||
|
controls.enablePan = false;
|
||||||
|
controls.minDistance = 140;
|
||||||
|
controls.maxDistance = 380;
|
||||||
|
controls.rotateSpeed = 0.7;
|
||||||
|
controls.zoomSpeed = 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureWebsdrGlobePopup(mapEl);
|
||||||
|
resizeWebsdrGlobe();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initWebsdrLeaflet(mapEl) {
|
||||||
|
if (typeof L === 'undefined') return false;
|
||||||
|
|
||||||
|
mapEl.innerHTML = '';
|
||||||
|
const mapHeight = mapEl.clientHeight || 500;
|
||||||
|
const minZoom = Math.ceil(Math.log2(mapHeight / 256));
|
||||||
|
|
||||||
|
websdrMap = L.map('websdrMap', {
|
||||||
|
center: [20, 0],
|
||||||
|
zoom: Math.max(minZoom, 2),
|
||||||
|
minZoom: Math.max(minZoom, 2),
|
||||||
|
zoomControl: true,
|
||||||
|
maxBounds: [[-85, -360], [85, 360]],
|
||||||
|
maxBoundsViscosity: 1.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
|
||||||
|
await Settings.init();
|
||||||
|
Settings.createTileLayer().addTo(websdrMap);
|
||||||
|
Settings.registerMap(websdrMap);
|
||||||
|
} else {
|
||||||
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||||
|
attribution: '© OpenStreetMap contributors © CARTO',
|
||||||
|
subdomains: 'abcd',
|
||||||
|
maxZoom: 19,
|
||||||
|
className: 'tile-layer-cyan',
|
||||||
|
}).addTo(websdrMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
mapEl.style.background = '#1a1d29';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupWebsdrResizeHandling(mapEl) {
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
if (websdrResizeObserver) {
|
||||||
|
websdrResizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
websdrResizeObserver = new ResizeObserver(() => invalidateWebSDRViewport());
|
||||||
|
websdrResizeObserver.observe(mapEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!websdrResizeHooked) {
|
||||||
|
window.addEventListener('resize', invalidateWebSDRViewport);
|
||||||
|
window.addEventListener('orientationchange', () => setTimeout(invalidateWebSDRViewport, 120));
|
||||||
|
websdrResizeHooked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateWebSDRViewport() {
|
||||||
|
if (websdrMapType === 'globe') {
|
||||||
|
resizeWebsdrGlobe();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (websdrMap && typeof websdrMap.invalidateSize === 'function') {
|
||||||
|
websdrMap.invalidateSize({ pan: false, animate: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeWebsdrGlobe() {
|
||||||
|
if (!websdrGlobe) return;
|
||||||
|
const mapEl = document.getElementById('websdrMap');
|
||||||
|
if (!mapEl) return;
|
||||||
|
|
||||||
|
const width = mapEl.clientWidth;
|
||||||
|
const height = mapEl.clientHeight;
|
||||||
|
if (!width || !height) return;
|
||||||
|
|
||||||
|
websdrGlobe.width(width);
|
||||||
|
websdrGlobe.height(height);
|
||||||
|
}
|
||||||
|
|
||||||
|
function plotReceiversOnGlobe(receivers) {
|
||||||
|
if (!websdrGlobe) return;
|
||||||
|
|
||||||
|
const points = [];
|
||||||
|
receivers.forEach((rx, idx) => {
|
||||||
|
const lat = Number(rx.lat);
|
||||||
|
const lon = Number(rx.lon);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
|
||||||
|
|
||||||
|
const selected = idx === websdrSelectedReceiverIndex;
|
||||||
|
points.push({
|
||||||
|
lat: lat,
|
||||||
|
lng: lon,
|
||||||
|
receiverIndex: idx,
|
||||||
|
radius: selected ? 0.52 : 0.38,
|
||||||
|
altitude: selected ? 0.1 : 0.04,
|
||||||
|
color: selected ? '#00ff88' : (rx.available ? '#00d4ff' : '#5f6976'),
|
||||||
|
label: buildWebsdrPointLabel(rx, idx),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
websdrGlobe.pointsData(points);
|
||||||
|
|
||||||
|
if (points.length > 0) {
|
||||||
|
if (websdrSelectedReceiverIndex != null) {
|
||||||
|
const selectedPoint = points.find(point => point.receiverIndex === websdrSelectedReceiverIndex);
|
||||||
|
if (selectedPoint) {
|
||||||
|
websdrGlobe.pointOfView({ lat: selectedPoint.lat, lng: selectedPoint.lng, altitude: 1.45 }, 900);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = computeWebsdrGlobeCenter(points);
|
||||||
|
websdrGlobe.pointOfView(center, 900);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeWebsdrGlobeCenter(points) {
|
||||||
|
if (!points.length) return { lat: 20, lng: 0, altitude: 2.1 };
|
||||||
|
|
||||||
|
let x = 0;
|
||||||
|
let y = 0;
|
||||||
|
let z = 0;
|
||||||
|
points.forEach(point => {
|
||||||
|
const latRad = point.lat * Math.PI / 180;
|
||||||
|
const lonRad = point.lng * Math.PI / 180;
|
||||||
|
x += Math.cos(latRad) * Math.cos(lonRad);
|
||||||
|
y += Math.cos(latRad) * Math.sin(lonRad);
|
||||||
|
z += Math.sin(latRad);
|
||||||
|
});
|
||||||
|
|
||||||
|
const count = points.length;
|
||||||
|
x /= count;
|
||||||
|
y /= count;
|
||||||
|
z /= count;
|
||||||
|
|
||||||
|
const hyp = Math.sqrt((x * x) + (y * y));
|
||||||
|
const centerLat = Math.atan2(z, hyp) * 180 / Math.PI;
|
||||||
|
const centerLng = Math.atan2(y, x) * 180 / Math.PI;
|
||||||
|
|
||||||
|
let meanAngularDistance = 0;
|
||||||
|
const centerLatRad = centerLat * Math.PI / 180;
|
||||||
|
const centerLngRad = centerLng * Math.PI / 180;
|
||||||
|
points.forEach(point => {
|
||||||
|
const latRad = point.lat * Math.PI / 180;
|
||||||
|
const lonRad = point.lng * Math.PI / 180;
|
||||||
|
const cosAngle = (
|
||||||
|
(Math.sin(centerLatRad) * Math.sin(latRad)) +
|
||||||
|
(Math.cos(centerLatRad) * Math.cos(latRad) * Math.cos(lonRad - centerLngRad))
|
||||||
|
);
|
||||||
|
const safeCos = Math.max(-1, Math.min(1, cosAngle));
|
||||||
|
meanAngularDistance += Math.acos(safeCos) * 180 / Math.PI;
|
||||||
|
});
|
||||||
|
meanAngularDistance /= count;
|
||||||
|
|
||||||
|
const altitude = Math.min(2.9, Math.max(1.35, 1.35 + (meanAngularDistance / 45)));
|
||||||
|
return { lat: centerLat, lng: centerLng, altitude: altitude };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureWebsdrGlobePopup(mapEl) {
|
||||||
|
if (websdrGlobePopup) {
|
||||||
|
if (websdrGlobePopup.parentElement !== mapEl) {
|
||||||
|
mapEl.appendChild(websdrGlobePopup);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
websdrGlobePopup = document.createElement('div');
|
||||||
|
websdrGlobePopup.id = 'websdrGlobePopup';
|
||||||
|
websdrGlobePopup.style.position = 'absolute';
|
||||||
|
websdrGlobePopup.style.minWidth = '220px';
|
||||||
|
websdrGlobePopup.style.maxWidth = '260px';
|
||||||
|
websdrGlobePopup.style.padding = '10px';
|
||||||
|
websdrGlobePopup.style.borderRadius = '8px';
|
||||||
|
websdrGlobePopup.style.border = '1px solid rgba(0, 212, 255, 0.35)';
|
||||||
|
websdrGlobePopup.style.background = 'rgba(5, 13, 20, 0.92)';
|
||||||
|
websdrGlobePopup.style.backdropFilter = 'blur(4px)';
|
||||||
|
websdrGlobePopup.style.boxShadow = '0 8px 24px rgba(0, 0, 0, 0.4)';
|
||||||
|
websdrGlobePopup.style.color = 'var(--text-primary)';
|
||||||
|
websdrGlobePopup.style.display = 'none';
|
||||||
|
websdrGlobePopup.style.zIndex = '20';
|
||||||
|
mapEl.appendChild(websdrGlobePopup);
|
||||||
|
|
||||||
|
if (!mapEl.dataset.websdrPopupHooked) {
|
||||||
|
mapEl.addEventListener('click', (event) => {
|
||||||
|
if (!websdrGlobePopup || websdrGlobePopup.style.display === 'none') return;
|
||||||
|
if (event.target.closest('#websdrGlobePopup')) return;
|
||||||
|
hideWebsdrGlobePopup();
|
||||||
|
});
|
||||||
|
mapEl.dataset.websdrPopupHooked = 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWebsdrGlobePopup(point, event) {
|
||||||
|
if (!websdrGlobePopup || !point || point.receiverIndex == null) return;
|
||||||
|
const rx = websdrReceivers[point.receiverIndex];
|
||||||
|
if (!rx) return;
|
||||||
|
|
||||||
|
const mapEl = document.getElementById('websdrMap');
|
||||||
|
if (!mapEl) return;
|
||||||
|
|
||||||
|
websdrSelectedReceiverIndex = point.receiverIndex;
|
||||||
|
renderReceiverList(websdrReceivers);
|
||||||
|
plotReceiversOnGlobe(websdrReceivers);
|
||||||
|
|
||||||
|
websdrGlobePopup.innerHTML = `
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: start; gap: 10px; margin-bottom: 6px;">
|
||||||
|
<strong style="font-size: 12px; color: var(--accent-cyan);">${escapeHtmlWebsdr(rx.name)}</strong>
|
||||||
|
<button type="button" data-websdr-popup-close style="border: none; background: transparent; color: var(--text-muted); cursor: pointer; font-size: 14px; line-height: 1;">×</button>
|
||||||
|
</div>
|
||||||
|
${rx.location ? `<div style="font-size: 10px; color: var(--text-secondary); margin-bottom: 3px;">${escapeHtmlWebsdr(rx.location)}</div>` : ''}
|
||||||
|
<div style="font-size: 10px; color: var(--text-muted); margin-bottom: 2px;">Antenna: ${escapeHtmlWebsdr(rx.antenna || 'Unknown')}</div>
|
||||||
|
<div style="font-size: 10px; color: var(--text-muted); margin-bottom: 10px;">Users: ${rx.users}/${rx.users_max}</div>
|
||||||
|
<button type="button" data-websdr-listen style="width: 100%; padding: 5px 10px; background: #00d4ff; color: #041018; border: none; border-radius: 4px; cursor: pointer; font-weight: 700;">Listen</button>
|
||||||
|
`;
|
||||||
|
websdrGlobePopup.style.display = 'block';
|
||||||
|
|
||||||
|
const rect = mapEl.getBoundingClientRect();
|
||||||
|
const x = event && Number.isFinite(event.clientX) ? (event.clientX - rect.left) : (rect.width / 2);
|
||||||
|
const y = event && Number.isFinite(event.clientY) ? (event.clientY - rect.top) : (rect.height / 2);
|
||||||
|
const popupWidth = 260;
|
||||||
|
const popupHeight = 155;
|
||||||
|
const left = Math.max(12, Math.min(rect.width - popupWidth - 12, x + 12));
|
||||||
|
const top = Math.max(12, Math.min(rect.height - popupHeight - 12, y + 12));
|
||||||
|
websdrGlobePopup.style.left = `${left}px`;
|
||||||
|
websdrGlobePopup.style.top = `${top}px`;
|
||||||
|
|
||||||
|
const closeBtn = websdrGlobePopup.querySelector('[data-websdr-popup-close]');
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.onclick = () => hideWebsdrGlobePopup();
|
||||||
|
}
|
||||||
|
const listenBtn = websdrGlobePopup.querySelector('[data-websdr-listen]');
|
||||||
|
if (listenBtn) {
|
||||||
|
listenBtn.onclick = () => selectReceiver(point.receiverIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event && typeof event.stopPropagation === 'function') {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideWebsdrGlobePopup() {
|
||||||
|
if (websdrGlobePopup) {
|
||||||
|
websdrGlobePopup.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWebsdrPointLabel(rx, idx) {
|
||||||
|
const location = rx.location ? escapeHtmlWebsdr(rx.location) : 'Unknown location';
|
||||||
|
const antenna = escapeHtmlWebsdr(rx.antenna || 'Unknown antenna');
|
||||||
|
return `
|
||||||
|
<div style="padding: 4px 6px; font-size: 11px; background: rgba(4, 12, 19, 0.9); border: 1px solid rgba(0,212,255,0.28); border-radius: 4px;">
|
||||||
|
<div style="color: #00d4ff; font-weight: 600;">${escapeHtmlWebsdr(rx.name)}</div>
|
||||||
|
<div style="color: #a5b1c3;">${location}</div>
|
||||||
|
<div style="color: #8f9fb3;">${antenna} · ${rx.users}/${rx.users_max}</div>
|
||||||
|
<div style="color: #7a899b; margin-top: 2px;">Receiver #${idx + 1}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
// ============== RECEIVER LIST ==============
|
// ============== RECEIVER LIST ==============
|
||||||
|
|
||||||
function renderReceiverList(receivers) {
|
function renderReceiverList(receivers) {
|
||||||
@@ -155,12 +524,16 @@ function renderReceiverList(receivers) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = receivers.slice(0, 50).map((rx, idx) => `
|
container.innerHTML = receivers.slice(0, 50).map((rx, idx) => {
|
||||||
<div style="padding: 8px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; transition: background 0.2s;"
|
const selected = idx === websdrSelectedReceiverIndex;
|
||||||
onmouseover="this.style.background='rgba(0,212,255,0.05)'" onmouseout="this.style.background='transparent'"
|
const baseBg = selected ? 'rgba(0,212,255,0.14)' : 'transparent';
|
||||||
|
const hoverBg = selected ? 'rgba(0,212,255,0.18)' : 'rgba(0,212,255,0.05)';
|
||||||
|
return `
|
||||||
|
<div style="padding: 8px 8px 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; transition: background 0.2s; border-left: 2px solid ${selected ? 'var(--accent-cyan)' : 'transparent'}; background: ${baseBg};"
|
||||||
|
onmouseover="this.style.background='${hoverBg}'" onmouseout="this.style.background='${baseBg}'"
|
||||||
onclick="selectReceiver(${idx})">
|
onclick="selectReceiver(${idx})">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
<strong style="font-size: 11px; color: var(--text-primary);">${escapeHtmlWebsdr(rx.name)}</strong>
|
<strong style="font-size: 11px; color: ${selected ? 'var(--accent-cyan)' : 'var(--text-primary)'};">${escapeHtmlWebsdr(rx.name)}</strong>
|
||||||
<span style="font-size: 9px; padding: 1px 6px; background: ${rx.available ? 'rgba(0,230,118,0.15)' : 'rgba(158,158,158,0.15)'}; color: ${rx.available ? '#00e676' : '#9e9e9e'}; border-radius: 3px;">${rx.users}/${rx.users_max}</span>
|
<span style="font-size: 9px; padding: 1px 6px; background: ${rx.available ? 'rgba(0,230,118,0.15)' : 'rgba(158,158,158,0.15)'}; color: ${rx.available ? '#00e676' : '#9e9e9e'}; border-radius: 3px;">${rx.users}/${rx.users_max}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size: 9px; color: var(--text-muted); margin-top: 2px;">
|
<div style="font-size: 9px; color: var(--text-muted); margin-top: 2px;">
|
||||||
@@ -168,7 +541,8 @@ function renderReceiverList(receivers) {
|
|||||||
${rx.distance_km !== undefined ? ` · ${rx.distance_km} km` : ''}
|
${rx.distance_km !== undefined ? ` · ${rx.distance_km} km` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============== SELECT RECEIVER ==============
|
// ============== SELECT RECEIVER ==============
|
||||||
@@ -180,14 +554,30 @@ function selectReceiver(index) {
|
|||||||
const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 7000);
|
const freqKhz = parseFloat(document.getElementById('websdrFrequency')?.value || 7000);
|
||||||
const mode = document.getElementById('websdrMode_select')?.value || 'am';
|
const mode = document.getElementById('websdrMode_select')?.value || 'am';
|
||||||
|
|
||||||
|
websdrSelectedReceiverIndex = index;
|
||||||
|
renderReceiverList(websdrReceivers);
|
||||||
|
focusReceiverOnMap(rx);
|
||||||
|
hideWebsdrGlobePopup();
|
||||||
|
|
||||||
kiwiReceiverName = rx.name;
|
kiwiReceiverName = rx.name;
|
||||||
|
|
||||||
// Connect via backend proxy
|
// Connect via backend proxy
|
||||||
connectToReceiver(rx.url, freqKhz, mode);
|
connectToReceiver(rx.url, freqKhz, mode);
|
||||||
|
}
|
||||||
|
|
||||||
// Highlight on map
|
function focusReceiverOnMap(rx) {
|
||||||
if (websdrMap && rx.lat != null && rx.lon != null) {
|
const lat = Number(rx.lat);
|
||||||
websdrMap.setView([rx.lat, rx.lon], 6);
|
const lon = Number(rx.lon);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
|
||||||
|
|
||||||
|
if (websdrMapType === 'globe' && websdrGlobe) {
|
||||||
|
plotReceiversOnGlobe(websdrReceivers);
|
||||||
|
websdrGlobe.pointOfView({ lat: lat, lng: lon, altitude: 1.4 }, 900);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (websdrMap) {
|
||||||
|
websdrMap.setView([lat, lon], 6);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,6 +941,8 @@ function tuneToSpyStation(stationId, freqKhz) {
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
websdrReceivers = data.receivers || [];
|
websdrReceivers = data.receivers || [];
|
||||||
|
websdrSelectedReceiverIndex = null;
|
||||||
|
hideWebsdrGlobePopup();
|
||||||
renderReceiverList(websdrReceivers);
|
renderReceiverList(websdrReceivers);
|
||||||
plotReceiversOnMap(websdrReceivers);
|
plotReceiversOnMap(websdrReceivers);
|
||||||
|
|
||||||
|
|||||||
+19
-4
@@ -590,20 +590,35 @@ const WiFiMode = (function() {
|
|||||||
eventSource = null;
|
eventSource = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update UI immediately so mode transitions are responsive even if the
|
||||||
|
// backend needs extra time to terminate subprocesses.
|
||||||
|
setScanning(false);
|
||||||
|
|
||||||
// Stop scan on server (local or agent)
|
// Stop scan on server (local or agent)
|
||||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
|
const timeoutMs = isAgentMode ? 8000 : 2200;
|
||||||
|
const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
||||||
|
const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isAgentMode) {
|
if (isAgentMode) {
|
||||||
await fetch(`/controller/agents/${currentAgent}/wifi/stop`, { method: 'POST' });
|
await fetch(`/controller/agents/${currentAgent}/wifi/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
...(controller ? { signal: controller.signal } : {}),
|
||||||
|
});
|
||||||
} else if (scanMode === 'deep') {
|
} else if (scanMode === 'deep') {
|
||||||
await fetch(`${CONFIG.apiBase}/scan/stop`, { method: 'POST' });
|
await fetch(`${CONFIG.apiBase}/scan/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
...(controller ? { signal: controller.signal } : {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[WiFiMode] Error stopping scan:', error);
|
console.warn('[WiFiMode] Error stopping scan:', error);
|
||||||
|
} finally {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setScanning(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setScanning(scanning, mode = null) {
|
function setScanning(scanning, mode = null) {
|
||||||
|
|||||||
Vendored
+297
@@ -0,0 +1,297 @@
|
|||||||
|
/*
|
||||||
|
* Leaflet.heat — a tiny, fast Leaflet heatmap plugin
|
||||||
|
* https://github.com/Leaflet/Leaflet.heat
|
||||||
|
* (c) 2014, Vladimir Agafonkin
|
||||||
|
* MIT License
|
||||||
|
*
|
||||||
|
* Bundled local copy for INTERCEPT — avoids CDN dependency.
|
||||||
|
* Includes simpleheat (https://github.com/mourner/simpleheat), MIT License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---- simpleheat ----
|
||||||
|
(function (global, factory) {
|
||||||
|
typeof define === 'function' && define.amd ? define(factory) :
|
||||||
|
typeof exports !== 'undefined' ? module.exports = factory() :
|
||||||
|
global.simpleheat = factory();
|
||||||
|
}(this, function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function simpleheat(canvas) {
|
||||||
|
if (!(this instanceof simpleheat)) return new simpleheat(canvas);
|
||||||
|
this._canvas = canvas = typeof canvas === 'string' ? document.getElementById(canvas) : canvas;
|
||||||
|
this._ctx = canvas.getContext('2d');
|
||||||
|
this._width = canvas.width;
|
||||||
|
this._height = canvas.height;
|
||||||
|
this._max = 1;
|
||||||
|
this._data = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
simpleheat.prototype = {
|
||||||
|
defaultRadius: 25,
|
||||||
|
defaultGradient: {
|
||||||
|
0.4: 'blue',
|
||||||
|
0.6: 'cyan',
|
||||||
|
0.7: 'lime',
|
||||||
|
0.8: 'yellow',
|
||||||
|
1.0: 'red'
|
||||||
|
},
|
||||||
|
|
||||||
|
data: function (data) {
|
||||||
|
this._data = data;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
max: function (max) {
|
||||||
|
this._max = max;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
add: function (point) {
|
||||||
|
this._data.push(point);
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
clear: function () {
|
||||||
|
this._data = [];
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
radius: function (r, blur) {
|
||||||
|
blur = blur === undefined ? 15 : blur;
|
||||||
|
var circle = this._circle = this._createCanvas(),
|
||||||
|
ctx = circle.getContext('2d'),
|
||||||
|
r2 = this._r = r + blur;
|
||||||
|
circle.width = circle.height = r2 * 2;
|
||||||
|
ctx.shadowOffsetX = ctx.shadowOffsetY = r2 * 2;
|
||||||
|
ctx.shadowBlur = blur;
|
||||||
|
ctx.shadowColor = 'black';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(-r2, -r2, r, 0, Math.PI * 2, true);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
resize: function () {
|
||||||
|
this._width = this._canvas.width;
|
||||||
|
this._height = this._canvas.height;
|
||||||
|
},
|
||||||
|
|
||||||
|
gradient: function (grad) {
|
||||||
|
var canvas = this._createCanvas(),
|
||||||
|
ctx = canvas.getContext('2d'),
|
||||||
|
gradient = ctx.createLinearGradient(0, 0, 0, 256);
|
||||||
|
canvas.width = 1;
|
||||||
|
canvas.height = 256;
|
||||||
|
for (var i in grad) {
|
||||||
|
gradient.addColorStop(+i, grad[i]);
|
||||||
|
}
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, 1, 256);
|
||||||
|
this._grad = ctx.getImageData(0, 0, 1, 256).data;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
draw: function (minOpacity) {
|
||||||
|
if (!this._circle) this.radius(this.defaultRadius);
|
||||||
|
if (!this._grad) this.gradient(this.defaultGradient);
|
||||||
|
|
||||||
|
var ctx = this._ctx;
|
||||||
|
ctx.clearRect(0, 0, this._width, this._height);
|
||||||
|
|
||||||
|
for (var i = 0, len = this._data.length, p; i < len; i++) {
|
||||||
|
p = this._data[i];
|
||||||
|
ctx.globalAlpha = Math.min(Math.max(p[2] / this._max, minOpacity === undefined ? 0.05 : minOpacity), 1);
|
||||||
|
ctx.drawImage(this._circle, p[0] - this._r, p[1] - this._r);
|
||||||
|
}
|
||||||
|
|
||||||
|
var colored = ctx.getImageData(0, 0, this._width, this._height);
|
||||||
|
this._colorize(colored.data, this._grad);
|
||||||
|
ctx.putImageData(colored, 0, 0);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
_colorize: function (pixels, gradient) {
|
||||||
|
for (var i = 3, len = pixels.length, j; i < len; i += 4) {
|
||||||
|
j = pixels[i] * 4;
|
||||||
|
if (j) {
|
||||||
|
pixels[i - 3] = gradient[j];
|
||||||
|
pixels[i - 2] = gradient[j + 1];
|
||||||
|
pixels[i - 1] = gradient[j + 2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_createCanvas: function () {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
return document.createElement('canvas');
|
||||||
|
}
|
||||||
|
return { getContext: function () {} };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return simpleheat;
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---- Leaflet.heat plugin ----
|
||||||
|
(function () {
|
||||||
|
if (typeof L === 'undefined') return;
|
||||||
|
|
||||||
|
L.HeatLayer = (L.Layer ? L.Layer : L.Class).extend({
|
||||||
|
initialize: function (latlngs, options) {
|
||||||
|
this._latlngs = latlngs;
|
||||||
|
L.setOptions(this, options);
|
||||||
|
},
|
||||||
|
|
||||||
|
setLatLngs: function (latlngs) {
|
||||||
|
this._latlngs = latlngs;
|
||||||
|
return this.redraw();
|
||||||
|
},
|
||||||
|
|
||||||
|
addLatLng: function (latlng) {
|
||||||
|
this._latlngs.push(latlng);
|
||||||
|
return this.redraw();
|
||||||
|
},
|
||||||
|
|
||||||
|
setOptions: function (options) {
|
||||||
|
L.setOptions(this, options);
|
||||||
|
if (this._heat) this._updateOptions();
|
||||||
|
return this.redraw();
|
||||||
|
},
|
||||||
|
|
||||||
|
redraw: function () {
|
||||||
|
if (this._heat && !this._frame && this._map && !this._map._animating) {
|
||||||
|
this._frame = L.Util.requestAnimFrame(this._redraw, this);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
onAdd: function (map) {
|
||||||
|
this._map = map;
|
||||||
|
if (!this._canvas) this._initCanvas();
|
||||||
|
if (this.options.pane) this.getPane().appendChild(this._canvas);
|
||||||
|
else map._panes.overlayPane.appendChild(this._canvas);
|
||||||
|
map.on('moveend', this._reset, this);
|
||||||
|
if (map.options.zoomAnimation && L.Browser.any3d) {
|
||||||
|
map.on('zoomanim', this._animateZoom, this);
|
||||||
|
}
|
||||||
|
this._reset();
|
||||||
|
},
|
||||||
|
|
||||||
|
onRemove: function (map) {
|
||||||
|
if (this.options.pane) this.getPane().removeChild(this._canvas);
|
||||||
|
else map.getPanes().overlayPane.removeChild(this._canvas);
|
||||||
|
map.off('moveend', this._reset, this);
|
||||||
|
if (map.options.zoomAnimation) {
|
||||||
|
map.off('zoomanim', this._animateZoom, this);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addTo: function (map) {
|
||||||
|
map.addLayer(this);
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
_initCanvas: function () {
|
||||||
|
var canvas = this._canvas = L.DomUtil.create('canvas', 'leaflet-heatmap-layer leaflet-layer');
|
||||||
|
var originProp = L.DomUtil.testProp(['transformOrigin', 'WebkitTransformOrigin', 'msTransformOrigin']);
|
||||||
|
canvas.style[originProp] = '50% 50%';
|
||||||
|
var size = this._map.getSize();
|
||||||
|
canvas.width = size.x;
|
||||||
|
canvas.height = size.y;
|
||||||
|
var animated = this._map.options.zoomAnimation && L.Browser.any3d;
|
||||||
|
L.DomUtil.addClass(canvas, 'leaflet-zoom-' + (animated ? 'animated' : 'hide'));
|
||||||
|
this._heat = simpleheat(canvas);
|
||||||
|
this._updateOptions();
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateOptions: function () {
|
||||||
|
this._heat.radius(this.options.radius || this._heat.defaultRadius, this.options.blur);
|
||||||
|
if (this.options.gradient) this._heat.gradient(this.options.gradient);
|
||||||
|
if (this.options.minOpacity) this._heat.minOpacity = this.options.minOpacity;
|
||||||
|
},
|
||||||
|
|
||||||
|
_reset: function () {
|
||||||
|
var topLeft = this._map.containerPointToLayerPoint([0, 0]);
|
||||||
|
L.DomUtil.setPosition(this._canvas, topLeft);
|
||||||
|
var size = this._map.getSize();
|
||||||
|
if (this._heat._width !== size.x) {
|
||||||
|
this._canvas.width = this._heat._width = size.x;
|
||||||
|
}
|
||||||
|
if (this._heat._height !== size.y) {
|
||||||
|
this._canvas.height = this._heat._height = size.y;
|
||||||
|
}
|
||||||
|
this._redraw();
|
||||||
|
},
|
||||||
|
|
||||||
|
_redraw: function () {
|
||||||
|
this._frame = null;
|
||||||
|
if (!this._map) return;
|
||||||
|
var data = [],
|
||||||
|
r = this._heat._r,
|
||||||
|
size = this._map.getSize(),
|
||||||
|
bounds = new L.Bounds(L.point([-r, -r]), size.add([r, r])),
|
||||||
|
max = this.options.max === undefined ? 1 : this.options.max,
|
||||||
|
maxZoom = this.options.maxZoom === undefined ? this._map.getMaxZoom() : this.options.maxZoom,
|
||||||
|
v = 1 / Math.pow(2, Math.max(0, Math.min(maxZoom - this._map.getZoom(), 12))),
|
||||||
|
cellSize = r / 2,
|
||||||
|
grid = [],
|
||||||
|
panePos = this._map._getMapPanePos(),
|
||||||
|
offsetX = panePos.x % cellSize,
|
||||||
|
offsetY = panePos.y % cellSize,
|
||||||
|
i, len, p, cell, x, y, j, len2, k;
|
||||||
|
|
||||||
|
for (i = 0, len = this._latlngs.length; i < len; i++) {
|
||||||
|
p = this._map.latLngToContainerPoint(this._latlngs[i]);
|
||||||
|
if (bounds.contains(p)) {
|
||||||
|
x = Math.floor((p.x - offsetX) / cellSize) + 2;
|
||||||
|
y = Math.floor((p.y - offsetY) / cellSize) + 2;
|
||||||
|
var alt = this._latlngs[i].alt !== undefined ? this._latlngs[i].alt :
|
||||||
|
this._latlngs[i][2] !== undefined ? +this._latlngs[i][2] : 1;
|
||||||
|
k = alt * v;
|
||||||
|
grid[y] = grid[y] || [];
|
||||||
|
cell = grid[y][x];
|
||||||
|
if (!cell) {
|
||||||
|
grid[y][x] = [p.x, p.y, k];
|
||||||
|
} else {
|
||||||
|
cell[0] = (cell[0] * cell[2] + p.x * k) / (cell[2] + k);
|
||||||
|
cell[1] = (cell[1] * cell[2] + p.y * k) / (cell[2] + k);
|
||||||
|
cell[2] += k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i = 0, len = grid.length; i < len; i++) {
|
||||||
|
if (grid[i]) {
|
||||||
|
for (j = 0, len2 = grid[i].length; j < len2; j++) {
|
||||||
|
cell = grid[i][j];
|
||||||
|
if (cell) {
|
||||||
|
data.push([
|
||||||
|
Math.round(cell[0]),
|
||||||
|
Math.round(cell[1]),
|
||||||
|
Math.min(cell[2], max)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._heat.data(data).draw(this.options.minOpacity);
|
||||||
|
},
|
||||||
|
|
||||||
|
_animateZoom: function (e) {
|
||||||
|
var scale = this._map.getZoomScale(e.zoom),
|
||||||
|
offset = this._map._getCenterOffset(e.center)._multiplyBy(-scale).subtract(this._map._getMapPanePos());
|
||||||
|
if (L.DomUtil.setTransform) {
|
||||||
|
L.DomUtil.setTransform(this._canvas, offset, scale);
|
||||||
|
} else {
|
||||||
|
this._canvas.style[L.DomUtil.TRANSFORM] = L.DomUtil.getTranslateString(offset) + ' scale(' + scale + ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
L.heatLayer = function (latlngs, options) {
|
||||||
|
return new L.HeatLayer(latlngs, options);
|
||||||
|
};
|
||||||
|
}());
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "INTERCEPT Signal Intelligence",
|
||||||
|
"short_name": "INTERCEPT",
|
||||||
|
"description": "Unified SIGINT platform for software-defined radio analysis",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0b1118",
|
||||||
|
"theme_color": "#0b1118",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
/* INTERCEPT Service Worker — cache-first static, network-only for API/SSE/WS */
|
||||||
|
const CACHE_NAME = 'intercept-v3';
|
||||||
|
|
||||||
|
const NETWORK_ONLY_PREFIXES = [
|
||||||
|
'/stream', '/ws/', '/api/', '/gps/', '/wifi/', '/bluetooth/',
|
||||||
|
'/adsb/', '/ais/', '/acars/', '/aprs/', '/tscm/', '/satellite/',
|
||||||
|
'/meshtastic/', '/bt_locate/', '/receiver/', '/sensor/', '/pager/',
|
||||||
|
'/sstv/', '/weather-sat/', '/subghz/', '/rtlamr/', '/dsc/', '/vdl2/',
|
||||||
|
'/spy/', '/space-weather/', '/websdr/', '/analytics/', '/correlation/',
|
||||||
|
'/recordings/', '/controller/', '/ops/',
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATIC_PREFIXES = [
|
||||||
|
'/static/css/',
|
||||||
|
'/static/js/',
|
||||||
|
'/static/icons/',
|
||||||
|
'/static/fonts/',
|
||||||
|
];
|
||||||
|
|
||||||
|
const CACHE_EXACT = ['/manifest.json'];
|
||||||
|
|
||||||
|
function isHttpRequest(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNetworkOnly(req) {
|
||||||
|
if (req.method !== 'GET') return true;
|
||||||
|
const accept = req.headers.get('Accept') || '';
|
||||||
|
if (accept.includes('text/event-stream')) return true;
|
||||||
|
const url = new URL(req.url);
|
||||||
|
return NETWORK_ONLY_PREFIXES.some(p => url.pathname.startsWith(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStaticAsset(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (CACHE_EXACT.includes(url.pathname)) return true;
|
||||||
|
return STATIC_PREFIXES.some(p => url.pathname.startsWith(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackResponse(req, status = 503) {
|
||||||
|
const accept = req.headers.get('Accept') || '';
|
||||||
|
if (accept.includes('application/json')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ status: 'error', message: 'Network unavailable' }),
|
||||||
|
{
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accept.includes('text/event-stream')) {
|
||||||
|
return new Response('', {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'text/event-stream' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Offline', {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('install', (e) => {
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (e) => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches.keys().then(keys =>
|
||||||
|
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
|
||||||
|
).then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (e) => {
|
||||||
|
const req = e.request;
|
||||||
|
|
||||||
|
// Ignore non-HTTP(S) requests so extensions/browser-internal URLs are untouched.
|
||||||
|
if (!isHttpRequest(req)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always bypass service worker for non-GET and streaming routes
|
||||||
|
if (isNetworkOnly(req)) {
|
||||||
|
e.respondWith(
|
||||||
|
fetch(req).catch(() => fallbackResponse(req, 503))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache-first for static assets
|
||||||
|
if (isStaticAsset(req)) {
|
||||||
|
e.respondWith(
|
||||||
|
caches.open(CACHE_NAME).then(cache =>
|
||||||
|
cache.match(req).then(cached => {
|
||||||
|
if (cached) {
|
||||||
|
// Revalidate in background
|
||||||
|
fetch(req).then(res => {
|
||||||
|
if (res && res.status === 200) cache.put(req, res.clone());
|
||||||
|
}).catch(() => {});
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
return fetch(req).then(res => {
|
||||||
|
if (res && res.status === 200) cache.put(req, res.clone());
|
||||||
|
return res;
|
||||||
|
}).catch(() => fallbackResponse(req, 504));
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network-first for HTML pages
|
||||||
|
e.respondWith(
|
||||||
|
fetch(req).catch(() =>
|
||||||
|
caches.match(req).then(cached => cached || new Response('Offline', { status: 503 }))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -54,8 +54,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{% if not embedded %}
|
||||||
{% set active_mode = 'adsb' %}
|
{% set active_mode = 'adsb' %}
|
||||||
{% include 'partials/nav.html' with context %}
|
{% include 'partials/nav.html' with context %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Slim Statistics Bar -->
|
<!-- Slim Statistics Bar -->
|
||||||
<div class="stats-strip">
|
<div class="stats-strip">
|
||||||
@@ -246,6 +248,10 @@
|
|||||||
<div class="display-container">
|
<div class="display-container">
|
||||||
<div id="radarMap">
|
<div id="radarMap">
|
||||||
</div>
|
</div>
|
||||||
|
<div id="mapCrosshairOverlay" class="map-crosshair-overlay" aria-hidden="true">
|
||||||
|
<div class="map-crosshair-line map-crosshair-vertical"></div>
|
||||||
|
<div class="map-crosshair-line map-crosshair-horizontal"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -417,6 +423,17 @@
|
|||||||
let alertsEnabled = true;
|
let alertsEnabled = true;
|
||||||
let detectionSoundEnabled = localStorage.getItem('adsb_detectionSound') !== 'false'; // Default on
|
let detectionSoundEnabled = localStorage.getItem('adsb_detectionSound') !== 'false'; // Default on
|
||||||
let soundedAircraft = {}; // Track aircraft we've played detection sound for
|
let soundedAircraft = {}; // Track aircraft we've played detection sound for
|
||||||
|
const MAP_CROSSHAIR_DURATION_MS = 1500;
|
||||||
|
const PANEL_SELECTION_BASE_ZOOM = 10;
|
||||||
|
const PANEL_SELECTION_MAX_ZOOM = 12;
|
||||||
|
const PANEL_SELECTION_ZOOM_INCREMENT = 1.4;
|
||||||
|
const PANEL_SELECTION_STAGE1_DURATION_SEC = 1.05;
|
||||||
|
const PANEL_SELECTION_STAGE2_DURATION_SEC = 1.15;
|
||||||
|
const PANEL_SELECTION_STAGE_GAP_MS = 180;
|
||||||
|
let mapCrosshairResetTimer = null;
|
||||||
|
let panelSelectionFallbackTimer = null;
|
||||||
|
let panelSelectionStageTimer = null;
|
||||||
|
let mapCrosshairRequestId = 0;
|
||||||
// Watchlist - persisted to localStorage
|
// Watchlist - persisted to localStorage
|
||||||
let watchlist = JSON.parse(localStorage.getItem('adsb_watchlist') || '[]');
|
let watchlist = JSON.parse(localStorage.getItem('adsb_watchlist') || '[]');
|
||||||
|
|
||||||
@@ -2608,7 +2625,7 @@ sudo make install</code>
|
|||||||
} else {
|
} else {
|
||||||
markers[icao] = L.marker([ac.lat, ac.lon], { icon: createMarkerIcon(rotation, color, iconType, isSelected) })
|
markers[icao] = L.marker([ac.lat, ac.lon], { icon: createMarkerIcon(rotation, color, iconType, isSelected) })
|
||||||
.addTo(radarMap)
|
.addTo(radarMap)
|
||||||
.on('click', () => selectAircraft(icao));
|
.on('click', () => selectAircraft(icao, 'map'));
|
||||||
markers[icao].bindTooltip(`${callsign}<br>${alt}`, {
|
markers[icao].bindTooltip(`${callsign}<br>${alt}`, {
|
||||||
permanent: false, direction: 'top', className: 'aircraft-tooltip'
|
permanent: false, direction: 'top', className: 'aircraft-tooltip'
|
||||||
});
|
});
|
||||||
@@ -2712,7 +2729,7 @@ sudo make install</code>
|
|||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = `aircraft-item ${selectedIcao === ac.icao ? 'selected' : ''} ${isOnWatchlist(ac) ? 'watched' : ''}`;
|
div.className = `aircraft-item ${selectedIcao === ac.icao ? 'selected' : ''} ${isOnWatchlist(ac) ? 'watched' : ''}`;
|
||||||
div.setAttribute('data-icao', ac.icao);
|
div.setAttribute('data-icao', ac.icao);
|
||||||
div.onclick = () => selectAircraft(ac.icao);
|
div.onclick = () => selectAircraft(ac.icao, 'panel');
|
||||||
div.innerHTML = buildAircraftItemHTML(ac);
|
div.innerHTML = buildAircraftItemHTML(ac);
|
||||||
fragment.appendChild(div);
|
fragment.appendChild(div);
|
||||||
});
|
});
|
||||||
@@ -2782,9 +2799,139 @@ sudo make install</code>
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectAircraft(icao) {
|
function triggerMapCrosshairAnimation(lat, lon, durationMs = MAP_CROSSHAIR_DURATION_MS, lockToMapCenter = false) {
|
||||||
|
if (!radarMap) return;
|
||||||
|
const overlay = document.getElementById('mapCrosshairOverlay');
|
||||||
|
if (!overlay) return;
|
||||||
|
|
||||||
|
const size = radarMap.getSize();
|
||||||
|
let targetX;
|
||||||
|
let targetY;
|
||||||
|
|
||||||
|
if (lockToMapCenter) {
|
||||||
|
targetX = size.x / 2;
|
||||||
|
targetY = size.y / 2;
|
||||||
|
} else {
|
||||||
|
const point = radarMap.latLngToContainerPoint([lat, lon]);
|
||||||
|
targetX = Math.max(0, Math.min(size.x, point.x));
|
||||||
|
targetY = Math.max(0, Math.min(size.y, point.y));
|
||||||
|
}
|
||||||
|
|
||||||
|
const startX = size.x + 8;
|
||||||
|
const startY = size.y + 8;
|
||||||
|
|
||||||
|
overlay.style.setProperty('--crosshair-x-start', `${startX}px`);
|
||||||
|
overlay.style.setProperty('--crosshair-y-start', `${startY}px`);
|
||||||
|
overlay.style.setProperty('--crosshair-x-end', `${targetX}px`);
|
||||||
|
overlay.style.setProperty('--crosshair-y-end', `${targetY}px`);
|
||||||
|
overlay.style.setProperty('--crosshair-duration', `${durationMs}ms`);
|
||||||
|
overlay.classList.remove('active');
|
||||||
|
void overlay.offsetWidth;
|
||||||
|
overlay.classList.add('active');
|
||||||
|
|
||||||
|
if (mapCrosshairResetTimer) {
|
||||||
|
clearTimeout(mapCrosshairResetTimer);
|
||||||
|
}
|
||||||
|
mapCrosshairResetTimer = setTimeout(() => {
|
||||||
|
overlay.classList.remove('active');
|
||||||
|
mapCrosshairResetTimer = null;
|
||||||
|
}, durationMs + 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPanelSelectionFinalZoom() {
|
||||||
|
if (!radarMap) return PANEL_SELECTION_BASE_ZOOM;
|
||||||
|
const currentZoom = radarMap.getZoom();
|
||||||
|
const maxZoom = typeof radarMap.getMaxZoom === 'function' ? radarMap.getMaxZoom() : PANEL_SELECTION_MAX_ZOOM;
|
||||||
|
return Math.min(
|
||||||
|
PANEL_SELECTION_MAX_ZOOM,
|
||||||
|
maxZoom,
|
||||||
|
Math.max(PANEL_SELECTION_BASE_ZOOM, currentZoom + PANEL_SELECTION_ZOOM_INCREMENT)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPanelSelectionIntermediateZoom(finalZoom) {
|
||||||
|
if (!radarMap) return finalZoom;
|
||||||
|
const currentZoom = radarMap.getZoom();
|
||||||
|
if (finalZoom - currentZoom < 0.8) {
|
||||||
|
return finalZoom;
|
||||||
|
}
|
||||||
|
const midpointZoom = currentZoom + ((finalZoom - currentZoom) * 0.55);
|
||||||
|
return Math.min(finalZoom - 0.45, midpointZoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPanelSelectionAnimation(lat, lon, requestId) {
|
||||||
|
if (!radarMap) return;
|
||||||
|
|
||||||
|
const finalZoom = getPanelSelectionFinalZoom();
|
||||||
|
const intermediateZoom = getPanelSelectionIntermediateZoom(finalZoom);
|
||||||
|
const sequenceDurationMs = Math.round(
|
||||||
|
((PANEL_SELECTION_STAGE1_DURATION_SEC + PANEL_SELECTION_STAGE2_DURATION_SEC) * 1000) +
|
||||||
|
PANEL_SELECTION_STAGE_GAP_MS + 260
|
||||||
|
);
|
||||||
|
const startSecondStage = () => {
|
||||||
|
if (requestId !== mapCrosshairRequestId) return;
|
||||||
|
radarMap.flyTo([lat, lon], finalZoom, {
|
||||||
|
animate: true,
|
||||||
|
duration: PANEL_SELECTION_STAGE2_DURATION_SEC,
|
||||||
|
easeLinearity: 0.2
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
triggerMapCrosshairAnimation(
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
Math.max(MAP_CROSSHAIR_DURATION_MS, sequenceDurationMs),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (intermediateZoom >= finalZoom - 0.1) {
|
||||||
|
radarMap.flyTo([lat, lon], finalZoom, {
|
||||||
|
animate: true,
|
||||||
|
duration: PANEL_SELECTION_STAGE2_DURATION_SEC,
|
||||||
|
easeLinearity: 0.2
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stage1Handled = false;
|
||||||
|
const finishStage1 = () => {
|
||||||
|
if (stage1Handled || requestId !== mapCrosshairRequestId) return;
|
||||||
|
stage1Handled = true;
|
||||||
|
if (panelSelectionFallbackTimer) {
|
||||||
|
clearTimeout(panelSelectionFallbackTimer);
|
||||||
|
panelSelectionFallbackTimer = null;
|
||||||
|
}
|
||||||
|
panelSelectionStageTimer = setTimeout(() => {
|
||||||
|
panelSelectionStageTimer = null;
|
||||||
|
startSecondStage();
|
||||||
|
}, PANEL_SELECTION_STAGE_GAP_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
radarMap.once('moveend', finishStage1);
|
||||||
|
panelSelectionFallbackTimer = setTimeout(
|
||||||
|
finishStage1,
|
||||||
|
Math.round(PANEL_SELECTION_STAGE1_DURATION_SEC * 1000) + 160
|
||||||
|
);
|
||||||
|
|
||||||
|
radarMap.flyTo([lat, lon], intermediateZoom, {
|
||||||
|
animate: true,
|
||||||
|
duration: PANEL_SELECTION_STAGE1_DURATION_SEC,
|
||||||
|
easeLinearity: 0.2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAircraft(icao, source = 'map') {
|
||||||
const prevSelected = selectedIcao;
|
const prevSelected = selectedIcao;
|
||||||
selectedIcao = icao;
|
selectedIcao = icao;
|
||||||
|
mapCrosshairRequestId += 1;
|
||||||
|
if (panelSelectionFallbackTimer) {
|
||||||
|
clearTimeout(panelSelectionFallbackTimer);
|
||||||
|
panelSelectionFallbackTimer = null;
|
||||||
|
}
|
||||||
|
if (panelSelectionStageTimer) {
|
||||||
|
clearTimeout(panelSelectionStageTimer);
|
||||||
|
panelSelectionStageTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Update marker icons for both previous and new selection
|
// Update marker icons for both previous and new selection
|
||||||
[prevSelected, icao].forEach(targetIcao => {
|
[prevSelected, icao].forEach(targetIcao => {
|
||||||
@@ -2809,7 +2956,15 @@ sudo make install</code>
|
|||||||
|
|
||||||
const ac = aircraft[icao];
|
const ac = aircraft[icao];
|
||||||
if (ac && ac.lat !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) {
|
if (ac && ac.lat !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) {
|
||||||
radarMap.setView([ac.lat, ac.lon], 10);
|
const targetLat = ac.lat;
|
||||||
|
const targetLon = ac.lon;
|
||||||
|
|
||||||
|
if (source === 'panel' && radarMap) {
|
||||||
|
runPanelSelectionAnimation(targetLat, targetLon, mapCrosshairRequestId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
radarMap.setView([targetLat, targetLon], 10);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3079,7 +3234,7 @@ sudo make install</code>
|
|||||||
|
|
||||||
function initAirband() {
|
function initAirband() {
|
||||||
// Check if audio tools are available
|
// Check if audio tools are available
|
||||||
fetch('/listening/tools')
|
fetch('/receiver/tools')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
const missingTools = [];
|
const missingTools = [];
|
||||||
@@ -3229,7 +3384,7 @@ sudo make install</code>
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Start audio on backend
|
// Start audio on backend
|
||||||
const response = await fetch('/listening/audio/start', {
|
const response = await fetch('/receiver/audio/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -3266,7 +3421,7 @@ sudo make install</code>
|
|||||||
audioPlayer.load();
|
audioPlayer.load();
|
||||||
|
|
||||||
// Connect to stream
|
// Connect to stream
|
||||||
const streamUrl = `/listening/audio/stream?t=${Date.now()}`;
|
const streamUrl = `/receiver/audio/stream?t=${Date.now()}`;
|
||||||
console.log('[AIRBAND] Connecting to stream:', streamUrl);
|
console.log('[AIRBAND] Connecting to stream:', streamUrl);
|
||||||
audioPlayer.src = streamUrl;
|
audioPlayer.src = streamUrl;
|
||||||
|
|
||||||
@@ -3310,7 +3465,7 @@ sudo make install</code>
|
|||||||
audioPlayer.pause();
|
audioPlayer.pause();
|
||||||
audioPlayer.src = '';
|
audioPlayer.src = '';
|
||||||
|
|
||||||
fetch('/listening/audio/stop', { method: 'POST' })
|
fetch('/receiver/audio/stop', { method: 'POST' })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(() => {
|
.then(() => {
|
||||||
isAirbandPlaying = false;
|
isAirbandPlaying = false;
|
||||||
|
|||||||
@@ -54,8 +54,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{% if not embedded %}
|
||||||
{% set active_mode = 'ais' %}
|
{% set active_mode = 'ais' %}
|
||||||
{% include 'partials/nav.html' with context %}
|
{% include 'partials/nav.html' with context %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="stats-strip">
|
<div class="stats-strip">
|
||||||
<div class="stats-strip-inner">
|
<div class="stats-strip-inner">
|
||||||
|
|||||||
+947
-807
File diff suppressed because it is too large
Load Diff
@@ -166,7 +166,9 @@
|
|||||||
|
|
||||||
{% block navigation %}
|
{% block navigation %}
|
||||||
{# Include the unified nav partial with active_mode set #}
|
{# Include the unified nav partial with active_mode set #}
|
||||||
|
{% if not embedded %}
|
||||||
{% include 'partials/nav.html' with context %}
|
{% include 'partials/nav.html' with context %}
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span><span class="desc">Aircraft - ADS-B tracking & history</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span><span class="desc">Aircraft - ADS-B tracking & history</span></div>
|
||||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg></span><span class="desc">Vessels - AIS & VHF DSC distress</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg></span><span class="desc">Vessels - AIS & VHF DSC distress</span></div>
|
||||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span><span class="desc">APRS - Amateur radio tracking</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span><span class="desc">APRS - Amateur radio tracking</span></div>
|
||||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span><span class="desc">Listening Post - SDR scanner</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M2 18h20" opacity="0.4"/><path d="M2 21h20" opacity="0.2"/></svg></span><span class="desc">Waterfall - SDR receiver + signal ID</span></div>
|
||||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span><span class="desc">Spy Stations - Number stations database</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span><span class="desc">Spy Stations - Number stations database</span></div>
|
||||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span><span class="desc">Meshtastic - LoRa mesh networking</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span><span class="desc">Meshtastic - LoRa mesh networking</span></div>
|
||||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span><span class="desc">WebSDR - Remote SDR receivers</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span><span class="desc">WebSDR - Remote SDR receivers</span></div>
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
<li>Interactive map shows station positions in real-time</li>
|
<li>Interactive map shows station positions in real-time</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Listening Post Mode</h3>
|
<h3>Spectrum Waterfall Mode</h3>
|
||||||
<ul class="tip-list">
|
<ul class="tip-list">
|
||||||
<li>Wideband SDR scanner with spectrum visualization</li>
|
<li>Wideband SDR scanner with spectrum visualization</li>
|
||||||
<li>Tune to any frequency supported by your SDR hardware</li>
|
<li>Tune to any frequency supported by your SDR hardware</li>
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
<li>Browse stations from priyom.org with frequencies and schedules</li>
|
<li>Browse stations from priyom.org with frequencies and schedules</li>
|
||||||
<li>Filter by type (number/diplomatic), country, and mode</li>
|
<li>Filter by type (number/diplomatic), country, and mode</li>
|
||||||
<li>Famous stations: UVB-76 "The Buzzer", Cuban HM01, Israeli E17z</li>
|
<li>Famous stations: UVB-76 "The Buzzer", Cuban HM01, Israeli E17z</li>
|
||||||
<li>Click "Tune" to listen via Listening Post mode</li>
|
<li>Click "Tune" to listen via Spectrum Waterfall mode</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Meshtastic Mode</h3>
|
<h3>Meshtastic Mode</h3>
|
||||||
@@ -166,11 +166,27 @@
|
|||||||
<li>View next pass predictions with elevation and duration</li>
|
<li>View next pass predictions with elevation and duration</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<h3>ACARS Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Decodes Aircraft Communications Addressing and Reporting System messages via acarsdec</li>
|
||||||
|
<li>Receives operational, weather, and position reports on 129-136 MHz</li>
|
||||||
|
<li>Supports North America, Europe, and Asia-Pacific regional frequency presets</li>
|
||||||
|
<li>Filter by message type, flight ID, or aircraft registration</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>VDL2 Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Decodes VHF Data Link Mode 2 aircraft datalink messages via dumpvdl2</li>
|
||||||
|
<li>Captures ACARS-over-AVLC frames with full signal analysis (SNR, burst length)</li>
|
||||||
|
<li>Monitor multiple VDL2 frequencies simultaneously (136.725, 136.775, 136.975 MHz)</li>
|
||||||
|
<li>Export captured messages to CSV or JSON for offline analysis</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<h3>ISS SSTV Mode</h3>
|
<h3>ISS SSTV Mode</h3>
|
||||||
<ul class="tip-list">
|
<ul class="tip-list">
|
||||||
<li>Decodes Slow Scan Television (SSTV) images from the International Space Station</li>
|
<li>Decodes Slow Scan Television (SSTV) images from the International Space Station</li>
|
||||||
<li>Automated ISS pass tracking with Doppler correction on 145.800 MHz</li>
|
<li>Automated ISS pass tracking with Doppler correction on 145.800 MHz</li>
|
||||||
<li>Images decoded in real-time using slowrx</li>
|
<li>Images decoded in real-time using the built-in pure Python decoder</li>
|
||||||
<li>Gallery view with timestamped decoded images</li>
|
<li>Gallery view with timestamped decoded images</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -330,15 +346,17 @@
|
|||||||
<li><strong>Aircraft (ACARS):</strong> Second RTL-SDR, acarsdec</li>
|
<li><strong>Aircraft (ACARS):</strong> Second RTL-SDR, acarsdec</li>
|
||||||
<li><strong>Vessels (AIS):</strong> RTL-SDR, AIS-catcher</li>
|
<li><strong>Vessels (AIS):</strong> RTL-SDR, AIS-catcher</li>
|
||||||
<li><strong>APRS:</strong> RTL-SDR, direwolf or multimon-ng</li>
|
<li><strong>APRS:</strong> RTL-SDR, direwolf or multimon-ng</li>
|
||||||
<li><strong>Listening Post:</strong> RTL-SDR or SoapySDR-compatible hardware</li>
|
<li><strong>Spectrum Waterfall:</strong> RTL-SDR or SoapySDR-compatible hardware</li>
|
||||||
<li><strong>Spy Stations:</strong> Internet connection (database lookup)</li>
|
<li><strong>Spy Stations:</strong> Internet connection (database lookup)</li>
|
||||||
<li><strong>Meshtastic:</strong> Meshtastic LoRa device, <code>pip install meshtastic</code></li>
|
<li><strong>Meshtastic:</strong> Meshtastic LoRa device, <code>pip install meshtastic</code></li>
|
||||||
<li><strong>WebSDR:</strong> Internet connection (remote receivers)</li>
|
<li><strong>WebSDR:</strong> Internet connection (remote receivers)</li>
|
||||||
<li><strong>SubGHz:</strong> RTL-SDR or compatible SDR hardware</li>
|
<li><strong>SubGHz:</strong> RTL-SDR or compatible SDR hardware</li>
|
||||||
<li><strong>Satellite:</strong> Internet for Celestrak (optional), skyfield</li>
|
<li><strong>Satellite:</strong> Internet for Celestrak (optional), skyfield</li>
|
||||||
<li><strong>ISS SSTV:</strong> RTL-SDR, slowrx</li>
|
<li><strong>ACARS:</strong> RTL-SDR, acarsdec</li>
|
||||||
|
<li><strong>VDL2:</strong> RTL-SDR, dumpvdl2</li>
|
||||||
|
<li><strong>ISS SSTV:</strong> RTL-SDR (pure Python decoder — no external tools needed)</li>
|
||||||
<li><strong>Weather Sat:</strong> RTL-SDR, SatDump</li>
|
<li><strong>Weather Sat:</strong> RTL-SDR, SatDump</li>
|
||||||
<li><strong>HF SSTV:</strong> RTL-SDR or SoapySDR-compatible hardware, slowrx</li>
|
<li><strong>HF SSTV:</strong> RTL-SDR or SoapySDR-compatible hardware (pure Python decoder)</li>
|
||||||
<li><strong>GPS:</strong> RTL-SDR or GPS-capable SDR</li>
|
<li><strong>GPS:</strong> RTL-SDR or GPS-capable SDR</li>
|
||||||
<li><strong>Space Weather:</strong> Internet connection (public APIs)</li>
|
<li><strong>Space Weather:</strong> Internet connection (public APIs)</li>
|
||||||
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>
|
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
<!-- ANALYTICS MODE -->
|
|
||||||
<div id="analyticsMode" class="mode-content">
|
|
||||||
{# Analytics Dashboard Sidebar Panel #}
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
|
||||||
<span>Summary</span>
|
|
||||||
<span class="collapse-icon">▼</span>
|
|
||||||
</h3>
|
|
||||||
<div class="section-content">
|
|
||||||
<div class="analytics-grid" id="analyticsSummaryCards">
|
|
||||||
<div class="analytics-card" data-mode="adsb">
|
|
||||||
<div class="card-count" id="analyticsCountAdsb">0</div>
|
|
||||||
<div class="card-label">Aircraft</div>
|
|
||||||
<div class="card-sparkline" id="analyticsSparkAdsb"></div>
|
|
||||||
</div>
|
|
||||||
<div class="analytics-card" data-mode="ais">
|
|
||||||
<div class="card-count" id="analyticsCountAis">0</div>
|
|
||||||
<div class="card-label">Vessels</div>
|
|
||||||
<div class="card-sparkline" id="analyticsSparkAis"></div>
|
|
||||||
</div>
|
|
||||||
<div class="analytics-card" data-mode="wifi">
|
|
||||||
<div class="card-count" id="analyticsCountWifi">0</div>
|
|
||||||
<div class="card-label">WiFi</div>
|
|
||||||
<div class="card-sparkline" id="analyticsSparkWifi"></div>
|
|
||||||
</div>
|
|
||||||
<div class="analytics-card" data-mode="bluetooth">
|
|
||||||
<div class="card-count" id="analyticsCountBt">0</div>
|
|
||||||
<div class="card-label">Bluetooth</div>
|
|
||||||
<div class="card-sparkline" id="analyticsSparkBt"></div>
|
|
||||||
</div>
|
|
||||||
<div class="analytics-card" data-mode="dsc">
|
|
||||||
<div class="card-count" id="analyticsCountDsc">0</div>
|
|
||||||
<div class="card-label">DSC</div>
|
|
||||||
<div class="card-sparkline" id="analyticsSparkDsc"></div>
|
|
||||||
</div>
|
|
||||||
<div class="analytics-card" data-mode="acars">
|
|
||||||
<div class="card-count" id="analyticsCountAcars">0</div>
|
|
||||||
<div class="card-label">ACARS</div>
|
|
||||||
<div class="card-sparkline" id="analyticsSparkAcars"></div>
|
|
||||||
</div>
|
|
||||||
<div class="analytics-card" data-mode="vdl2">
|
|
||||||
<div class="card-count" id="analyticsCountVdl2">0</div>
|
|
||||||
<div class="card-label">VDL2</div>
|
|
||||||
<div class="card-sparkline" id="analyticsSparkVdl2"></div>
|
|
||||||
</div>
|
|
||||||
<div class="analytics-card" data-mode="aprs">
|
|
||||||
<div class="card-count" id="analyticsCountAprs">0</div>
|
|
||||||
<div class="card-label">APRS</div>
|
|
||||||
<div class="card-sparkline" id="analyticsSparkAprs"></div>
|
|
||||||
</div>
|
|
||||||
<div class="analytics-card" data-mode="meshtastic">
|
|
||||||
<div class="card-count" id="analyticsCountMesh">0</div>
|
|
||||||
<div class="card-label">Mesh</div>
|
|
||||||
<div class="card-sparkline" id="analyticsSparkMesh"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
|
||||||
<span>Operational Insights</span>
|
|
||||||
<span class="collapse-icon">▼</span>
|
|
||||||
</h3>
|
|
||||||
<div class="section-content">
|
|
||||||
<div class="analytics-insight-grid" id="analyticsInsights">
|
|
||||||
<div class="analytics-empty">Insights loading...</div>
|
|
||||||
</div>
|
|
||||||
<div class="analytics-top-changes">
|
|
||||||
<div class="analytics-section-header">Top Changes</div>
|
|
||||||
<div id="analyticsTopChanges">
|
|
||||||
<div class="analytics-empty">No change signals yet</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
|
||||||
<span>Mode Health</span>
|
|
||||||
<span class="collapse-icon">▼</span>
|
|
||||||
</h3>
|
|
||||||
<div class="section-content">
|
|
||||||
<div class="analytics-health" id="analyticsHealth"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section" id="analyticsSquawkSection" style="display:none;">
|
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
|
||||||
<span>Emergency Squawks</span>
|
|
||||||
<span class="collapse-icon">▼</span>
|
|
||||||
</h3>
|
|
||||||
<div class="section-content">
|
|
||||||
<div class="squawk-emergency" id="analyticsSquawkPanel">
|
|
||||||
<div class="squawk-title">Active Emergency Codes</div>
|
|
||||||
<div id="analyticsSquawkList"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
|
||||||
<span>Temporal Patterns</span>
|
|
||||||
<span class="collapse-icon">▼</span>
|
|
||||||
</h3>
|
|
||||||
<div class="section-content">
|
|
||||||
<div id="analyticsPatternList">
|
|
||||||
<div class="analytics-empty">No recurring patterns detected</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
|
||||||
<span>Recent Alerts</span>
|
|
||||||
<span class="collapse-icon">▼</span>
|
|
||||||
</h3>
|
|
||||||
<div class="section-content">
|
|
||||||
<div class="analytics-alert-feed" id="analyticsAlertFeed">
|
|
||||||
<div class="analytics-empty">No recent alerts</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
|
||||||
<span>Correlations</span>
|
|
||||||
<span class="collapse-icon">▼</span>
|
|
||||||
</h3>
|
|
||||||
<div class="section-content">
|
|
||||||
<div id="analyticsCorrelations">
|
|
||||||
<div class="analytics-empty">No correlations detected</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
|
||||||
<span>Geofences</span>
|
|
||||||
<span class="collapse-icon">▼</span>
|
|
||||||
</h3>
|
|
||||||
<div class="section-content">
|
|
||||||
<div id="analyticsGeofenceList"></div>
|
|
||||||
<button class="btn btn-sm" onclick="Analytics.addGeofence()" style="margin-top:8px; font-size:10px; padding:4px 10px; background:var(--accent-cyan); color:#fff; border:none; border-radius:4px; cursor:pointer;">
|
|
||||||
+ Add Zone
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
|
||||||
<span>Target View</span>
|
|
||||||
<span class="collapse-icon">▼</span>
|
|
||||||
</h3>
|
|
||||||
<div class="section-content">
|
|
||||||
<div class="analytics-target-toolbar">
|
|
||||||
<input id="analyticsTargetQuery" type="text" placeholder="Search callsign, ICAO, MMSI, MAC, SSID, node..." onkeydown="if(event.key==='Enter'){Analytics.searchTarget();}">
|
|
||||||
<button onclick="Analytics.searchTarget()">Search</button>
|
|
||||||
</div>
|
|
||||||
<div id="analyticsTargetSummary" class="analytics-target-summary">Search to correlate entities across modes</div>
|
|
||||||
<div id="analyticsTargetResults">
|
|
||||||
<div class="analytics-empty">No target selected</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
|
||||||
<span>Session Replay</span>
|
|
||||||
<span class="collapse-icon">▼</span>
|
|
||||||
</h3>
|
|
||||||
<div class="section-content">
|
|
||||||
<div class="analytics-replay-toolbar">
|
|
||||||
<select id="analyticsReplaySelect"></select>
|
|
||||||
<button onclick="Analytics.loadReplay()">Load</button>
|
|
||||||
<button onclick="Analytics.playReplay()">Play</button>
|
|
||||||
<button onclick="Analytics.pauseReplay()">Pause</button>
|
|
||||||
<button onclick="Analytics.stepReplay()">Step</button>
|
|
||||||
</div>
|
|
||||||
<div id="analyticsReplayMeta" class="analytics-target-summary">No replay loaded</div>
|
|
||||||
<div id="analyticsReplayTimeline">
|
|
||||||
<div class="analytics-empty">Select a recording to replay key events</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
|
||||||
<span>Export Data</span>
|
|
||||||
<span class="collapse-icon">▼</span>
|
|
||||||
</h3>
|
|
||||||
<div class="section-content">
|
|
||||||
<div class="export-controls">
|
|
||||||
<select id="exportMode">
|
|
||||||
<option value="adsb">ADS-B</option>
|
|
||||||
<option value="ais">AIS</option>
|
|
||||||
<option value="wifi">WiFi</option>
|
|
||||||
<option value="bluetooth">Bluetooth</option>
|
|
||||||
<option value="dsc">DSC</option>
|
|
||||||
</select>
|
|
||||||
<select id="exportFormat">
|
|
||||||
<option value="json">JSON</option>
|
|
||||||
<option value="csv">CSV</option>
|
|
||||||
</select>
|
|
||||||
<button onclick="Analytics.exportData()">Export</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
<!-- DMR / DIGITAL VOICE MODE -->
|
|
||||||
<div id="dmrMode" class="mode-content">
|
|
||||||
<div class="section">
|
|
||||||
<h3>Digital Voice</h3>
|
|
||||||
<div class="alpha-mode-notice">
|
|
||||||
ALPHA: Digital Voice decoding is still in active development. Expect occasional decode instability and false protocol locks.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dependency Warning -->
|
|
||||||
<div id="dmrToolsWarning" style="display: none; background: rgba(255, 100, 100, 0.1); border: 1px solid var(--accent-red); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
|
||||||
<p style="color: var(--accent-red); margin: 0; font-size: 0.85em;">
|
|
||||||
<strong>Missing:</strong><br>
|
|
||||||
<span id="dmrToolsWarningText"></span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Frequency (MHz)</label>
|
|
||||||
<input type="number" id="dmrFrequency" value="462.5625" step="0.0001" style="width: 100%;">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Protocol</label>
|
|
||||||
<select id="dmrProtocol">
|
|
||||||
<option value="auto" selected>Auto Detect (DMR/P25/D-STAR)</option>
|
|
||||||
<option value="dmr">DMR</option>
|
|
||||||
<option value="p25">P25</option>
|
|
||||||
<option value="nxdn">NXDN</option>
|
|
||||||
<option value="dstar">D-STAR</option>
|
|
||||||
<option value="provoice">ProVoice</option>
|
|
||||||
</select>
|
|
||||||
<span style="font-size: 0.75em; color: var(--text-muted); display: block; margin-top: 2px;">
|
|
||||||
For NXDN and ProVoice, use manual protocol selection for best lock reliability
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Gain</label>
|
|
||||||
<input type="number" id="dmrGain" value="40" min="0" max="50" style="width: 100%;">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>PPM Correction</label>
|
|
||||||
<input type="number" id="dmrPPM" value="0" min="-200" max="200" step="1" style="width: 100%;"
|
|
||||||
title="Frequency error correction for your RTL-SDR dongle. Digital voice is very sensitive to frequency offset.">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group" style="margin-top: 4px;">
|
|
||||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
|
||||||
<input type="checkbox" id="dmrRelaxCrc" style="width: auto; accent-color: var(--accent-cyan);">
|
|
||||||
<span>Relax CRC (weak signals)</span>
|
|
||||||
</label>
|
|
||||||
<span style="font-size: 0.75em; color: var(--text-muted); display: block; margin-top: 2px;">
|
|
||||||
Allows more frames through on marginal signals at the cost of occasional errors
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bookmarks -->
|
|
||||||
<div class="section" style="margin-top: 8px;">
|
|
||||||
<h3>Bookmarks</h3>
|
|
||||||
<div style="display: flex; gap: 4px; margin-bottom: 6px;">
|
|
||||||
<input type="number" id="dmrBookmarkFreq" placeholder="Freq MHz" step="0.0001"
|
|
||||||
style="flex: 1; font-size: 11px; padding: 4px 6px;">
|
|
||||||
<button class="preset-btn" onclick="addDmrBookmark()" style="font-size: 10px; padding: 4px 8px;"
|
|
||||||
title="Add bookmark">+</button>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; gap: 4px; margin-bottom: 6px;">
|
|
||||||
<input type="text" id="dmrBookmarkLabel" placeholder="Label (optional)"
|
|
||||||
style="flex: 1; font-size: 11px; padding: 4px 6px;">
|
|
||||||
<button class="preset-btn" onclick="addCurrentDmrFreqBookmark()" style="font-size: 9px; padding: 4px 6px;"
|
|
||||||
title="Save current frequency">Save current</button>
|
|
||||||
</div>
|
|
||||||
<div id="dmrBookmarksList" style="max-height: 150px; overflow-y: auto;">
|
|
||||||
<div style="color: var(--text-muted); text-align: center; padding: 10px; font-size: 11px;">No bookmarks saved</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Current Call -->
|
|
||||||
<div class="section" style="margin-top: 12px;">
|
|
||||||
<h3>Current Call</h3>
|
|
||||||
<div id="dmrCurrentCall" style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 10px; font-size: 11px;">
|
|
||||||
<div style="color: var(--text-muted); text-align: center;">No active call</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status -->
|
|
||||||
<div class="section" style="margin-top: 12px;">
|
|
||||||
<h3>Status</h3>
|
|
||||||
<div style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 10px;">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
|
||||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Status</span>
|
|
||||||
<span id="dmrStatus" style="font-size: 11px; color: var(--accent-cyan);">IDLE</span>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
|
||||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Protocol</span>
|
|
||||||
<span id="dmrActiveProtocol" style="font-size: 11px; color: var(--text-primary);">--</span>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
||||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Calls</span>
|
|
||||||
<span id="dmrCallCount" style="font-size: 14px; font-weight: bold; color: var(--accent-green);">0</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mode-actions-bottom">
|
|
||||||
<button class="run-btn" id="startDmrBtn" onclick="startDmr()">
|
|
||||||
Start Decoder
|
|
||||||
</button>
|
|
||||||
<button class="stop-btn" id="stopDmrBtn" onclick="stopDmr()" style="display: none;">
|
|
||||||
Stop Decoder
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<!-- LISTENING POST MODE -->
|
|
||||||
<div id="listeningPostMode" class="mode-content">
|
|
||||||
<div class="section">
|
|
||||||
<h3>Status</h3>
|
|
||||||
|
|
||||||
<!-- Dependency Warning -->
|
|
||||||
<div id="scannerToolsWarning" style="display: none; background: rgba(255, 100, 100, 0.1); border: 1px solid var(--accent-red); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
|
||||||
<p style="color: var(--accent-red); margin: 0; font-size: 0.85em;">
|
|
||||||
<strong>Missing:</strong><br>
|
|
||||||
<span id="scannerToolsWarningText"></span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick Status -->
|
|
||||||
<div style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 12px;">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
|
||||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Status</span>
|
|
||||||
<span id="lpQuickStatus" style="font-size: 11px; color: var(--accent-cyan);">IDLE</span>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
|
||||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Frequency</span>
|
|
||||||
<span id="lpQuickFreq" style="font-size: 14px; font-family: var(--font-mono); color: var(--text-primary);">---.--- MHz</span>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
||||||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Signals</span>
|
|
||||||
<span id="lpQuickSignals" style="font-size: 14px; font-weight: bold; color: var(--accent-green);">0</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3>Bookmarks</h3>
|
|
||||||
<div style="display: flex; gap: 4px; margin-bottom: 8px;">
|
|
||||||
<input type="text" id="bookmarkFreqInput" placeholder="Freq (MHz)" style="flex: 1; padding: 6px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
|
||||||
<button class="preset-btn" onclick="addFrequencyBookmark()" style="background: var(--accent-green); color: #000; padding: 6px 10px;">+</button>
|
|
||||||
</div>
|
|
||||||
<div id="bookmarksList" style="max-height: 150px; overflow-y: auto; font-size: 11px;">
|
|
||||||
<div style="color: var(--text-muted); text-align: center; padding: 10px;">No bookmarks saved</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3>Recent Signals</h3>
|
|
||||||
<div id="sidebarRecentSignals" style="max-height: 150px; overflow-y: auto; font-size: 11px;">
|
|
||||||
<div style="color: var(--text-muted); text-align: center; padding: 10px;">No signals yet</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Signal Identification -->
|
|
||||||
<div class="section">
|
|
||||||
<h3>Signal Identification</h3>
|
|
||||||
<div style="display: flex; gap: 4px; margin-bottom: 8px;">
|
|
||||||
<input type="text" id="signalGuessFreqInput" placeholder="Freq (MHz)" style="flex: 1; padding: 6px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
|
|
||||||
<button class="preset-btn" onclick="manualSignalGuess()" style="background: var(--accent-cyan); color: #000; padding: 6px 10px; font-weight: 600;">ID</button>
|
|
||||||
</div>
|
|
||||||
<div id="signalGuessPanel" style="display: none; background: rgba(0,0,0,0.3); border-radius: 6px; padding: 10px; font-size: 11px;">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
|
||||||
<span id="signalGuessLabel" style="font-weight: bold; color: var(--text-primary);"></span>
|
|
||||||
<span id="signalGuessBadge" style="padding: 2px 8px; border-radius: 3px; font-size: 9px; font-weight: bold;"></span>
|
|
||||||
</div>
|
|
||||||
<div id="signalGuessExplanation" style="color: var(--text-muted); font-size: 10px; margin-bottom: 6px;"></div>
|
|
||||||
<div id="signalGuessTags" style="display: flex; flex-wrap: wrap; gap: 3px;"></div>
|
|
||||||
<div id="signalGuessAlternatives" style="margin-top: 6px; font-size: 10px; color: var(--text-muted);"></div>
|
|
||||||
<div id="signalGuessSendTo" style="margin-top: 8px; display: none;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
<!-- WATERFALL MODE -->
|
||||||
|
<div id="waterfallMode" class="mode-content wf-side">
|
||||||
|
<div class="section wf-side-hero">
|
||||||
|
<div class="wf-side-hero-title-row">
|
||||||
|
<div class="wf-side-hero-title">Spectrum Waterfall</div>
|
||||||
|
<div class="wf-side-chip" id="wfHeroVisualStatus">CONNECTING</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-hero-subtext">
|
||||||
|
Click spectrum/waterfall to tune. Scroll to step-tune. Ctrl/Cmd + scroll to zoom span.
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-hero-stats">
|
||||||
|
<div class="wf-side-stat">
|
||||||
|
<div class="wf-side-stat-label">Tuned</div>
|
||||||
|
<div class="wf-side-stat-value" id="wfHeroFreq">100.0000 MHz</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-stat">
|
||||||
|
<div class="wf-side-stat-label">Mode</div>
|
||||||
|
<div class="wf-side-stat-value" id="wfHeroMode">WFM</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-stat">
|
||||||
|
<div class="wf-side-stat-label">Scan</div>
|
||||||
|
<div class="wf-side-stat-value" id="wfHeroScan">Idle</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-stat">
|
||||||
|
<div class="wf-side-stat-label">Hits</div>
|
||||||
|
<div class="wf-side-stat-value" id="wfHeroHits">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-hero-actions">
|
||||||
|
<button class="run-btn" id="wfStartBtn" onclick="Waterfall.start()">Start Waterfall</button>
|
||||||
|
<button class="stop-btn" id="wfStopBtn" onclick="Waterfall.stop()" style="display:none;">Stop Waterfall</button>
|
||||||
|
</div>
|
||||||
|
<div id="wfStatus" class="wf-side-status-line"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Device</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>SDR Device</label>
|
||||||
|
<select id="wfDevice" onchange="Waterfall && Waterfall.onDeviceChange && Waterfall.onDeviceChange()">
|
||||||
|
<option value="">Loading devices...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="wfDeviceInfo" class="wf-side-box" style="display:none;">
|
||||||
|
<div class="wf-side-kv">
|
||||||
|
<span class="wf-side-kv-label">Type</span>
|
||||||
|
<span id="wfDeviceType" class="wf-side-kv-value">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-kv">
|
||||||
|
<span class="wf-side-kv-label">Range</span>
|
||||||
|
<span id="wfDeviceRange" class="wf-side-kv-value">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-kv">
|
||||||
|
<span class="wf-side-kv-label">Capture SR</span>
|
||||||
|
<span id="wfDeviceBw" class="wf-side-kv-value">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Tuning</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Center Frequency (MHz)</label>
|
||||||
|
<input type="number" id="wfCenterFreq" value="100.0000" step="0.001" min="0.001" max="6000">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Span (MHz)</label>
|
||||||
|
<input type="number" id="wfSpanMhz" value="2.4" step="0.1" min="0.05" max="30">
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-grid-2">
|
||||||
|
<button class="preset-btn" onclick="Waterfall.applyPreset('fm')">FM Broadcast</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.applyPreset('air')">Airband</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.applyPreset('marine')">Marine</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.applyPreset('ham2m')">2m Ham</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Quick Tune & Bookmarks</h3>
|
||||||
|
<div class="wf-side-grid-2">
|
||||||
|
<button class="preset-btn" onclick="Waterfall.quickTune(121.5, 'am')">121.5 Guard</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.quickTune(156.8, 'fm')">156.8 CH16</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.quickTune(145.5, 'fm')">145.5 2m</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.quickTune(98.1, 'wfm')">98.1 FM</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.quickTune(462.5625, 'fm')">462.56 FRS</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.quickTune(446.0, 'fm')">446.0 PMR</button>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-divider"></div>
|
||||||
|
<div class="wf-bookmark-row">
|
||||||
|
<input type="number" id="wfBookmarkFreqInput" step="0.0001" min="0.001" max="6000" placeholder="Frequency MHz">
|
||||||
|
<select id="wfBookmarkMode">
|
||||||
|
<option value="auto" selected>Auto</option>
|
||||||
|
<option value="wfm">WFM</option>
|
||||||
|
<option value="fm">NFM</option>
|
||||||
|
<option value="am">AM</option>
|
||||||
|
<option value="usb">USB</option>
|
||||||
|
<option value="lsb">LSB</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-grid-2">
|
||||||
|
<button class="preset-btn" onclick="Waterfall.useTuneForBookmark()">Use Tuned</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.addBookmarkFromInput()">Save Bookmark</button>
|
||||||
|
</div>
|
||||||
|
<div id="wfBookmarkList" class="wf-bookmark-list">
|
||||||
|
<div class="wf-empty">No bookmarks saved</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-inline-label">Recent Hits</div>
|
||||||
|
<div id="wfRecentSignals" class="wf-recent-list">
|
||||||
|
<div class="wf-empty">No recent signal hits</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Handoff</h3>
|
||||||
|
<div class="wf-side-help">
|
||||||
|
Send current tuned frequency to another decoder/workflow.
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-grid-2">
|
||||||
|
<button class="preset-btn" onclick="Waterfall.handoff('pager')">To Pager</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.handoff('subghz')">To SubGHz</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.handoff('subghz433')">433 Profile</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.handoff('signalid')">Signal ID</button>
|
||||||
|
</div>
|
||||||
|
<div id="wfHandoffStatus" class="wf-side-status-line">Ready</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Signal Identification</h3>
|
||||||
|
<div class="wf-side-help">
|
||||||
|
Identify current frequency using local catalog and SigID Wiki matches.
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Frequency (MHz)</label>
|
||||||
|
<input type="number" id="wfSigIdFreq" value="100.0000" step="0.0001" min="0.001" max="6000">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Mode Hint</label>
|
||||||
|
<select id="wfSigIdMode">
|
||||||
|
<option value="auto" selected>Auto (Current Mode)</option>
|
||||||
|
<option value="wfm">WFM</option>
|
||||||
|
<option value="fm">NFM</option>
|
||||||
|
<option value="am">AM</option>
|
||||||
|
<option value="usb">USB</option>
|
||||||
|
<option value="lsb">LSB</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-grid-2">
|
||||||
|
<button class="preset-btn" onclick="Waterfall.useTuneForSignalId()">Use Tuned</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.identifySignal()">Identify</button>
|
||||||
|
</div>
|
||||||
|
<div id="wfSigIdStatus" class="wf-side-status-line">Ready</div>
|
||||||
|
<div id="wfSigIdResult" class="wf-side-box" style="display:none;"></div>
|
||||||
|
<div id="wfSigIdExternal" class="wf-side-box wf-side-box-muted" style="display:none;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Scan</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Range Start (MHz)</label>
|
||||||
|
<input type="number" id="wfScanStart" value="98.8000" step="0.001" min="0.001" max="6000">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Range End (MHz)</label>
|
||||||
|
<input type="number" id="wfScanEnd" value="101.2000" step="0.001" min="0.001" max="6000">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Step (kHz)</label>
|
||||||
|
<select id="wfScanStepKhz">
|
||||||
|
<option value="5">5</option>
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="12.5">12.5</option>
|
||||||
|
<option value="25">25</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100" selected>100</option>
|
||||||
|
<option value="200">200</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Dwell (ms)</label>
|
||||||
|
<input type="number" id="wfScanDwellMs" value="450" min="60" max="10000" step="10">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Signal Threshold <span id="wfScanThresholdValue" class="wf-inline-value">170</span></label>
|
||||||
|
<input type="range" id="wfScanThreshold" min="0" max="255" value="170">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Hold On Hit (ms)</label>
|
||||||
|
<input type="number" id="wfScanHoldMs" value="2500" min="0" max="30000" step="100">
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-group wf-scan-checkboxes">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="wfScanStopOnSignal" checked>
|
||||||
|
Pause scan when signal is above threshold
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-grid-2 wf-side-grid-gap-top">
|
||||||
|
<button class="preset-btn" onclick="Waterfall.setScanRangeFromView()">Use Current Span</button>
|
||||||
|
<button class="preset-btn" id="wfScanStartBtn" onclick="Waterfall.startScan()">Start Scan</button>
|
||||||
|
<button class="preset-btn" id="wfScanStopBtn" onclick="Waterfall.stopScan()" disabled>Stop Scan</button>
|
||||||
|
</div>
|
||||||
|
<div id="wfScanState" class="wf-side-status-line">Scan idle</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Scan Activity</h3>
|
||||||
|
<div class="wf-scan-metric-grid">
|
||||||
|
<div class="wf-scan-metric-card">
|
||||||
|
<div class="wf-scan-metric-label">Signals</div>
|
||||||
|
<div class="wf-scan-metric-value" id="wfScanSignalsCount">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-scan-metric-card">
|
||||||
|
<div class="wf-scan-metric-label">Scanned</div>
|
||||||
|
<div class="wf-scan-metric-value" id="wfScanStepsCount">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-scan-metric-card">
|
||||||
|
<div class="wf-scan-metric-label">Cycles</div>
|
||||||
|
<div class="wf-scan-metric-value" id="wfScanCyclesCount">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wf-side-grid-2 wf-side-grid-gap-top">
|
||||||
|
<button class="preset-btn" onclick="Waterfall.exportScanLog()">Export Log</button>
|
||||||
|
<button class="preset-btn" onclick="Waterfall.clearScanHistory()">Clear History</button>
|
||||||
|
</div>
|
||||||
|
<div class="wf-hit-table-wrap">
|
||||||
|
<table class="wf-hit-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Frequency</th>
|
||||||
|
<th>Level</th>
|
||||||
|
<th>Mode</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="wfSignalHitsBody">
|
||||||
|
<tr><td colspan="5" class="wf-empty">No signals detected</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="wfSignalHitCount" class="wf-side-inline-label">0 signals found</div>
|
||||||
|
<div id="wfActivityLog" class="wf-activity-log">
|
||||||
|
<div class="wf-empty">Ready</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Capture</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Gain <span class="wf-inline-value">(dB or AUTO)</span></label>
|
||||||
|
<input type="text" id="wfGain" value="AUTO" placeholder="AUTO or numeric">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>FFT Size</label>
|
||||||
|
<select id="wfFftSize">
|
||||||
|
<option value="256">256</option>
|
||||||
|
<option value="512">512</option>
|
||||||
|
<option value="1024" selected>1024</option>
|
||||||
|
<option value="2048">2048</option>
|
||||||
|
<option value="4096">4096</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Frame Rate</label>
|
||||||
|
<select id="wfFps">
|
||||||
|
<option value="10">10 fps</option>
|
||||||
|
<option value="20" selected>20 fps</option>
|
||||||
|
<option value="30">30 fps</option>
|
||||||
|
<option value="40">40 fps</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>FFT Averaging</label>
|
||||||
|
<select id="wfAvgCount">
|
||||||
|
<option value="1">1 (none)</option>
|
||||||
|
<option value="2">2</option>
|
||||||
|
<option value="4" selected>4</option>
|
||||||
|
<option value="8">8</option>
|
||||||
|
<option value="16">16</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>PPM Correction</label>
|
||||||
|
<input type="number" id="wfPpm" value="0" step="1" min="-200" max="200" placeholder="0">
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-group wf-scan-checkboxes">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="wfBiasT">
|
||||||
|
Bias-T (antenna power)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Display</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Color Palette</label>
|
||||||
|
<select id="wfPalette" onchange="Waterfall.setPalette(this.value)">
|
||||||
|
<option value="turbo" selected>Turbo</option>
|
||||||
|
<option value="plasma">Plasma</option>
|
||||||
|
<option value="inferno">Inferno</option>
|
||||||
|
<option value="viridis">Viridis</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Noise Floor (dB)</label>
|
||||||
|
<input type="number" id="wfDbMin" value="-100" step="5" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Ceiling (dB)</label>
|
||||||
|
<input type="number" id="wfDbMax" value="-20" step="5" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-group wf-scan-checkboxes">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="wfPeakHold" onchange="Waterfall.togglePeakHold(this.checked)">
|
||||||
|
Peak Hold
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="wfBandAnnotations" checked onchange="Waterfall.toggleAnnotations(this.checked)">
|
||||||
|
Band Labels
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="wfAutoRange" checked onchange="Waterfall.toggleAutoRange(this.checked)">
|
||||||
|
Auto Range
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -65,8 +65,8 @@
|
|||||||
{{ mode_item('pager', 'Pager', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg>') }}
|
{{ mode_item('pager', 'Pager', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg>') }}
|
||||||
{{ mode_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
{{ mode_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
||||||
{{ mode_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
{{ mode_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
||||||
{{ mode_item('listening', 'Listening Post', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
|
|
||||||
{{ mode_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
|
{{ mode_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
|
||||||
|
{{ mode_item('waterfall', 'Waterfall', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M2 18h20" opacity="0.4"/><path d="M2 21h20" opacity="0.2"/></svg>') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -133,7 +133,6 @@
|
|||||||
|
|
||||||
<div class="mode-nav-dropdown-menu">
|
<div class="mode-nav-dropdown-menu">
|
||||||
{{ mode_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
{{ mode_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
||||||
{{ mode_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
|
|
||||||
{{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
{{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||||
{{ mode_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
{{ mode_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -177,6 +176,15 @@
|
|||||||
<button type="button" class="nav-tool-btn" onclick="showSettings()" title="Settings" aria-label="Open settings">
|
<button type="button" class="nav-tool-btn" onclick="showSettings()" title="Settings" aria-label="Open settings">
|
||||||
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="nav-tool-btn" id="voiceMuteBtn" onclick="window.VoiceAlerts && VoiceAlerts.toggleMute()" title="Toggle voice alerts" aria-label="Toggle voice alerts">
|
||||||
|
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg></span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="nav-tool-btn" onclick="window.CheatSheets && CheatSheets.showForCurrentMode()" title="Mode cheat sheet (Alt+C)" aria-label="Mode cheat sheet">
|
||||||
|
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="nav-tool-btn" onclick="window.KeyboardShortcuts && KeyboardShortcuts.showHelp()" title="Keyboard shortcuts (Alt+K)" aria-label="Keyboard shortcuts">
|
||||||
|
<span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M8 12h.01M12 12h.01M16 12h.01M7 16h10"/></svg></span>
|
||||||
|
</button>
|
||||||
<button type="button" class="nav-tool-btn" onclick="showHelp()" title="Help & Documentation" aria-label="Open help">?</button>
|
<button type="button" class="nav-tool-btn" onclick="showHelp()" title="Help & Documentation" aria-label="Open help">?</button>
|
||||||
<button type="button" class="nav-tool-btn" onclick="logout(event)" title="Logout" aria-label="Logout">
|
<button type="button" class="nav-tool-btn" onclick="logout(event)" title="Logout" aria-label="Logout">
|
||||||
<span class="power-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></span>
|
<span class="power-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></span>
|
||||||
@@ -191,7 +199,6 @@
|
|||||||
{{ mobile_item('pager', 'Pager', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg>') }}
|
{{ mobile_item('pager', 'Pager', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg>') }}
|
||||||
{{ mobile_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
{{ mobile_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
||||||
{{ mobile_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
{{ mobile_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
|
||||||
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
|
|
||||||
{{ mobile_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
|
{{ mobile_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
|
||||||
{# Tracking #}
|
{# Tracking #}
|
||||||
{{ mobile_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }}
|
{{ mobile_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }}
|
||||||
@@ -215,13 +222,70 @@
|
|||||||
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
|
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
|
||||||
{# Intel #}
|
{# Intel #}
|
||||||
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
||||||
{{ mobile_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
|
|
||||||
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||||
{{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
{{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||||
|
{# New modes #}
|
||||||
|
{{ mobile_item('waterfall', 'Waterfall', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h4l3-8 3 16 3-8h4"/></svg>') }}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{# JavaScript stub for pages that don't have switchMode defined #}
|
{# JavaScript stub for pages that don't have switchMode defined #}
|
||||||
<script>
|
<script>
|
||||||
|
(function () {
|
||||||
|
const NAV_PERF_KEY = 'intercept_nav_perf_v1';
|
||||||
|
const MAX_NAV_AGE_MS = 30000;
|
||||||
|
|
||||||
|
function parseNavPerf(raw) {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.InterceptNavPerf) {
|
||||||
|
window.InterceptNavPerf = {
|
||||||
|
markStart(meta = {}) {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
startedAtEpochMs: Date.now(),
|
||||||
|
sourcePath: window.location.pathname + window.location.search,
|
||||||
|
sourceMode: document.body?.getAttribute('data-mode') || null,
|
||||||
|
...meta,
|
||||||
|
};
|
||||||
|
sessionStorage.setItem(NAV_PERF_KEY, JSON.stringify(payload));
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore storage errors in private/incognito mode.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const payload = parseNavPerf(sessionStorage.getItem(NAV_PERF_KEY));
|
||||||
|
if (!payload || !payload.targetPath) return;
|
||||||
|
|
||||||
|
const ageMs = Date.now() - (payload.startedAtEpochMs || 0);
|
||||||
|
if (ageMs < 0 || ageMs > MAX_NAV_AGE_MS) {
|
||||||
|
try { sessionStorage.removeItem(NAV_PERF_KEY); } catch (_) { }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.location.pathname !== payload.targetPath) return;
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
`[Perf] Nav ${payload.sourcePath || '(unknown)'} -> ${payload.targetPath} in ${Math.round(ageMs)}ms`,
|
||||||
|
{
|
||||||
|
trigger: payload.trigger || 'unknown',
|
||||||
|
sourceMode: payload.sourceMode || null,
|
||||||
|
activeScans: payload.activeScans || null,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
try { sessionStorage.removeItem(NAV_PERF_KEY); } catch (_) { }
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
// Ensure navigation functions exist (for dashboard pages that don't have the full JS)
|
// Ensure navigation functions exist (for dashboard pages that don't have the full JS)
|
||||||
if (typeof switchMode === 'undefined') {
|
if (typeof switchMode === 'undefined') {
|
||||||
window.switchMode = function(mode) {
|
window.switchMode = function(mode) {
|
||||||
|
|||||||
@@ -284,6 +284,93 @@
|
|||||||
|
|
||||||
<!-- Alerts Section -->
|
<!-- Alerts Section -->
|
||||||
<div id="settings-alerts" class="settings-section">
|
<div id="settings-alerts" class="settings-section">
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Voice Alerts</div>
|
||||||
|
<p style="color: var(--text-dim); margin-bottom: 10px; font-size: 12px;">
|
||||||
|
Configure which events trigger spoken alerts and adjust voice settings.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Pager Messages</span>
|
||||||
|
<span class="settings-label-desc">Speak decoded pager messages</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="voiceCfgPager" checked onchange="saveVoiceAlertConfig()">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">TSCM Alerts</span>
|
||||||
|
<span class="settings-label-desc">Speak counter-surveillance detections</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="voiceCfgTscm" checked onchange="saveVoiceAlertConfig()">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Tracker Detection</span>
|
||||||
|
<span class="settings-label-desc">Speak when AirTag, Tile, or SmartTag found</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="voiceCfgTracker" checked onchange="saveVoiceAlertConfig()">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Emergency Squawks</span>
|
||||||
|
<span class="settings-label-desc">Speak aircraft emergency transponder codes</span>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="voiceCfgSquawk" checked onchange="saveVoiceAlertConfig()">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Voice</span>
|
||||||
|
<span class="settings-label-desc">Speech synthesis voice</span>
|
||||||
|
</div>
|
||||||
|
<select id="voiceCfgVoice" class="settings-select" style="width: 200px;" onchange="saveVoiceAlertConfig()">
|
||||||
|
<option value="">Default</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Rate</span>
|
||||||
|
<span class="settings-label-desc">Speech speed (0.5 – 2.0)</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; width:200px;">
|
||||||
|
<input type="range" id="voiceCfgRate" min="0.5" max="2.0" step="0.1" value="1.1" style="flex:1;" oninput="document.getElementById('voiceCfgRateVal').textContent=this.value; saveVoiceAlertConfig();">
|
||||||
|
<span id="voiceCfgRateVal" style="font-family:var(--font-mono); font-size:11px; color:var(--text-dim); min-width:28px; text-align:right;">1.1</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Pitch</span>
|
||||||
|
<span class="settings-label-desc">Voice pitch (0.5 – 2.0)</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; width:200px;">
|
||||||
|
<input type="range" id="voiceCfgPitch" min="0.5" max="2.0" step="0.1" value="0.9" style="flex:1;" oninput="document.getElementById('voiceCfgPitchVal').textContent=this.value; saveVoiceAlertConfig();">
|
||||||
|
<span id="voiceCfgPitchVal" style="font-family:var(--font-mono); font-size:11px; color:var(--text-dim); min-width:28px; text-align:right;">0.9</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 8px;">
|
||||||
|
<button class="check-assets-btn" onclick="testVoiceAlert()">Test Voice</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<div class="settings-group-title">Alert Feed <span id="alertsFeedCount" style="color: var(--text-dim); font-weight: 500;"></span></div>
|
<div class="settings-group-title">Alert Feed <span id="alertsFeedCount" style="color: var(--text-dim); font-weight: 500;"></span></div>
|
||||||
<div id="alertsFeedList" class="settings-feed">
|
<div id="alertsFeedList" class="settings-feed">
|
||||||
@@ -316,7 +403,6 @@
|
|||||||
<option value="acars">ACARS</option>
|
<option value="acars">ACARS</option>
|
||||||
<option value="vdl2">VDL2</option>
|
<option value="vdl2">VDL2</option>
|
||||||
<option value="aprs">APRS</option>
|
<option value="aprs">APRS</option>
|
||||||
<option value="dsc">DSC</option>
|
|
||||||
<option value="meshtastic">Meshtastic</option>
|
<option value="meshtastic">Meshtastic</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -392,14 +478,12 @@
|
|||||||
<option value="bluetooth">Bluetooth</option>
|
<option value="bluetooth">Bluetooth</option>
|
||||||
<option value="adsb">ADS-B</option>
|
<option value="adsb">ADS-B</option>
|
||||||
<option value="ais">AIS</option>
|
<option value="ais">AIS</option>
|
||||||
<option value="dsc">DSC</option>
|
|
||||||
<option value="acars">ACARS</option>
|
<option value="acars">ACARS</option>
|
||||||
<option value="aprs">APRS</option>
|
<option value="aprs">APRS</option>
|
||||||
<option value="rtlamr">RTLAMR</option>
|
<option value="rtlamr">RTLAMR</option>
|
||||||
<option value="tscm">TSCM</option>
|
<option value="tscm">TSCM</option>
|
||||||
<option value="sstv">SSTV</option>
|
<option value="sstv">SSTV</option>
|
||||||
<option value="sstv_general">SSTV General</option>
|
<option value="sstv_general">SSTV General</option>
|
||||||
<option value="listening_scanner">Listening Post</option>
|
|
||||||
<option value="waterfall">Waterfall</option>
|
<option value="waterfall">Waterfall</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -74,8 +74,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{% if not embedded %}
|
||||||
{% set active_mode = 'satellite' %}
|
{% set active_mode = 'satellite' %}
|
||||||
{% include 'partials/nav.html' with context %}
|
{% include 'partials/nav.html' with context %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<main class="dashboard">
|
<main class="dashboard">
|
||||||
<!-- Polar Plot -->
|
<!-- Polar Plot -->
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
"""Tests for analytics endpoints, export, and squawk detection."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='session')
|
|
||||||
def app():
|
|
||||||
"""Create application for testing."""
|
|
||||||
import app as app_module
|
|
||||||
import utils.database as db_mod
|
|
||||||
from routes import register_blueprints
|
|
||||||
|
|
||||||
app_module.app.config['TESTING'] = True
|
|
||||||
|
|
||||||
# Use temp directory for test database
|
|
||||||
tmp_dir = Path(tempfile.mkdtemp())
|
|
||||||
db_mod.DB_DIR = tmp_dir
|
|
||||||
db_mod.DB_PATH = tmp_dir / 'test_intercept.db'
|
|
||||||
# Reset thread-local connection so it picks up new path
|
|
||||||
if hasattr(db_mod._local, 'connection') and db_mod._local.connection:
|
|
||||||
db_mod._local.connection.close()
|
|
||||||
db_mod._local.connection = None
|
|
||||||
|
|
||||||
db_mod.init_db()
|
|
||||||
|
|
||||||
if 'pager' not in app_module.app.blueprints:
|
|
||||||
register_blueprints(app_module.app)
|
|
||||||
|
|
||||||
return app_module.app
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client(app):
|
|
||||||
client = app.test_client()
|
|
||||||
# Set session login to bypass require_login before_request hook
|
|
||||||
with client.session_transaction() as sess:
|
|
||||||
sess['logged_in'] = True
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
class TestAnalyticsSummary:
|
|
||||||
"""Tests for /analytics/summary endpoint."""
|
|
||||||
|
|
||||||
def test_summary_returns_json(self, client):
|
|
||||||
response = client.get('/analytics/summary')
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['status'] == 'success'
|
|
||||||
assert 'counts' in data
|
|
||||||
assert 'health' in data
|
|
||||||
assert 'squawks' in data
|
|
||||||
|
|
||||||
def test_summary_counts_structure(self, client):
|
|
||||||
response = client.get('/analytics/summary')
|
|
||||||
data = json.loads(response.data)
|
|
||||||
counts = data['counts']
|
|
||||||
assert 'adsb' in counts
|
|
||||||
assert 'ais' in counts
|
|
||||||
assert 'wifi' in counts
|
|
||||||
assert 'bluetooth' in counts
|
|
||||||
assert 'dsc' in counts
|
|
||||||
# All should be integers
|
|
||||||
for val in counts.values():
|
|
||||||
assert isinstance(val, int)
|
|
||||||
|
|
||||||
def test_summary_health_structure(self, client):
|
|
||||||
response = client.get('/analytics/summary')
|
|
||||||
data = json.loads(response.data)
|
|
||||||
health = data['health']
|
|
||||||
# Should have process statuses
|
|
||||||
assert 'pager' in health
|
|
||||||
assert 'sensor' in health
|
|
||||||
assert 'adsb' in health
|
|
||||||
# Each should have a running flag
|
|
||||||
for mode_info in health.values():
|
|
||||||
if isinstance(mode_info, dict) and 'running' in mode_info:
|
|
||||||
assert isinstance(mode_info['running'], bool)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAnalyticsExport:
|
|
||||||
"""Tests for /analytics/export/<mode> endpoint."""
|
|
||||||
|
|
||||||
def test_export_adsb_json(self, client):
|
|
||||||
response = client.get('/analytics/export/adsb?format=json')
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['status'] == 'success'
|
|
||||||
assert data['mode'] == 'adsb'
|
|
||||||
assert 'data' in data
|
|
||||||
assert isinstance(data['data'], list)
|
|
||||||
|
|
||||||
def test_export_adsb_csv(self, client):
|
|
||||||
response = client.get('/analytics/export/adsb?format=csv')
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.content_type.startswith('text/csv')
|
|
||||||
assert 'Content-Disposition' in response.headers
|
|
||||||
|
|
||||||
def test_export_invalid_mode(self, client):
|
|
||||||
response = client.get('/analytics/export/invalid_mode')
|
|
||||||
assert response.status_code == 400
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['status'] == 'error'
|
|
||||||
|
|
||||||
def test_export_wifi_json(self, client):
|
|
||||||
response = client.get('/analytics/export/wifi?format=json')
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['status'] == 'success'
|
|
||||||
assert data['mode'] == 'wifi'
|
|
||||||
|
|
||||||
|
|
||||||
class TestAnalyticsSquawks:
|
|
||||||
"""Tests for squawk detection."""
|
|
||||||
|
|
||||||
def test_squawks_endpoint(self, client):
|
|
||||||
response = client.get('/analytics/squawks')
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['status'] == 'success'
|
|
||||||
assert isinstance(data['squawks'], list)
|
|
||||||
|
|
||||||
def test_get_emergency_squawks_detects_7700(self):
|
|
||||||
from utils.analytics import get_emergency_squawks
|
|
||||||
|
|
||||||
# Mock the adsb_aircraft DataStore
|
|
||||||
mock_store = MagicMock()
|
|
||||||
mock_store.items.return_value = [
|
|
||||||
('ABC123', {'squawk': '7700', 'callsign': 'TEST01', 'altitude': 35000}),
|
|
||||||
('DEF456', {'squawk': '1200', 'callsign': 'TEST02'}),
|
|
||||||
]
|
|
||||||
|
|
||||||
with patch('utils.analytics.app_module') as mock_app:
|
|
||||||
mock_app.adsb_aircraft = mock_store
|
|
||||||
squawks = get_emergency_squawks()
|
|
||||||
|
|
||||||
assert len(squawks) == 1
|
|
||||||
assert squawks[0]['squawk'] == '7700'
|
|
||||||
assert squawks[0]['meaning'] == 'General Emergency'
|
|
||||||
assert squawks[0]['icao'] == 'ABC123'
|
|
||||||
|
|
||||||
|
|
||||||
class TestGeofenceCRUD:
|
|
||||||
"""Tests for geofence CRUD endpoints."""
|
|
||||||
|
|
||||||
def test_list_geofences(self, client):
|
|
||||||
response = client.get('/analytics/geofences')
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['status'] == 'success'
|
|
||||||
assert isinstance(data['zones'], list)
|
|
||||||
|
|
||||||
def test_create_geofence(self, client):
|
|
||||||
response = client.post('/analytics/geofences',
|
|
||||||
data=json.dumps({
|
|
||||||
'name': 'Test Zone',
|
|
||||||
'lat': 51.5074,
|
|
||||||
'lon': -0.1278,
|
|
||||||
'radius_m': 500,
|
|
||||||
}),
|
|
||||||
content_type='application/json')
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['status'] == 'success'
|
|
||||||
assert 'zone_id' in data
|
|
||||||
|
|
||||||
def test_create_geofence_missing_fields(self, client):
|
|
||||||
response = client.post('/analytics/geofences',
|
|
||||||
data=json.dumps({'name': 'No coords'}),
|
|
||||||
content_type='application/json')
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
def test_create_geofence_invalid_coords(self, client):
|
|
||||||
response = client.post('/analytics/geofences',
|
|
||||||
data=json.dumps({
|
|
||||||
'name': 'Bad',
|
|
||||||
'lat': 100,
|
|
||||||
'lon': 0,
|
|
||||||
'radius_m': 100,
|
|
||||||
}),
|
|
||||||
content_type='application/json')
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
def test_delete_geofence_not_found(self, client):
|
|
||||||
response = client.delete('/analytics/geofences/99999')
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
class TestAnalyticsActivity:
|
|
||||||
"""Tests for /analytics/activity endpoint."""
|
|
||||||
|
|
||||||
def test_activity_returns_sparklines(self, client):
|
|
||||||
response = client.get('/analytics/activity')
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['status'] == 'success'
|
|
||||||
assert 'sparklines' in data
|
|
||||||
assert isinstance(data['sparklines'], dict)
|
|
||||||
@@ -136,6 +136,14 @@ class TestLocateTarget:
|
|||||||
device.name = None
|
device.name = None
|
||||||
assert target.matches(device) is True
|
assert target.matches(device) is True
|
||||||
|
|
||||||
|
def test_match_by_mac_without_separators(self):
|
||||||
|
target = LocateTarget(mac_address='aabbccddeeff')
|
||||||
|
device = MagicMock()
|
||||||
|
device.device_id = 'other'
|
||||||
|
device.address = 'AA:BB:CC:DD:EE:FF'
|
||||||
|
device.name = None
|
||||||
|
assert target.matches(device) is True
|
||||||
|
|
||||||
def test_match_by_name_pattern(self):
|
def test_match_by_name_pattern(self):
|
||||||
target = LocateTarget(name_pattern='iPhone')
|
target = LocateTarget(name_pattern='iPhone')
|
||||||
device = MagicMock()
|
device = MagicMock()
|
||||||
@@ -276,3 +284,16 @@ class TestModuleLevelSessionManagement:
|
|||||||
assert session2.active is True
|
assert session2.active is True
|
||||||
|
|
||||||
stop_locate_session()
|
stop_locate_session()
|
||||||
|
|
||||||
|
@patch('utils.bt_locate.get_bluetooth_scanner')
|
||||||
|
def test_start_raises_when_scanner_cannot_start(self, mock_get_scanner):
|
||||||
|
mock_scanner = MagicMock()
|
||||||
|
mock_scanner.is_scanning = False
|
||||||
|
mock_scanner.start_scan.return_value = False
|
||||||
|
status = MagicMock()
|
||||||
|
status.error = 'No adapter'
|
||||||
|
mock_scanner.get_status.return_value = status
|
||||||
|
mock_get_scanner.return_value = mock_scanner
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
start_locate_session(LocateTarget(mac_address='AA:BB:CC:DD:EE:FF'))
|
||||||
|
|||||||
@@ -1,311 +0,0 @@
|
|||||||
"""Tests for the DMR / Digital Voice decoding module."""
|
|
||||||
|
|
||||||
import queue
|
|
||||||
from unittest.mock import patch, MagicMock
|
|
||||||
import pytest
|
|
||||||
import routes.dmr as dmr_module
|
|
||||||
from routes.dmr import parse_dsd_output, _DSD_PROTOCOL_FLAGS, _DSD_FME_PROTOCOL_FLAGS, _DSD_FME_MODULATION
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# parse_dsd_output() tests
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
def test_parse_sync_dmr():
|
|
||||||
"""Should parse DMR sync line."""
|
|
||||||
result = parse_dsd_output('Sync: +DMR (data)')
|
|
||||||
assert result is not None
|
|
||||||
assert result['type'] == 'sync'
|
|
||||||
assert 'DMR' in result['protocol']
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_sync_p25():
|
|
||||||
"""Should parse P25 sync line."""
|
|
||||||
result = parse_dsd_output('Sync: +P25 Phase 1')
|
|
||||||
assert result is not None
|
|
||||||
assert result['type'] == 'sync'
|
|
||||||
assert 'P25' in result['protocol']
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_talkgroup_and_source():
|
|
||||||
"""Should parse talkgroup and source ID."""
|
|
||||||
result = parse_dsd_output('TG: 12345 Src: 67890')
|
|
||||||
assert result is not None
|
|
||||||
assert result['type'] == 'call'
|
|
||||||
assert result['talkgroup'] == 12345
|
|
||||||
assert result['source_id'] == 67890
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_slot():
|
|
||||||
"""Should parse slot info."""
|
|
||||||
result = parse_dsd_output('Slot 1')
|
|
||||||
assert result is not None
|
|
||||||
assert result['type'] == 'slot'
|
|
||||||
assert result['slot'] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_voice():
|
|
||||||
"""Should parse voice frame info."""
|
|
||||||
result = parse_dsd_output('Voice Frame 1')
|
|
||||||
assert result is not None
|
|
||||||
assert result['type'] == 'voice'
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_nac():
|
|
||||||
"""Should parse P25 NAC."""
|
|
||||||
result = parse_dsd_output('NAC: 293')
|
|
||||||
assert result is not None
|
|
||||||
assert result['type'] == 'nac'
|
|
||||||
assert result['nac'] == '293'
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_talkgroup_dsd_fme_format():
|
|
||||||
"""Should parse dsd-fme comma-separated TG/Src format."""
|
|
||||||
result = parse_dsd_output('TG: 12345, Src: 67890')
|
|
||||||
assert result is not None
|
|
||||||
assert result['type'] == 'call'
|
|
||||||
assert result['talkgroup'] == 12345
|
|
||||||
assert result['source_id'] == 67890
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_talkgroup_dsd_fme_tgt_src_format():
|
|
||||||
"""Should parse dsd-fme TGT/SRC pipe-delimited format."""
|
|
||||||
result = parse_dsd_output('Slot 1 | TGT: 12345 | SRC: 67890')
|
|
||||||
assert result is not None
|
|
||||||
assert result['type'] == 'call'
|
|
||||||
assert result['talkgroup'] == 12345
|
|
||||||
assert result['source_id'] == 67890
|
|
||||||
assert result['slot'] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_talkgroup_with_slot():
|
|
||||||
"""TG line with slot info should capture both."""
|
|
||||||
result = parse_dsd_output('Slot 1 Voice LC, TG: 100, Src: 200')
|
|
||||||
assert result is not None
|
|
||||||
assert result['type'] == 'call'
|
|
||||||
assert result['talkgroup'] == 100
|
|
||||||
assert result['source_id'] == 200
|
|
||||||
assert result['slot'] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_voice_with_slot():
|
|
||||||
"""Voice frame with slot info should be voice, not slot."""
|
|
||||||
result = parse_dsd_output('Slot 2 Voice Frame')
|
|
||||||
assert result is not None
|
|
||||||
assert result['type'] == 'voice'
|
|
||||||
assert result['slot'] == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_empty_line():
|
|
||||||
"""Empty lines should return None."""
|
|
||||||
assert parse_dsd_output('') is None
|
|
||||||
assert parse_dsd_output(' ') is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_unrecognized():
|
|
||||||
"""Unrecognized lines should return raw event for diagnostics."""
|
|
||||||
result = parse_dsd_output('some random text')
|
|
||||||
assert result is not None
|
|
||||||
assert result['type'] == 'raw'
|
|
||||||
assert result['text'] == 'some random text'
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_banner_filtered():
|
|
||||||
"""Pure box-drawing lines (banners) should be filtered."""
|
|
||||||
assert parse_dsd_output('╔══════════════╗') is None
|
|
||||||
assert parse_dsd_output('║ ║') is None
|
|
||||||
assert parse_dsd_output('╚══════════════╝') is None
|
|
||||||
assert parse_dsd_output('───────────────') is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_box_drawing_with_data_not_filtered():
|
|
||||||
"""Lines with box-drawing separators AND data should NOT be filtered."""
|
|
||||||
result = parse_dsd_output('DMR BS │ Slot 1 │ TG: 12345 │ SRC: 67890')
|
|
||||||
assert result is not None
|
|
||||||
assert result['type'] == 'call'
|
|
||||||
assert result['talkgroup'] == 12345
|
|
||||||
assert result['source_id'] == 67890
|
|
||||||
|
|
||||||
|
|
||||||
def test_dsd_fme_flags_differ_from_classic():
|
|
||||||
"""dsd-fme remapped several flags; tables must NOT be identical."""
|
|
||||||
assert _DSD_FME_PROTOCOL_FLAGS != _DSD_PROTOCOL_FLAGS
|
|
||||||
|
|
||||||
|
|
||||||
def test_dsd_fme_protocol_flags_known_values():
|
|
||||||
"""dsd-fme flags use its own flag names (NOT classic DSD mappings)."""
|
|
||||||
assert _DSD_FME_PROTOCOL_FLAGS['auto'] == ['-fa'] # Broad auto
|
|
||||||
assert _DSD_FME_PROTOCOL_FLAGS['dmr'] == ['-fs'] # Simplex (-fd is D-STAR!)
|
|
||||||
assert _DSD_FME_PROTOCOL_FLAGS['p25'] == ['-ft'] # P25 P1/P2 coverage
|
|
||||||
assert _DSD_FME_PROTOCOL_FLAGS['nxdn'] == ['-fn']
|
|
||||||
assert _DSD_FME_PROTOCOL_FLAGS['dstar'] == ['-fd'] # -fd is D-STAR in dsd-fme
|
|
||||||
assert _DSD_FME_PROTOCOL_FLAGS['provoice'] == ['-fp'] # NOT -fv
|
|
||||||
|
|
||||||
|
|
||||||
def test_dsd_protocol_flags_known_values():
|
|
||||||
"""Classic DSD protocol flags should map to the correct -f flags."""
|
|
||||||
assert _DSD_PROTOCOL_FLAGS['dmr'] == ['-fd']
|
|
||||||
assert _DSD_PROTOCOL_FLAGS['p25'] == ['-fp']
|
|
||||||
assert _DSD_PROTOCOL_FLAGS['nxdn'] == ['-fn']
|
|
||||||
assert _DSD_PROTOCOL_FLAGS['dstar'] == ['-fi']
|
|
||||||
assert _DSD_PROTOCOL_FLAGS['provoice'] == ['-fv']
|
|
||||||
assert _DSD_PROTOCOL_FLAGS['auto'] == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_dsd_fme_modulation_hints():
|
|
||||||
"""C4FM modulation hints should be set for C4FM protocols."""
|
|
||||||
assert _DSD_FME_MODULATION['dmr'] == ['-mc']
|
|
||||||
assert _DSD_FME_MODULATION['nxdn'] == ['-mc']
|
|
||||||
# P25, D-Star and ProVoice should not have forced modulation
|
|
||||||
assert 'p25' not in _DSD_FME_MODULATION
|
|
||||||
assert 'dstar' not in _DSD_FME_MODULATION
|
|
||||||
assert 'provoice' not in _DSD_FME_MODULATION
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Endpoint tests
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def auth_client(client):
|
|
||||||
"""Client with logged-in session."""
|
|
||||||
with client.session_transaction() as sess:
|
|
||||||
sess['logged_in'] = True
|
|
||||||
return client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def reset_dmr_globals():
|
|
||||||
"""Reset DMR globals before/after each test to avoid cross-test bleed."""
|
|
||||||
dmr_module.dmr_rtl_process = None
|
|
||||||
dmr_module.dmr_dsd_process = None
|
|
||||||
dmr_module.dmr_thread = None
|
|
||||||
dmr_module.dmr_running = False
|
|
||||||
dmr_module.dmr_has_audio = False
|
|
||||||
dmr_module.dmr_active_device = None
|
|
||||||
with dmr_module._ffmpeg_sinks_lock:
|
|
||||||
dmr_module._ffmpeg_sinks.clear()
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
dmr_module.dmr_queue.get_nowait()
|
|
||||||
except queue.Empty:
|
|
||||||
pass
|
|
||||||
|
|
||||||
yield
|
|
||||||
|
|
||||||
dmr_module.dmr_rtl_process = None
|
|
||||||
dmr_module.dmr_dsd_process = None
|
|
||||||
dmr_module.dmr_thread = None
|
|
||||||
dmr_module.dmr_running = False
|
|
||||||
dmr_module.dmr_has_audio = False
|
|
||||||
dmr_module.dmr_active_device = None
|
|
||||||
with dmr_module._ffmpeg_sinks_lock:
|
|
||||||
dmr_module._ffmpeg_sinks.clear()
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
dmr_module.dmr_queue.get_nowait()
|
|
||||||
except queue.Empty:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def test_dmr_tools(auth_client):
|
|
||||||
"""Tools endpoint should return availability info."""
|
|
||||||
resp = auth_client.get('/dmr/tools')
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.get_json()
|
|
||||||
assert 'dsd' in data
|
|
||||||
assert 'rtl_fm' in data
|
|
||||||
assert 'protocols' in data
|
|
||||||
|
|
||||||
|
|
||||||
def test_dmr_status(auth_client):
|
|
||||||
"""Status endpoint should work."""
|
|
||||||
resp = auth_client.get('/dmr/status')
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.get_json()
|
|
||||||
assert 'running' in data
|
|
||||||
|
|
||||||
|
|
||||||
def test_dmr_start_no_dsd(auth_client):
|
|
||||||
"""Start should fail gracefully when dsd is not installed."""
|
|
||||||
with patch('routes.dmr.find_dsd', return_value=(None, False)):
|
|
||||||
resp = auth_client.post('/dmr/start', json={
|
|
||||||
'frequency': 462.5625,
|
|
||||||
'protocol': 'auto',
|
|
||||||
})
|
|
||||||
assert resp.status_code == 503
|
|
||||||
data = resp.get_json()
|
|
||||||
assert 'dsd' in data['message']
|
|
||||||
|
|
||||||
|
|
||||||
def test_dmr_start_no_rtl_fm(auth_client):
|
|
||||||
"""Start should fail when rtl_fm is missing."""
|
|
||||||
with patch('routes.dmr.find_dsd', return_value=('/usr/bin/dsd', False)), \
|
|
||||||
patch('routes.dmr.find_rtl_fm', return_value=None):
|
|
||||||
resp = auth_client.post('/dmr/start', json={
|
|
||||||
'frequency': 462.5625,
|
|
||||||
})
|
|
||||||
assert resp.status_code == 503
|
|
||||||
|
|
||||||
|
|
||||||
def test_dmr_start_invalid_protocol(auth_client):
|
|
||||||
"""Start should reject invalid protocol."""
|
|
||||||
with patch('routes.dmr.find_dsd', return_value=('/usr/bin/dsd', False)), \
|
|
||||||
patch('routes.dmr.find_rtl_fm', return_value='/usr/bin/rtl_fm'):
|
|
||||||
resp = auth_client.post('/dmr/start', json={
|
|
||||||
'frequency': 462.5625,
|
|
||||||
'protocol': 'invalid',
|
|
||||||
})
|
|
||||||
assert resp.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
def test_dmr_stop(auth_client):
|
|
||||||
"""Stop should succeed."""
|
|
||||||
resp = auth_client.post('/dmr/stop')
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.get_json()
|
|
||||||
assert data['status'] == 'stopped'
|
|
||||||
|
|
||||||
|
|
||||||
def test_dmr_stream_mimetype(auth_client):
|
|
||||||
"""Stream should return event-stream content type."""
|
|
||||||
resp = auth_client.get('/dmr/stream')
|
|
||||||
assert resp.content_type.startswith('text/event-stream')
|
|
||||||
|
|
||||||
|
|
||||||
def test_dmr_start_exception_cleans_up_resources(auth_client):
|
|
||||||
"""If startup fails after rtl_fm launch, process/device state should be reset."""
|
|
||||||
rtl_proc = MagicMock()
|
|
||||||
rtl_proc.poll.return_value = None
|
|
||||||
rtl_proc.wait.return_value = 0
|
|
||||||
rtl_proc.stdout = MagicMock()
|
|
||||||
rtl_proc.stderr = MagicMock()
|
|
||||||
|
|
||||||
builder = MagicMock()
|
|
||||||
builder.build_fm_demod_command.return_value = ['rtl_fm', '-f', '462.5625M']
|
|
||||||
|
|
||||||
with patch('routes.dmr.find_dsd', return_value=('/usr/bin/dsd', False)), \
|
|
||||||
patch('routes.dmr.find_rtl_fm', return_value='/usr/bin/rtl_fm'), \
|
|
||||||
patch('routes.dmr.find_ffmpeg', return_value=None), \
|
|
||||||
patch('routes.dmr.SDRFactory.create_default_device', return_value=MagicMock()), \
|
|
||||||
patch('routes.dmr.SDRFactory.get_builder', return_value=builder), \
|
|
||||||
patch('routes.dmr.app_module.claim_sdr_device', return_value=None), \
|
|
||||||
patch('routes.dmr.app_module.release_sdr_device') as release_mock, \
|
|
||||||
patch('routes.dmr.register_process') as register_mock, \
|
|
||||||
patch('routes.dmr.unregister_process') as unregister_mock, \
|
|
||||||
patch('routes.dmr.subprocess.Popen', side_effect=[rtl_proc, RuntimeError('dsd launch failed')]):
|
|
||||||
resp = auth_client.post('/dmr/start', json={
|
|
||||||
'frequency': 462.5625,
|
|
||||||
'protocol': 'auto',
|
|
||||||
'device': 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
assert resp.status_code == 500
|
|
||||||
assert 'dsd launch failed' in resp.get_json()['message']
|
|
||||||
register_mock.assert_called_once_with(rtl_proc)
|
|
||||||
rtl_proc.terminate.assert_called_once()
|
|
||||||
unregister_mock.assert_called_once_with(rtl_proc)
|
|
||||||
release_mock.assert_called_once_with(0)
|
|
||||||
assert dmr_module.dmr_running is False
|
|
||||||
assert dmr_module.dmr_rtl_process is None
|
|
||||||
assert dmr_module.dmr_dsd_process is None
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Tests for pager scope waveform payload generation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import queue
|
||||||
|
import struct
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from routes.pager import _encode_scope_waveform, audio_relay_thread
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_scope_waveform_respects_window_and_range():
|
||||||
|
samples = (-32768, -16384, 0, 16384, 32767)
|
||||||
|
waveform = _encode_scope_waveform(samples, window_size=4)
|
||||||
|
|
||||||
|
assert len(waveform) == 4
|
||||||
|
assert waveform[0] == -64
|
||||||
|
assert waveform[1] == 0
|
||||||
|
assert waveform[2] == 64
|
||||||
|
assert waveform[3] == 127
|
||||||
|
assert max(waveform) <= 127
|
||||||
|
assert min(waveform) >= -127
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_relay_thread_emits_scope_waveform(monkeypatch):
|
||||||
|
base_samples = (0, 32767, -32768, 16384) * 512
|
||||||
|
pcm = struct.pack(f"<{len(base_samples)}h", *base_samples)
|
||||||
|
|
||||||
|
rtl_stdout = io.BytesIO(pcm)
|
||||||
|
multimon_stdin = io.BytesIO()
|
||||||
|
output_queue: queue.Queue = queue.Queue()
|
||||||
|
stop_event = threading.Event()
|
||||||
|
|
||||||
|
ticks = iter([0.0, 0.2, 0.2, 0.2])
|
||||||
|
monkeypatch.setattr("routes.pager.time.monotonic", lambda: next(ticks, 0.2))
|
||||||
|
|
||||||
|
audio_relay_thread(rtl_stdout, multimon_stdin, output_queue, stop_event)
|
||||||
|
|
||||||
|
scope_event = output_queue.get_nowait()
|
||||||
|
assert scope_event["type"] == "scope"
|
||||||
|
assert scope_event["rms"] > 0
|
||||||
|
assert scope_event["peak"] > 0
|
||||||
|
assert "waveform" in scope_event
|
||||||
|
assert len(scope_event["waveform"]) > 0
|
||||||
|
assert max(scope_event["waveform"]) <= 127
|
||||||
|
assert min(scope_event["waveform"]) >= -127
|
||||||
@@ -58,17 +58,6 @@ class TestHealthEndpoint:
|
|||||||
assert 'wifi' in processes
|
assert 'wifi' in processes
|
||||||
assert 'bluetooth' in processes
|
assert 'bluetooth' in processes
|
||||||
|
|
||||||
def test_health_reports_dmr_route_process(self, client):
|
|
||||||
"""Health should reflect DMR route module state (not stale app globals)."""
|
|
||||||
mock_proc = MagicMock()
|
|
||||||
mock_proc.poll.return_value = None
|
|
||||||
with patch('routes.dmr.dmr_running', True), \
|
|
||||||
patch('routes.dmr.dmr_dsd_process', mock_proc):
|
|
||||||
response = client.get('/health')
|
|
||||||
data = json.loads(response.data)
|
|
||||||
assert data['processes']['dmr'] is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestDevicesEndpoint:
|
class TestDevicesEndpoint:
|
||||||
"""Tests for devices endpoint."""
|
"""Tests for devices endpoint."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Tests for RTL-SDR detection parsing."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from utils.sdr.base import SDRType
|
||||||
|
from utils.sdr.detection import detect_rtlsdr_devices
|
||||||
|
|
||||||
|
|
||||||
|
@patch('utils.sdr.detection._check_tool', return_value=True)
|
||||||
|
@patch('utils.sdr.detection.subprocess.run')
|
||||||
|
def test_detect_rtlsdr_devices_filters_empty_serial_entries(mock_run, _mock_check_tool):
|
||||||
|
"""Ignore malformed rtl_test rows that have an empty SN field."""
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.stdout = ""
|
||||||
|
mock_result.stderr = (
|
||||||
|
"Found 3 device(s):\n"
|
||||||
|
" 0: ??C?, , SN:\n"
|
||||||
|
" 1: ??C?, , SN:\n"
|
||||||
|
" 2: RTLSDRBlog, Blog V4, SN: 1\n"
|
||||||
|
)
|
||||||
|
mock_run.return_value = mock_result
|
||||||
|
|
||||||
|
devices = detect_rtlsdr_devices()
|
||||||
|
|
||||||
|
assert len(devices) == 1
|
||||||
|
assert devices[0].sdr_type == SDRType.RTL_SDR
|
||||||
|
assert devices[0].index == 2
|
||||||
|
assert devices[0].name == "RTLSDRBlog, Blog V4"
|
||||||
|
assert devices[0].serial == "1"
|
||||||
|
|
||||||
|
|
||||||
|
@patch('utils.sdr.detection._check_tool', return_value=True)
|
||||||
|
@patch('utils.sdr.detection.subprocess.run')
|
||||||
|
def test_detect_rtlsdr_devices_uses_replace_decode_mode(mock_run, _mock_check_tool):
|
||||||
|
"""Run rtl_test with tolerant decoding for malformed output bytes."""
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.stdout = ""
|
||||||
|
mock_result.stderr = "Found 0 device(s):"
|
||||||
|
mock_run.return_value = mock_result
|
||||||
|
|
||||||
|
detect_rtlsdr_devices()
|
||||||
|
|
||||||
|
_, kwargs = mock_run.call_args
|
||||||
|
assert kwargs["text"] is True
|
||||||
|
assert kwargs["encoding"] == "utf-8"
|
||||||
|
assert kwargs["errors"] == "replace"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Tests for synthesized 433 MHz scope waveform payload."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from routes.sensor import _build_scope_waveform
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_scope_waveform_has_expected_shape_and_bounds():
|
||||||
|
waveform = _build_scope_waveform(rssi=-8.5, snr=11.2, noise=-26.0, points=96)
|
||||||
|
|
||||||
|
assert len(waveform) == 96
|
||||||
|
assert max(waveform) <= 127
|
||||||
|
assert min(waveform) >= -127
|
||||||
|
assert any(sample != 0 for sample in waveform)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_scope_waveform_changes_with_signal_profile():
|
||||||
|
low_snr = _build_scope_waveform(rssi=-14.0, snr=2.0, noise=-12.0, points=64)
|
||||||
|
high_snr = _build_scope_waveform(rssi=-14.0, snr=20.0, noise=-12.0, points=64)
|
||||||
|
|
||||||
|
assert low_snr != high_snr
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Tests for the SigID Wiki lookup API endpoint."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import routes.signalid as signalid_module
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_client(client):
|
||||||
|
"""Client with logged-in session."""
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess['logged_in'] = True
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def test_sigidwiki_lookup_missing_frequency(auth_client):
|
||||||
|
"""frequency_mhz is required."""
|
||||||
|
resp = auth_client.post('/signalid/sigidwiki', json={})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
|
||||||
|
|
||||||
|
def test_sigidwiki_lookup_invalid_frequency(auth_client):
|
||||||
|
"""frequency_mhz must be numeric and positive."""
|
||||||
|
resp = auth_client.post('/signalid/sigidwiki', json={'frequency_mhz': 'abc'})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
resp = auth_client.post('/signalid/sigidwiki', json={'frequency_mhz': -1})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_sigidwiki_lookup_success(auth_client):
|
||||||
|
"""Endpoint returns normalized SigID lookup structure."""
|
||||||
|
signalid_module._cache.clear()
|
||||||
|
fake_lookup = {
|
||||||
|
'matches': [
|
||||||
|
{
|
||||||
|
'title': 'POCSAG',
|
||||||
|
'url': 'https://www.sigidwiki.com/wiki/POCSAG',
|
||||||
|
'frequencies_mhz': [929.6625],
|
||||||
|
'modes': ['NFM'],
|
||||||
|
'modulations': ['FSK'],
|
||||||
|
'distance_hz': 0,
|
||||||
|
'source': 'SigID Wiki',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'search_used': False,
|
||||||
|
'exact_queries': ['[[Category:Signal]][[Frequencies::929.6625 MHz]]|?Frequencies|?Mode|?Modulation|limit=10'],
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch('routes.signalid._lookup_sigidwiki_matches', return_value=fake_lookup) as lookup_mock:
|
||||||
|
resp = auth_client.post('/signalid/sigidwiki', json={
|
||||||
|
'frequency_mhz': 929.6625,
|
||||||
|
'modulation': 'fm',
|
||||||
|
'limit': 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert lookup_mock.call_count == 1
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data['status'] == 'ok'
|
||||||
|
assert data['source'] == 'sigidwiki'
|
||||||
|
assert data['cached'] is False
|
||||||
|
assert data['match_count'] == 1
|
||||||
|
assert data['matches'][0]['title'] == 'POCSAG'
|
||||||
|
|
||||||
|
|
||||||
|
def test_sigidwiki_lookup_cached_response(auth_client):
|
||||||
|
"""Second identical lookup should be served from cache."""
|
||||||
|
signalid_module._cache.clear()
|
||||||
|
fake_lookup = {
|
||||||
|
'matches': [{'title': 'Test Signal', 'url': 'https://www.sigidwiki.com/wiki/Test_Signal'}],
|
||||||
|
'search_used': True,
|
||||||
|
'exact_queries': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {'frequency_mhz': 433.92, 'modulation': 'nfm', 'limit': 5}
|
||||||
|
with patch('routes.signalid._lookup_sigidwiki_matches', return_value=fake_lookup) as lookup_mock:
|
||||||
|
first = auth_client.post('/signalid/sigidwiki', json=payload)
|
||||||
|
second = auth_client.post('/signalid/sigidwiki', json=payload)
|
||||||
|
|
||||||
|
assert lookup_mock.call_count == 1
|
||||||
|
assert first.status_code == 200
|
||||||
|
assert second.status_code == 200
|
||||||
|
assert first.get_json()['cached'] is False
|
||||||
|
assert second.get_json()['cached'] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_sigidwiki_lookup_backend_failure(auth_client):
|
||||||
|
"""Unexpected lookup failures should return 502."""
|
||||||
|
signalid_module._cache.clear()
|
||||||
|
with patch('routes.signalid._lookup_sigidwiki_matches', side_effect=RuntimeError('boom')):
|
||||||
|
resp = auth_client.post('/signalid/sigidwiki', json={'frequency_mhz': 433.92})
|
||||||
|
assert resp.status_code == 502
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Tests for SSTV scope waveform encoding."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from utils.sstv.sstv_decoder import _encode_scope_waveform
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_scope_waveform_respects_window_and_bounds():
|
||||||
|
samples = np.array([-32768, -16384, 0, 16384, 32767], dtype=np.int16)
|
||||||
|
waveform = _encode_scope_waveform(samples, window_size=4)
|
||||||
|
|
||||||
|
assert len(waveform) == 4
|
||||||
|
assert waveform[0] == -64
|
||||||
|
assert waveform[1] == 0
|
||||||
|
assert waveform[2] == 64
|
||||||
|
assert waveform[3] == 127
|
||||||
|
assert max(waveform) <= 127
|
||||||
|
assert min(waveform) >= -127
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_scope_waveform_empty_input():
|
||||||
|
waveform = _encode_scope_waveform(np.array([], dtype=np.int16))
|
||||||
|
assert waveform == []
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Tests for waterfall WebSocket configuration helpers."""
|
||||||
|
|
||||||
|
from routes.waterfall_websocket import (
|
||||||
|
_parse_center_freq_mhz,
|
||||||
|
_parse_span_mhz,
|
||||||
|
_pick_sample_rate,
|
||||||
|
)
|
||||||
|
from utils.sdr import SDRType
|
||||||
|
from utils.sdr.base import SDRCapabilities
|
||||||
|
|
||||||
|
|
||||||
|
def _caps(sample_rates):
|
||||||
|
return SDRCapabilities(
|
||||||
|
sdr_type=SDRType.RTL_SDR,
|
||||||
|
freq_min_mhz=24.0,
|
||||||
|
freq_max_mhz=1766.0,
|
||||||
|
gain_min=0.0,
|
||||||
|
gain_max=49.6,
|
||||||
|
sample_rates=sample_rates,
|
||||||
|
supports_bias_t=True,
|
||||||
|
supports_ppm=True,
|
||||||
|
tx_capable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_center_prefers_center_freq_mhz():
|
||||||
|
assert _parse_center_freq_mhz({'center_freq_mhz': 162.55, 'center_freq': 144000000}) == 162.55
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_center_supports_center_freq_hz():
|
||||||
|
assert _parse_center_freq_mhz({'center_freq_hz': 915000000}) == 915.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_center_supports_legacy_hz_payload():
|
||||||
|
assert _parse_center_freq_mhz({'center_freq': 109000000}) == 109.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_center_supports_legacy_mhz_payload():
|
||||||
|
assert _parse_center_freq_mhz({'center_freq': 433.92}) == 433.92
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_span_from_hz_and_mhz():
|
||||||
|
assert _parse_span_mhz({'span_hz': 2400000}) == 2.4
|
||||||
|
assert _parse_span_mhz({'span_mhz': 10.0}) == 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_sample_rate_chooses_nearest_declared_rate():
|
||||||
|
caps = _caps([250000, 1024000, 1800000, 2048000, 2400000])
|
||||||
|
assert _pick_sample_rate(700000, caps, SDRType.RTL_SDR) == 1024000
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_sample_rate_falls_back_to_max_bandwidth():
|
||||||
|
caps = _caps([])
|
||||||
|
assert _pick_sample_rate(10_000_000, caps, SDRType.RTL_SDR) == 2_400_000
|
||||||
@@ -97,7 +97,7 @@ class AgentClient:
|
|||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
raise AgentHTTPError(f"Request failed: {e}")
|
raise AgentHTTPError(f"Request failed: {e}")
|
||||||
|
|
||||||
def _post(self, path: str, data: dict | None = None) -> dict:
|
def _post(self, path: str, data: dict | None = None, timeout: float | None = None) -> dict:
|
||||||
"""
|
"""
|
||||||
Perform POST request to agent.
|
Perform POST request to agent.
|
||||||
|
|
||||||
@@ -113,19 +113,20 @@ class AgentClient:
|
|||||||
AgentConnectionError: If agent is unreachable
|
AgentConnectionError: If agent is unreachable
|
||||||
"""
|
"""
|
||||||
url = f"{self.base_url}{path}"
|
url = f"{self.base_url}{path}"
|
||||||
|
request_timeout = self.timeout if timeout is None else timeout
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
url,
|
url,
|
||||||
json=data or {},
|
json=data or {},
|
||||||
headers=self._headers(),
|
headers=self._headers(),
|
||||||
timeout=self.timeout
|
timeout=request_timeout
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json() if response.content else {}
|
return response.json() if response.content else {}
|
||||||
except requests.ConnectionError as e:
|
except requests.ConnectionError as e:
|
||||||
raise AgentConnectionError(f"Cannot connect to agent at {self.base_url}: {e}")
|
raise AgentConnectionError(f"Cannot connect to agent at {self.base_url}: {e}")
|
||||||
except requests.Timeout:
|
except requests.Timeout:
|
||||||
raise AgentConnectionError(f"Request to agent timed out after {self.timeout}s")
|
raise AgentConnectionError(f"Request to agent timed out after {request_timeout}s")
|
||||||
except requests.HTTPError as e:
|
except requests.HTTPError as e:
|
||||||
# Try to extract error message from response body
|
# Try to extract error message from response body
|
||||||
error_msg = f"Agent returned error: {e.response.status_code}"
|
error_msg = f"Agent returned error: {e.response.status_code}"
|
||||||
@@ -141,9 +142,9 @@ class AgentClient:
|
|||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
raise AgentHTTPError(f"Request failed: {e}")
|
raise AgentHTTPError(f"Request failed: {e}")
|
||||||
|
|
||||||
def post(self, path: str, data: dict | None = None) -> dict:
|
def post(self, path: str, data: dict | None = None, timeout: float | None = None) -> dict:
|
||||||
"""Public POST method for arbitrary endpoints."""
|
"""Public POST method for arbitrary endpoints."""
|
||||||
return self._post(path, data)
|
return self._post(path, data, timeout=timeout)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Capability & Status
|
# Capability & Status
|
||||||
@@ -214,7 +215,7 @@ class AgentClient:
|
|||||||
"""
|
"""
|
||||||
return self._post(f'/{mode}/start', params or {})
|
return self._post(f'/{mode}/start', params or {})
|
||||||
|
|
||||||
def stop_mode(self, mode: str) -> dict:
|
def stop_mode(self, mode: str, timeout: float = 8.0) -> dict:
|
||||||
"""
|
"""
|
||||||
Stop a running mode on the agent.
|
Stop a running mode on the agent.
|
||||||
|
|
||||||
@@ -224,7 +225,7 @@ class AgentClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Stop result with 'status' field
|
Stop result with 'status' field
|
||||||
"""
|
"""
|
||||||
return self._post(f'/{mode}/stop')
|
return self._post(f'/{mode}/stop', timeout=timeout)
|
||||||
|
|
||||||
def get_mode_status(self, mode: str) -> dict:
|
def get_mode_status(self, mode: str) -> dict:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ def _load_meta() -> dict[str, Any] | None:
|
|||||||
if os.path.exists(DB_META_FILE):
|
if os.path.exists(DB_META_FILE):
|
||||||
with open(DB_META_FILE, 'r') as f:
|
with open(DB_META_FILE, 'r') as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(f"Corrupt aircraft db meta file, removing: {e}")
|
||||||
|
try:
|
||||||
|
os.remove(DB_META_FILE)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error loading aircraft db meta: {e}")
|
logger.warning(f"Error loading aircraft db meta: {e}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,231 +0,0 @@
|
|||||||
"""Cross-mode analytics: activity tracking, summaries, and emergency squawk detection."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import time
|
|
||||||
from collections import deque
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import app as app_module
|
|
||||||
|
|
||||||
|
|
||||||
class ModeActivityTracker:
|
|
||||||
"""Track device counts per mode in time-bucketed ring buffer for sparklines."""
|
|
||||||
|
|
||||||
def __init__(self, max_buckets: int = 60, bucket_interval: float = 5.0):
|
|
||||||
self._max_buckets = max_buckets
|
|
||||||
self._bucket_interval = bucket_interval
|
|
||||||
self._history: dict[str, deque] = {}
|
|
||||||
self._last_record_time = 0.0
|
|
||||||
|
|
||||||
def record(self) -> None:
|
|
||||||
"""Snapshot current counts for all modes."""
|
|
||||||
now = time.time()
|
|
||||||
if now - self._last_record_time < self._bucket_interval:
|
|
||||||
return
|
|
||||||
self._last_record_time = now
|
|
||||||
|
|
||||||
counts = _get_mode_counts()
|
|
||||||
for mode, count in counts.items():
|
|
||||||
if mode not in self._history:
|
|
||||||
self._history[mode] = deque(maxlen=self._max_buckets)
|
|
||||||
self._history[mode].append(count)
|
|
||||||
|
|
||||||
def get_sparkline(self, mode: str) -> list[int]:
|
|
||||||
"""Return sparkline array for a mode."""
|
|
||||||
self.record()
|
|
||||||
return list(self._history.get(mode, []))
|
|
||||||
|
|
||||||
def get_all_sparklines(self) -> dict[str, list[int]]:
|
|
||||||
"""Return sparkline arrays for all tracked modes."""
|
|
||||||
self.record()
|
|
||||||
return {mode: list(values) for mode, values in self._history.items()}
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton
|
|
||||||
_tracker: ModeActivityTracker | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_activity_tracker() -> ModeActivityTracker:
|
|
||||||
global _tracker
|
|
||||||
if _tracker is None:
|
|
||||||
_tracker = ModeActivityTracker()
|
|
||||||
return _tracker
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_len(attr_name: str) -> int:
|
|
||||||
"""Safely get len() of an app_module attribute."""
|
|
||||||
try:
|
|
||||||
return len(getattr(app_module, attr_name))
|
|
||||||
except Exception:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_route_attr(module_path: str, attr_name: str, default: int = 0) -> int:
|
|
||||||
"""Safely read a module-level counter from a route file."""
|
|
||||||
try:
|
|
||||||
import importlib
|
|
||||||
mod = importlib.import_module(module_path)
|
|
||||||
return int(getattr(mod, attr_name, default))
|
|
||||||
except Exception:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def _get_mode_counts() -> dict[str, int]:
|
|
||||||
"""Read current entity counts from all available data sources."""
|
|
||||||
counts: dict[str, int] = {}
|
|
||||||
|
|
||||||
# ADS-B aircraft (DataStore)
|
|
||||||
counts['adsb'] = _safe_len('adsb_aircraft')
|
|
||||||
|
|
||||||
# AIS vessels (DataStore)
|
|
||||||
counts['ais'] = _safe_len('ais_vessels')
|
|
||||||
|
|
||||||
# WiFi: prefer v2 scanner, fall back to legacy DataStore
|
|
||||||
wifi_count = 0
|
|
||||||
try:
|
|
||||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
|
||||||
if wifi_scanner is not None:
|
|
||||||
wifi_count = len(wifi_scanner.access_points)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if wifi_count == 0:
|
|
||||||
wifi_count = _safe_len('wifi_networks')
|
|
||||||
counts['wifi'] = wifi_count
|
|
||||||
|
|
||||||
# Bluetooth: prefer v2 scanner, fall back to legacy DataStore
|
|
||||||
bt_count = 0
|
|
||||||
try:
|
|
||||||
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
|
||||||
if bt_scanner is not None:
|
|
||||||
bt_count = len(bt_scanner.get_devices())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if bt_count == 0:
|
|
||||||
bt_count = _safe_len('bt_devices')
|
|
||||||
counts['bluetooth'] = bt_count
|
|
||||||
|
|
||||||
# DSC messages (DataStore)
|
|
||||||
counts['dsc'] = _safe_len('dsc_messages')
|
|
||||||
|
|
||||||
# ACARS message count (route-level counter)
|
|
||||||
counts['acars'] = _safe_route_attr('routes.acars', 'acars_message_count')
|
|
||||||
|
|
||||||
# VDL2 message count (route-level counter)
|
|
||||||
counts['vdl2'] = _safe_route_attr('routes.vdl2', 'vdl2_message_count')
|
|
||||||
|
|
||||||
# APRS stations (route-level dict)
|
|
||||||
try:
|
|
||||||
import routes.aprs as aprs_mod
|
|
||||||
counts['aprs'] = len(getattr(aprs_mod, 'aprs_stations', {}))
|
|
||||||
except Exception:
|
|
||||||
counts['aprs'] = 0
|
|
||||||
|
|
||||||
# Meshtastic recent messages (route-level list)
|
|
||||||
try:
|
|
||||||
import routes.meshtastic as mesh_route
|
|
||||||
counts['meshtastic'] = len(getattr(mesh_route, '_recent_messages', []))
|
|
||||||
except Exception:
|
|
||||||
counts['meshtastic'] = 0
|
|
||||||
|
|
||||||
return counts
|
|
||||||
|
|
||||||
|
|
||||||
def get_cross_mode_summary() -> dict[str, Any]:
|
|
||||||
"""Return counts dict for all available data sources."""
|
|
||||||
counts = _get_mode_counts()
|
|
||||||
wifi_clients_count = 0
|
|
||||||
try:
|
|
||||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
|
||||||
if wifi_scanner is not None:
|
|
||||||
wifi_clients_count = len(wifi_scanner.clients)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if wifi_clients_count == 0:
|
|
||||||
wifi_clients_count = _safe_len('wifi_clients')
|
|
||||||
counts['wifi_clients'] = wifi_clients_count
|
|
||||||
return counts
|
|
||||||
|
|
||||||
|
|
||||||
def get_mode_health() -> dict[str, dict]:
|
|
||||||
"""Check process refs and SDR status for each mode."""
|
|
||||||
health: dict[str, dict] = {}
|
|
||||||
|
|
||||||
process_map = {
|
|
||||||
'pager': 'current_process',
|
|
||||||
'sensor': 'sensor_process',
|
|
||||||
'adsb': 'adsb_process',
|
|
||||||
'ais': 'ais_process',
|
|
||||||
'acars': 'acars_process',
|
|
||||||
'vdl2': 'vdl2_process',
|
|
||||||
'aprs': 'aprs_process',
|
|
||||||
'wifi': 'wifi_process',
|
|
||||||
'bluetooth': 'bt_process',
|
|
||||||
'dsc': 'dsc_process',
|
|
||||||
'rtlamr': 'rtlamr_process',
|
|
||||||
'dmr': 'dmr_process',
|
|
||||||
}
|
|
||||||
|
|
||||||
for mode, attr in process_map.items():
|
|
||||||
proc = getattr(app_module, attr, None)
|
|
||||||
running = proc is not None and (proc.poll() is None if proc else False)
|
|
||||||
health[mode] = {'running': running}
|
|
||||||
|
|
||||||
# Override WiFi/BT health with v2 scanner status if available
|
|
||||||
try:
|
|
||||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
|
||||||
if wifi_scanner is not None and wifi_scanner.is_scanning:
|
|
||||||
health['wifi'] = {'running': True}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
|
||||||
if bt_scanner is not None and bt_scanner.is_scanning:
|
|
||||||
health['bluetooth'] = {'running': True}
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Meshtastic: check client connection status
|
|
||||||
try:
|
|
||||||
from utils.meshtastic import get_meshtastic_client
|
|
||||||
client = get_meshtastic_client()
|
|
||||||
health['meshtastic'] = {'running': client._interface is not None}
|
|
||||||
except Exception:
|
|
||||||
health['meshtastic'] = {'running': False}
|
|
||||||
|
|
||||||
try:
|
|
||||||
sdr_status = app_module.get_sdr_device_status()
|
|
||||||
health['sdr_devices'] = {str(k): v for k, v in sdr_status.items()}
|
|
||||||
except Exception:
|
|
||||||
health['sdr_devices'] = {}
|
|
||||||
|
|
||||||
return health
|
|
||||||
|
|
||||||
|
|
||||||
EMERGENCY_SQUAWKS = {
|
|
||||||
'7700': 'General Emergency',
|
|
||||||
'7600': 'Comms Failure',
|
|
||||||
'7500': 'Hijack',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_emergency_squawks() -> list[dict]:
|
|
||||||
"""Iterate adsb_aircraft DataStore for emergency squawk codes."""
|
|
||||||
emergencies: list[dict] = []
|
|
||||||
try:
|
|
||||||
for icao, aircraft in app_module.adsb_aircraft.items():
|
|
||||||
sq = str(aircraft.get('squawk', '')).strip()
|
|
||||||
if sq in EMERGENCY_SQUAWKS:
|
|
||||||
emergencies.append({
|
|
||||||
'icao': icao,
|
|
||||||
'callsign': aircraft.get('callsign', ''),
|
|
||||||
'squawk': sq,
|
|
||||||
'meaning': EMERGENCY_SQUAWKS[sq],
|
|
||||||
'altitude': aircraft.get('altitude'),
|
|
||||||
'lat': aircraft.get('lat'),
|
|
||||||
'lon': aircraft.get('lon'),
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return emergencies
|
|
||||||
+139
-42
@@ -10,7 +10,8 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
from dataclasses import dataclass
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
@@ -26,6 +27,49 @@ MAX_TRAIL_POINTS = 500
|
|||||||
# EMA smoothing factor for RSSI
|
# EMA smoothing factor for RSSI
|
||||||
EMA_ALPHA = 0.3
|
EMA_ALPHA = 0.3
|
||||||
|
|
||||||
|
# Polling/restart tuning for scanner resilience without high CPU churn.
|
||||||
|
POLL_INTERVAL_SECONDS = 1.5
|
||||||
|
SCAN_RESTART_BACKOFF_SECONDS = 8.0
|
||||||
|
NO_MATCH_LOG_EVERY_POLLS = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_mac(address: str | None) -> str | None:
|
||||||
|
"""Normalize MAC string to colon-separated uppercase form when possible."""
|
||||||
|
if not address:
|
||||||
|
return None
|
||||||
|
|
||||||
|
text = str(address).strip().upper().replace('-', ':')
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Handle raw 12-hex form: AABBCCDDEEFF
|
||||||
|
raw = ''.join(ch for ch in text if ch in '0123456789ABCDEF')
|
||||||
|
if ':' not in text and len(raw) == 12:
|
||||||
|
text = ':'.join(raw[i:i + 2] for i in range(0, 12, 2))
|
||||||
|
|
||||||
|
parts = text.split(':')
|
||||||
|
if len(parts) == 6 and all(len(p) == 2 and all(c in '0123456789ABCDEF' for c in p) for p in parts):
|
||||||
|
return ':'.join(parts)
|
||||||
|
|
||||||
|
# Return cleaned original when not a strict MAC (caller may still use exact matching)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _address_looks_like_rpa(address: str | None) -> bool:
|
||||||
|
"""
|
||||||
|
Return True when an address looks like a Resolvable Private Address.
|
||||||
|
|
||||||
|
RPA check: most-significant two bits of the first octet are `01`.
|
||||||
|
"""
|
||||||
|
normalized = _normalize_mac(address)
|
||||||
|
if not normalized:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
first_octet = int(normalized.split(':', 1)[0], 16)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
return (first_octet >> 6) == 1
|
||||||
|
|
||||||
|
|
||||||
class Environment(Enum):
|
class Environment(Enum):
|
||||||
"""RF propagation environment presets."""
|
"""RF propagation environment presets."""
|
||||||
@@ -94,8 +138,27 @@ class LocateTarget:
|
|||||||
known_name: str | None = None
|
known_name: str | None = None
|
||||||
known_manufacturer: str | None = None
|
known_manufacturer: str | None = None
|
||||||
last_known_rssi: int | None = None
|
last_known_rssi: int | None = None
|
||||||
|
_cached_irk_hex: str | None = field(default=None, init=False, repr=False)
|
||||||
|
_cached_irk_bytes: bytes | None = field(default=None, init=False, repr=False)
|
||||||
|
|
||||||
def matches(self, device: BTDeviceAggregate) -> bool:
|
def _get_irk_bytes(self) -> bytes | None:
|
||||||
|
"""Parse/cache target IRK bytes once for repeated match checks."""
|
||||||
|
if not self.irk_hex:
|
||||||
|
return None
|
||||||
|
if self._cached_irk_hex == self.irk_hex:
|
||||||
|
return self._cached_irk_bytes
|
||||||
|
self._cached_irk_hex = self.irk_hex
|
||||||
|
self._cached_irk_bytes = None
|
||||||
|
try:
|
||||||
|
parsed = bytes.fromhex(self.irk_hex)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
if len(parsed) != 16:
|
||||||
|
return None
|
||||||
|
self._cached_irk_bytes = parsed
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
def matches(self, device: BTDeviceAggregate, irk_bytes: bytes | None = None) -> bool:
|
||||||
"""Check if a device matches this target."""
|
"""Check if a device matches this target."""
|
||||||
# Match by stable device key (survives MAC randomization for many devices)
|
# Match by stable device key (survives MAC randomization for many devices)
|
||||||
if self.device_key and getattr(device, 'device_key', None) == self.device_key:
|
if self.device_key and getattr(device, 'device_key', None) == self.device_key:
|
||||||
@@ -114,26 +177,28 @@ class LocateTarget:
|
|||||||
|
|
||||||
# Match by MAC/address (case-insensitive, normalize separators)
|
# Match by MAC/address (case-insensitive, normalize separators)
|
||||||
if self.mac_address:
|
if self.mac_address:
|
||||||
dev_addr = (device.address or '').upper().replace('-', ':')
|
dev_addr = _normalize_mac(device.address)
|
||||||
target_addr = self.mac_address.upper().replace('-', ':')
|
target_addr = _normalize_mac(self.mac_address)
|
||||||
if dev_addr == target_addr:
|
if dev_addr and target_addr and dev_addr == target_addr:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Match by payload fingerprint (guard against low-stability generic fingerprints)
|
# Match by payload fingerprint.
|
||||||
|
# For explicit hand-off sessions, allow exact fingerprint matches even if
|
||||||
|
# stability is still warming up.
|
||||||
if self.fingerprint_id:
|
if self.fingerprint_id:
|
||||||
dev_fp = getattr(device, 'payload_fingerprint_id', None)
|
dev_fp = getattr(device, 'payload_fingerprint_id', None)
|
||||||
dev_fp_stability = getattr(device, 'payload_fingerprint_stability', 0.0) or 0.0
|
dev_fp_stability = getattr(device, 'payload_fingerprint_stability', 0.0) or 0.0
|
||||||
if dev_fp and dev_fp == self.fingerprint_id and dev_fp_stability >= 0.35:
|
if dev_fp and dev_fp == self.fingerprint_id:
|
||||||
return True
|
if dev_fp_stability >= 0.35:
|
||||||
|
return True
|
||||||
|
if any([self.device_id, self.device_key, self.mac_address, self.known_name]):
|
||||||
|
return True
|
||||||
|
|
||||||
# Match by RPA resolution
|
# Match by RPA resolution
|
||||||
if self.irk_hex:
|
if self.irk_hex and device.address and _address_looks_like_rpa(device.address):
|
||||||
try:
|
irk = irk_bytes or self._get_irk_bytes()
|
||||||
irk = bytes.fromhex(self.irk_hex)
|
if irk and resolve_rpa(irk, device.address):
|
||||||
if len(irk) == 16 and device.address and resolve_rpa(irk, device.address):
|
return True
|
||||||
return True
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Match by name pattern
|
# Match by name pattern
|
||||||
if self.name_pattern and device.name and self.name_pattern.lower() in device.name.lower():
|
if self.name_pattern and device.name and self.name_pattern.lower() in device.name.lower():
|
||||||
@@ -260,6 +325,8 @@ class LocateSession:
|
|||||||
self.callback_call_count = 0
|
self.callback_call_count = 0
|
||||||
self.poll_count = 0
|
self.poll_count = 0
|
||||||
self._last_seen_device: str | None = None
|
self._last_seen_device: str | None = None
|
||||||
|
self._last_scan_restart_attempt = 0.0
|
||||||
|
self._target_irk = target._get_irk_bytes()
|
||||||
|
|
||||||
# Scanner reference
|
# Scanner reference
|
||||||
self._scanner: BluetoothScanner | None = None
|
self._scanner: BluetoothScanner | None = None
|
||||||
@@ -276,15 +343,22 @@ class LocateSession:
|
|||||||
"""
|
"""
|
||||||
self._scanner = get_bluetooth_scanner()
|
self._scanner = get_bluetooth_scanner()
|
||||||
self._scanner.add_device_callback(self._on_device)
|
self._scanner.add_device_callback(self._on_device)
|
||||||
|
self._scanner_started_by_us = False
|
||||||
|
|
||||||
# Ensure BLE scanning is active
|
# Ensure BLE scanning is active
|
||||||
if not self._scanner.is_scanning:
|
if not self._scanner.is_scanning:
|
||||||
logger.info("BT scanner not running, starting scan for locate session")
|
logger.info("BT scanner not running, starting scan for locate session")
|
||||||
self._scanner_started_by_us = True
|
self._scanner_started_by_us = True
|
||||||
|
self._last_scan_restart_attempt = time.monotonic()
|
||||||
if not self._scanner.start_scan(mode='auto'):
|
if not self._scanner.start_scan(mode='auto'):
|
||||||
logger.warning("Failed to start BT scanner for locate session")
|
# Surface startup failure to caller and avoid leaving stale callbacks.
|
||||||
else:
|
status = self._scanner.get_status()
|
||||||
self._scanner_started_by_us = False
|
reason = status.error or "unknown error"
|
||||||
|
logger.warning(f"Failed to start BT scanner for locate session: {reason}")
|
||||||
|
self._scanner.remove_device_callback(self._on_device)
|
||||||
|
self._scanner = None
|
||||||
|
self._scanner_started_by_us = False
|
||||||
|
return False
|
||||||
|
|
||||||
self.active = True
|
self.active = True
|
||||||
self.started_at = datetime.now()
|
self.started_at = datetime.now()
|
||||||
@@ -315,7 +389,7 @@ class LocateSession:
|
|||||||
def _poll_loop(self) -> None:
|
def _poll_loop(self) -> None:
|
||||||
"""Poll scanner aggregator for target device updates."""
|
"""Poll scanner aggregator for target device updates."""
|
||||||
while not self._stop_event.is_set():
|
while not self._stop_event.is_set():
|
||||||
self._stop_event.wait(timeout=1.5)
|
self._stop_event.wait(timeout=POLL_INTERVAL_SECONDS)
|
||||||
if self._stop_event.is_set():
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
@@ -332,8 +406,11 @@ class LocateSession:
|
|||||||
|
|
||||||
# Restart scan if it expired (bleak 10s timeout)
|
# Restart scan if it expired (bleak 10s timeout)
|
||||||
if not self._scanner.is_scanning:
|
if not self._scanner.is_scanning:
|
||||||
logger.info("Scanner stopped, restarting for locate session")
|
now = time.monotonic()
|
||||||
self._scanner.start_scan(mode='auto')
|
if (now - self._last_scan_restart_attempt) >= SCAN_RESTART_BACKOFF_SECONDS:
|
||||||
|
self._last_scan_restart_attempt = now
|
||||||
|
logger.info("Scanner stopped, restarting for locate session")
|
||||||
|
self._scanner.start_scan(mode='auto')
|
||||||
|
|
||||||
# Check devices seen within a recent window. Using a short window
|
# Check devices seen within a recent window. Using a short window
|
||||||
# (rather than the aggregator's full 120s) so that once a device
|
# (rather than the aggregator's full 120s) so that once a device
|
||||||
@@ -342,7 +419,7 @@ class LocateSession:
|
|||||||
devices = self._scanner.get_devices(max_age_seconds=15)
|
devices = self._scanner.get_devices(max_age_seconds=15)
|
||||||
found_target = False
|
found_target = False
|
||||||
for device in devices:
|
for device in devices:
|
||||||
if not self.target.matches(device):
|
if not self.target.matches(device, irk_bytes=self._target_irk):
|
||||||
continue
|
continue
|
||||||
found_target = True
|
found_target = True
|
||||||
rssi = device.rssi_current
|
rssi = device.rssi_current
|
||||||
@@ -352,7 +429,11 @@ class LocateSession:
|
|||||||
break # One match per poll cycle is sufficient
|
break # One match per poll cycle is sufficient
|
||||||
|
|
||||||
# Log periodically for debugging
|
# Log periodically for debugging
|
||||||
if self.poll_count % 20 == 0 or (self.poll_count <= 5) or not found_target:
|
if (
|
||||||
|
self.poll_count <= 5
|
||||||
|
or self.poll_count % 20 == 0
|
||||||
|
or (not found_target and self.poll_count % NO_MATCH_LOG_EVERY_POLLS == 0)
|
||||||
|
):
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Poll #{self.poll_count}: {len(devices)} devices, "
|
f"Poll #{self.poll_count}: {len(devices)} devices, "
|
||||||
f"target_found={found_target}, "
|
f"target_found={found_target}, "
|
||||||
@@ -368,7 +449,7 @@ class LocateSession:
|
|||||||
self.callback_call_count += 1
|
self.callback_call_count += 1
|
||||||
self._last_seen_device = f"{device.device_id}|{device.name}"
|
self._last_seen_device = f"{device.device_id}|{device.name}"
|
||||||
|
|
||||||
if not self.target.matches(device):
|
if not self.target.matches(device, irk_bytes=self._target_irk):
|
||||||
return
|
return
|
||||||
|
|
||||||
rssi = device.rssi_current
|
rssi = device.rssi_current
|
||||||
@@ -398,12 +479,8 @@ class LocateSession:
|
|||||||
|
|
||||||
# Check RPA resolution
|
# Check RPA resolution
|
||||||
rpa_resolved = False
|
rpa_resolved = False
|
||||||
if self.target.irk_hex and device.address:
|
if self._target_irk and device.address and _address_looks_like_rpa(device.address):
|
||||||
try:
|
rpa_resolved = resolve_rpa(self._target_irk, device.address)
|
||||||
irk = bytes.fromhex(self.target.irk_hex)
|
|
||||||
rpa_resolved = resolve_rpa(irk, device.address)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# GPS tag — prefer live GPS, fall back to user-set coordinates
|
# GPS tag — prefer live GPS, fall back to user-set coordinates
|
||||||
gps_pos = get_current_position()
|
gps_pos = get_current_position()
|
||||||
@@ -465,7 +542,7 @@ class LocateSession:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return [p.to_dict() for p in self.trail if p.lat is not None]
|
return [p.to_dict() for p in self.trail if p.lat is not None]
|
||||||
|
|
||||||
def get_status(self) -> dict:
|
def get_status(self, include_debug: bool = False) -> dict:
|
||||||
"""Get session status."""
|
"""Get session status."""
|
||||||
gps_pos = get_current_position()
|
gps_pos = get_current_position()
|
||||||
|
|
||||||
@@ -473,7 +550,7 @@ class LocateSession:
|
|||||||
# deadlock: get_status would hold self._lock then wait on
|
# deadlock: get_status would hold self._lock then wait on
|
||||||
# aggregator._lock, while _poll_loop holds aggregator._lock then
|
# aggregator._lock, while _poll_loop holds aggregator._lock then
|
||||||
# waits on self._lock in _record_detection.
|
# waits on self._lock in _record_detection.
|
||||||
debug_devices = self._debug_device_sample()
|
debug_devices = self._debug_device_sample() if include_debug else []
|
||||||
scanner_running = self._scanner.is_scanning if self._scanner else False
|
scanner_running = self._scanner.is_scanning if self._scanner else False
|
||||||
scanner_device_count = self._scanner.device_count if self._scanner else 0
|
scanner_device_count = self._scanner.device_count if self._scanner else 0
|
||||||
callback_registered = (
|
callback_registered = (
|
||||||
@@ -531,7 +608,7 @@ class LocateSession:
|
|||||||
'addr': d.address,
|
'addr': d.address,
|
||||||
'name': d.name,
|
'name': d.name,
|
||||||
'rssi': d.rssi_current,
|
'rssi': d.rssi_current,
|
||||||
'match': self.target.matches(d),
|
'match': self.target.matches(d, irk_bytes=self._target_irk),
|
||||||
}
|
}
|
||||||
for d in devices[:8]
|
for d in devices[:8]
|
||||||
]
|
]
|
||||||
@@ -560,25 +637,45 @@ def start_locate_session(
|
|||||||
"""Start a new locate session, stopping any existing one."""
|
"""Start a new locate session, stopping any existing one."""
|
||||||
global _session
|
global _session
|
||||||
|
|
||||||
|
# Grab and evict any existing session without holding the lock during stop()
|
||||||
|
# (stop() joins a thread which can block for up to 3 s).
|
||||||
|
old_session = None
|
||||||
with _session_lock:
|
with _session_lock:
|
||||||
if _session and _session.active:
|
if _session and _session.active:
|
||||||
_session.stop()
|
old_session = _session
|
||||||
|
_session = None
|
||||||
|
|
||||||
_session = LocateSession(
|
if old_session:
|
||||||
target, environment, custom_exponent, fallback_lat, fallback_lon
|
old_session.stop()
|
||||||
)
|
|
||||||
_session.start()
|
new_session = LocateSession(
|
||||||
return _session
|
target, environment, custom_exponent, fallback_lat, fallback_lon
|
||||||
|
)
|
||||||
|
with _session_lock:
|
||||||
|
_session = new_session
|
||||||
|
|
||||||
|
if not new_session.start():
|
||||||
|
with _session_lock:
|
||||||
|
if _session is new_session:
|
||||||
|
_session = None
|
||||||
|
raise RuntimeError("Bluetooth scanner failed to start")
|
||||||
|
|
||||||
|
return new_session
|
||||||
|
|
||||||
|
|
||||||
def stop_locate_session() -> None:
|
def stop_locate_session() -> None:
|
||||||
"""Stop the active locate session."""
|
"""Stop the active locate session."""
|
||||||
global _session
|
global _session
|
||||||
|
|
||||||
|
# Release the lock before stop() so concurrent status/SSE requests
|
||||||
|
# aren't blocked for up to 3 s while the poll thread is joined.
|
||||||
|
session_to_stop = None
|
||||||
with _session_lock:
|
with _session_lock:
|
||||||
if _session:
|
session_to_stop = _session
|
||||||
_session.stop()
|
_session = None
|
||||||
_session = None
|
|
||||||
|
if session_to_stop:
|
||||||
|
session_to_stop.stop()
|
||||||
|
|
||||||
|
|
||||||
def get_locate_session() -> LocateSession | None:
|
def get_locate_session() -> LocateSession | None:
|
||||||
|
|||||||
@@ -2302,3 +2302,4 @@ def remove_tracked_satellite(norad_id: str) -> tuple[bool, str]:
|
|||||||
)
|
)
|
||||||
return True, 'Removed'
|
return True, 'Removed'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ def process_event(mode: str, event: dict | Any, event_type: str | None = None) -
|
|||||||
# Alert failures should never break streaming
|
# Alert failures should never break streaming
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _extract_device_id(event: dict) -> str | None:
|
def _extract_device_id(event: dict) -> str | None:
|
||||||
for field in DEVICE_ID_FIELDS:
|
for field in DEVICE_ID_FIELDS:
|
||||||
value = event.get(field)
|
value = event.get(field)
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
|||||||
['rtl_test', '-t'],
|
['rtl_test', '-t'],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
encoding='utf-8',
|
||||||
|
errors='replace',
|
||||||
timeout=5,
|
timeout=5,
|
||||||
env=env
|
env=env
|
||||||
)
|
)
|
||||||
@@ -123,7 +125,8 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
|||||||
|
|
||||||
# Parse device info from rtl_test output
|
# Parse device info from rtl_test output
|
||||||
# Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001"
|
# Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001"
|
||||||
device_pattern = r'(\d+):\s+(.+?)(?:,\s*SN:\s*(\S+))?$'
|
# Require a non-empty serial to avoid matching malformed lines like "SN:".
|
||||||
|
device_pattern = r'(\d+):\s+(.+?),\s*SN:\s*(\S+)\s*$'
|
||||||
|
|
||||||
from .rtlsdr import RTLSDRCommandBuilder
|
from .rtlsdr import RTLSDRCommandBuilder
|
||||||
|
|
||||||
@@ -135,7 +138,7 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
|||||||
sdr_type=SDRType.RTL_SDR,
|
sdr_type=SDRType.RTL_SDR,
|
||||||
index=int(match.group(1)),
|
index=int(match.group(1)),
|
||||||
name=match.group(2).strip().rstrip(','),
|
name=match.group(2).strip().rstrip(','),
|
||||||
serial=match.group(3) or 'N/A',
|
serial=match.group(3),
|
||||||
driver='rtlsdr',
|
driver='rtlsdr',
|
||||||
capabilities=RTLSDRCommandBuilder.CAPABILITIES
|
capabilities=RTLSDRCommandBuilder.CAPABILITIES
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -343,22 +343,6 @@ SIGNAL_TYPES: list[SignalTypeDefinition] = [
|
|||||||
regions=["GLOBAL"],
|
regions=["GLOBAL"],
|
||||||
),
|
),
|
||||||
|
|
||||||
# LoRaWAN
|
|
||||||
SignalTypeDefinition(
|
|
||||||
label="LoRaWAN / LoRa Device",
|
|
||||||
tags=["iot", "lora", "lpwan", "telemetry"],
|
|
||||||
description="LoRa long-range IoT device",
|
|
||||||
frequency_ranges=[
|
|
||||||
(863_000_000, 870_000_000), # EU868
|
|
||||||
(902_000_000, 928_000_000), # US915
|
|
||||||
],
|
|
||||||
modulation_hints=["LoRa", "CSS", "FSK"],
|
|
||||||
bandwidth_range=(125_000, 500_000), # LoRa spreading bandwidths
|
|
||||||
base_score=11,
|
|
||||||
is_burst_type=True,
|
|
||||||
regions=["UK/EU", "US"],
|
|
||||||
),
|
|
||||||
|
|
||||||
# Key Fob / Remote
|
# Key Fob / Remote
|
||||||
SignalTypeDefinition(
|
SignalTypeDefinition(
|
||||||
label="Remote Control / Key Fob",
|
label="Remote Control / Key Fob",
|
||||||
|
|||||||
@@ -122,6 +122,17 @@ class DecodeProgress:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_scope_waveform(raw_samples: np.ndarray, window_size: int = 256) -> list[int]:
|
||||||
|
"""Compress recent int16 PCM samples to signed 8-bit values for SSE."""
|
||||||
|
if raw_samples.size == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
window = raw_samples[-window_size:] if raw_samples.size > window_size else raw_samples
|
||||||
|
packed = np.rint(window.astype(np.float64) / 256.0).astype(np.int16)
|
||||||
|
packed = np.clip(packed, -127, 127)
|
||||||
|
return packed.tolist()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# DopplerTracker
|
# DopplerTracker
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -423,6 +434,7 @@ class SSTVDecoder:
|
|||||||
# Scope: compute RMS/peak from raw int16 samples every chunk
|
# Scope: compute RMS/peak from raw int16 samples every chunk
|
||||||
rms_val = int(np.sqrt(np.mean(raw_samples.astype(np.float64) ** 2)))
|
rms_val = int(np.sqrt(np.mean(raw_samples.astype(np.float64) ** 2)))
|
||||||
peak_val = int(np.max(np.abs(raw_samples)))
|
peak_val = int(np.max(np.abs(raw_samples)))
|
||||||
|
waveform = _encode_scope_waveform(raw_samples)
|
||||||
|
|
||||||
if image_decoder is not None:
|
if image_decoder is not None:
|
||||||
# Currently decoding an image
|
# Currently decoding an image
|
||||||
@@ -451,7 +463,7 @@ class SSTVDecoder:
|
|||||||
message=f'Decoding {current_mode_name}: {pct}%',
|
message=f'Decoding {current_mode_name}: {pct}%',
|
||||||
partial_image=partial_url,
|
partial_image=partial_url,
|
||||||
))
|
))
|
||||||
self._emit_scope(rms_val, peak_val, 'decoding')
|
self._emit_scope(rms_val, peak_val, 'decoding', waveform)
|
||||||
|
|
||||||
if complete:
|
if complete:
|
||||||
# Save image
|
# Save image
|
||||||
@@ -529,7 +541,7 @@ class SSTVDecoder:
|
|||||||
vis_state=vis_detector.state.value,
|
vis_state=vis_detector.state.value,
|
||||||
))
|
))
|
||||||
|
|
||||||
self._emit_scope(rms_val, peak_val, scope_tone)
|
self._emit_scope(rms_val, peak_val, scope_tone, waveform)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in decode thread: {e}")
|
logger.error(f"Error in decode thread: {e}")
|
||||||
@@ -762,11 +774,20 @@ class SSTVDecoder:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in progress callback: {e}")
|
logger.error(f"Error in progress callback: {e}")
|
||||||
|
|
||||||
def _emit_scope(self, rms: int, peak: int, tone: str | None = None) -> None:
|
def _emit_scope(
|
||||||
|
self,
|
||||||
|
rms: int,
|
||||||
|
peak: int,
|
||||||
|
tone: str | None = None,
|
||||||
|
waveform: list[int] | None = None,
|
||||||
|
) -> None:
|
||||||
"""Emit scope signal levels to callback."""
|
"""Emit scope signal levels to callback."""
|
||||||
if self._callback:
|
if self._callback:
|
||||||
try:
|
try:
|
||||||
self._callback({'type': 'sstv_scope', 'rms': rms, 'peak': peak, 'tone': tone})
|
payload = {'type': 'sstv_scope', 'rms': rms, 'peak': peak, 'tone': tone}
|
||||||
|
if waveform:
|
||||||
|
payload['waveform'] = waveform
|
||||||
|
self._callback(payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
+72
-22
@@ -733,39 +733,69 @@ class UnifiedWiFiScanner:
|
|||||||
Returns:
|
Returns:
|
||||||
True if scan was stopped.
|
True if scan was stopped.
|
||||||
"""
|
"""
|
||||||
|
cleanup_process: Optional[subprocess.Popen] = None
|
||||||
|
cleanup_thread: Optional[threading.Thread] = None
|
||||||
|
cleanup_detector = None
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if not self._status.is_scanning:
|
if not self._status.is_scanning:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Stop deauth detector first
|
|
||||||
self._stop_deauth_detector()
|
|
||||||
|
|
||||||
self._deep_scan_stop_event.set()
|
self._deep_scan_stop_event.set()
|
||||||
|
cleanup_process = self._deep_scan_process
|
||||||
if self._deep_scan_process:
|
cleanup_thread = self._deep_scan_thread
|
||||||
try:
|
cleanup_detector = self._deauth_detector
|
||||||
self._deep_scan_process.terminate()
|
self._deauth_detector = None
|
||||||
self._deep_scan_process.wait(timeout=5)
|
self._deep_scan_process = None
|
||||||
except Exception as e:
|
self._deep_scan_thread = None
|
||||||
logger.warning(f"Error terminating airodump-ng: {e}")
|
|
||||||
try:
|
|
||||||
self._deep_scan_process.kill()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._deep_scan_process = None
|
|
||||||
|
|
||||||
if self._deep_scan_thread:
|
|
||||||
self._deep_scan_thread.join(timeout=5)
|
|
||||||
self._deep_scan_thread = None
|
|
||||||
|
|
||||||
self._status.is_scanning = False
|
self._status.is_scanning = False
|
||||||
|
self._status.error = None
|
||||||
|
|
||||||
self._queue_event({
|
self._queue_event({
|
||||||
'type': 'scan_stopped',
|
'type': 'scan_stopped',
|
||||||
'mode': SCAN_MODE_DEEP,
|
'mode': SCAN_MODE_DEEP,
|
||||||
})
|
})
|
||||||
|
|
||||||
return True
|
cleanup_start = time.perf_counter()
|
||||||
|
|
||||||
|
def _finalize_stop(
|
||||||
|
process: Optional[subprocess.Popen],
|
||||||
|
scan_thread: Optional[threading.Thread],
|
||||||
|
detector,
|
||||||
|
) -> None:
|
||||||
|
if detector:
|
||||||
|
try:
|
||||||
|
detector.stop()
|
||||||
|
logger.info("Deauth detector stopped")
|
||||||
|
self._queue_event({'type': 'deauth_detector_stopped'})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Error stopping deauth detector: {exc}")
|
||||||
|
|
||||||
|
if process and process.poll() is None:
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
process.wait(timeout=1.5)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if scan_thread and scan_thread.is_alive():
|
||||||
|
scan_thread.join(timeout=1.5)
|
||||||
|
|
||||||
|
elapsed_ms = (time.perf_counter() - cleanup_start) * 1000.0
|
||||||
|
logger.info(f"Deep scan stop finalized in {elapsed_ms:.1f}ms")
|
||||||
|
|
||||||
|
threading.Thread(
|
||||||
|
target=_finalize_stop,
|
||||||
|
args=(cleanup_process, cleanup_thread, cleanup_detector),
|
||||||
|
daemon=True,
|
||||||
|
name='wifi-deep-stop',
|
||||||
|
).start()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
def _run_deep_scan(
|
def _run_deep_scan(
|
||||||
self,
|
self,
|
||||||
@@ -799,12 +829,30 @@ class UnifiedWiFiScanner:
|
|||||||
|
|
||||||
logger.info(f"Starting airodump-ng: {' '.join(cmd)}")
|
logger.info(f"Starting airodump-ng: {' '.join(cmd)}")
|
||||||
|
|
||||||
|
process: Optional[subprocess.Popen] = None
|
||||||
try:
|
try:
|
||||||
self._deep_scan_process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
|
should_track_process = False
|
||||||
|
with self._lock:
|
||||||
|
# Only expose the process handle if this run has not been
|
||||||
|
# replaced by a newer deep scan session.
|
||||||
|
if self._status.is_scanning and not self._deep_scan_stop_event.is_set():
|
||||||
|
should_track_process = True
|
||||||
|
self._deep_scan_process = process
|
||||||
|
if not should_track_process:
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
process.wait(timeout=1.0)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
csv_file = f"{output_prefix}-01.csv"
|
csv_file = f"{output_prefix}-01.csv"
|
||||||
|
|
||||||
@@ -837,7 +885,9 @@ class UnifiedWiFiScanner:
|
|||||||
'error': str(e),
|
'error': str(e),
|
||||||
})
|
})
|
||||||
finally:
|
finally:
|
||||||
self._deep_scan_process = None
|
with self._lock:
|
||||||
|
if process is not None and self._deep_scan_process is process:
|
||||||
|
self._deep_scan_process = None
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Observation Processing
|
# Observation Processing
|
||||||
|
|||||||
Reference in New Issue
Block a user