Compare commits

..

25 Commits

Author SHA1 Message Date
Smittix 8cd64ce3ca fix: PWA install prompt - add PNG icons and fix apple-touch-icon
Browsers require PNG icons (192x192, 512x512) in the manifest to show
the install prompt. SVG-only manifests are not sufficient. Also adds the
180x180 apple-touch-icon PNG for iOS home screen, bumps SW cache to v3,
and adds scope to the manifest.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 19:36:28 +00:00
Smittix 9705e58691 Release v2.22.0
Waterfall overhaul, new modes (fingerprint, RF heatmap, SignalID, voice
alerts), PWA support, mode stop responsiveness improvements, ADS-B MSG2
surface tracking, WebSDR overhaul, and full documentation audit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 19:31:10 +00:00
Smittix 3acdab816a Improve mode transitions and add nav perf instrumentation 2026-02-23 18:14:31 +00:00
Smittix c31ed14041 Improve mode stop responsiveness and timeout handling 2026-02-23 17:53:50 +00:00
Smittix 7241dbed35 chore: commit all pending changes 2026-02-23 16:51:32 +00:00
Smittix 94b358f686 Commit all pending workspace changes 2026-02-23 14:28:57 +00:00
Smittix 8e19f7e688 Fix ADS-B update flush timing and parse MSG2 surface data 2026-02-23 13:39:01 +00:00
Smittix 7ea06caaa2 Remove legacy RF modes and add SignalID route/tests 2026-02-23 13:34:00 +00:00
Smittix 5f480caa3f feat: ship waterfall receiver overhaul and platform mode updates 2026-02-22 23:22:37 +00:00
Smittix 5d4b61b4c3 Fix nested nav bar appearing in embedded dashboard iframes
When dashboards (satellite, ADS-B, AIS) are loaded via iframe with
?embedded=true, the full navigation bar was still rendered, creating
a "UI in UI" effect. Pass the embedded query param from route handlers
to templates and conditionally skip the nav include.

Fixes #144

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:16:51 +00:00
Smittix a8e2b9d98d Shrink hit areas and spread overlapping radar dots
Hit area: was Math.max(dotSize * 2, 15) — up to 24px radius around a 4px
dot. Now the CSS hover-flicker is fixed the large hit area is unnecessary
and was the reason dots activated when merely nearby. Changed to dotSize + 4
(proportional, 4px padding around the visual circle).

Overlap spread: compute all band positions first, then run an iterative
push-apart pass (spreadOverlappingDots) that nudges any two dots whose
arc gap is smaller than 2 * maxHitArea + 2px apart. Positions within a
band are stable across renders (same hash angle, same band = same output
before spreading) so dots don't shuffle on every update.

Z-order: sort visible devices by rssi_current ascending before rendering
so the strongest signal lands last in SVG order and receives clicks when
dots stack.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 14:51:45 +00:00
Smittix 4b225db9da Fix proximity radar jitter caused by CSS scale-on-hover feedback loop
The root cause was in proximity-viz.css, not the JS:

  .radar-device:hover { transform: scale(1.2); }

When the cursor entered a .radar-device, the 1.2x scale physically moved
the hit-area boundary, pushing the cursor outside it. The browser then
fired mouseout, the scale reverted, the cursor was back inside, mouseover
fired again, and the scale reapplied — a rapid enter/exit loop that looked
like the dot jumping and dancing.

Replace the geometry-changing scale with a brightness filter on the dot
circle only. filter: brightness() does not affect pointer-event hit testing
so there is no feedback loop, and the hover still gives clear visual
feedback. Also removes the transition: transform rule that was animating
the scale and contributing to the flicker.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 14:44:46 +00:00
Smittix aba4ccd040 Fix radar jitter by using band-only positioning
Replace continuous estimated_distance_m-based radius with proximity band
snapping (immediate/near/far/unknown → fixed radius ratios of 0.15/0.40/
0.70/0.90). The proximity_band is computed server-side from rssi_ema which
is already smoothed, so it changes infrequently — dots now only move when
a device genuinely crosses a band boundary rather than on every RSSI
fluctuation.

Also removes the client-side EMA and positionCache added in the previous
commit, and reverts CSS style.transform back to SVG transform attribute to
avoid coordinate-system mismatch when the SVG is displayed at a scaled size.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 14:38:50 +00:00
Smittix f8a6d0ae70 Smooth proximity radar positions with EMA and CSS transitions
The remaining jitter after the in-place DOM rewrite was caused by RSSI
fluctuations propagating directly into dot positions on every 200ms
update cycle.

Two fixes:
1. Client-side EMA (alpha=0.25) on x/y coordinates per device. Each
   render blends 25% toward the new raw position and retains 75% of the
   smoothed position, filtering high-frequency RSSI noise without hiding
   genuine distance changes. positionCache is keyed by device_key and
   cleared on device removal or radar reset.

2. CSS transition (transform 0.6s ease-out) on each wrapper element.
   Switching from SVG transform attribute to style.transform enables
   native CSS transitions, so any remaining position change (e.g. a band
   crossing) animates smoothly rather than snapping.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 14:35:42 +00:00
Smittix 00681840c8 Rewrite proximity radar to use in-place DOM updates
Instead of rebuilding devicesGroup.innerHTML on every render, mutate
existing SVG elements in-place (update transforms, attributes, class
names) and only create/remove elements when devices genuinely appear
or disappear from the visible set.

This eliminates the root cause of both the jitter and the blank-radar
regression: hover state can never be disrupted by a render because the
DOM elements under the cursor are never destroyed. The isHovered /
renderPending / interactionLockUntil state machine and its associated
mouseover/mouseout listeners are removed entirely — they are no longer
needed. A shared buildSelectRing() helper deduplicates the animated
selection ring construction used by renderDevices() and
applySelectionToElement(). Closes #143.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 14:29:41 +00:00
Smittix 00be3e940a Fix proximity radar hover jitter without breaking device rendering
Replace capture-phase mouseenter/mouseleave with bubbling mouseover/mouseout
for tracking hover state in the ProximityRadar component.

The capture-phase approach caused two problems:
1. Moving between sibling child elements (hit-area → dot circle) fired
   mouseleave, prematurely clearing isHovered and triggering a DOM rebuild
   that caused visible jitter.
2. When renderDevices() rebuilt innerHTML, the browser fired mouseleave for
   the destroyed element with relatedTarget pointing at the newly created
   element at the same position, leaving isHovered permanently stuck at true
   and suppressing all future renders.

The fix uses mouseover/mouseout (which bubble) with devicesGroup.contains()
to reliably detect whether the cursor genuinely left the device group, immune
to innerHTML rebuilds. Fixes both WiFi and Bluetooth proximity radars as they
share this component. Closes #143.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 14:22:59 +00:00
Smittix fb2a12773a Force local dashboard assets and quiet BT locate warnings 2026-02-20 19:11:21 +00:00
Smittix 167f10c7f7 Harden BT Locate handoff matching and start flow 2026-02-20 18:57:06 +00:00
Smittix e386016349 Default dashboard assets/fonts to local bundles 2026-02-20 18:03:06 +00:00
Smittix aec925753e Pause BT Locate processing when mode is hidden 2026-02-20 17:48:22 +00:00
Smittix c3bf30b49c Fix BT Locate startup/map rendering and CelesTrak import reliability 2026-02-20 17:35:57 +00:00
Smittix c0221ba53d Fix manual TLE parsing for pasted multiline input 2026-02-20 17:18:15 +00:00
Smittix af5b17e841 Remove Drone Ops feature end-to-end 2026-02-20 17:09:17 +00:00
Smittix b628a5f751 Add drone ops mode and retire DMR support 2026-02-20 17:02:16 +00:00
Smittix 9ec316fbe2 fix(bt-locate): stabilize first-load map and release v2.21.1 2026-02-20 00:49:08 +00:00
98 changed files with 12900 additions and 11800 deletions
+50
View File
@@ -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
View File
@@ -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/*
+5 -7
View File
@@ -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/) |
+29 -27
View File
@@ -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:
+29 -4
View File
@@ -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",
-11
View File
@@ -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
-2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
) )
+2
View File
@@ -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,
) )
-528
View File
@@ -1,528 +0,0 @@
"""Analytics dashboard: cross-mode summary, activity sparklines, export, geofence CRUD."""
from __future__ import annotations
import csv
import io
import json
from datetime import datetime, timezone
from typing import Any
from flask import Blueprint, Response, jsonify, request
import app as app_module
from utils.analytics import (
get_activity_tracker,
get_cross_mode_summary,
get_emergency_squawks,
get_mode_health,
)
from utils.alerts import get_alert_manager
from utils.flight_correlator import get_flight_correlator
from utils.geofence import get_geofence_manager
from utils.temporal_patterns import get_pattern_detector
analytics_bp = Blueprint('analytics', __name__, url_prefix='/analytics')
# Map mode names to DataStore attribute(s)
MODE_STORES: dict[str, list[str]] = {
'adsb': ['adsb_aircraft'],
'ais': ['ais_vessels'],
'wifi': ['wifi_networks', 'wifi_clients'],
'bluetooth': ['bt_devices'],
'dsc': ['dsc_messages'],
}
@analytics_bp.route('/summary')
def analytics_summary():
"""Return cross-mode counts, health, and emergency squawks."""
return jsonify({
'status': 'success',
'counts': get_cross_mode_summary(),
'health': get_mode_health(),
'squawks': get_emergency_squawks(),
'flight_messages': {
'acars': get_flight_correlator().acars_count,
'vdl2': get_flight_correlator().vdl2_count,
},
})
@analytics_bp.route('/activity')
def analytics_activity():
"""Return sparkline arrays for each mode."""
tracker = get_activity_tracker()
return jsonify({
'status': 'success',
'sparklines': tracker.get_all_sparklines(),
})
@analytics_bp.route('/squawks')
def analytics_squawks():
"""Return current emergency squawk codes from ADS-B."""
return jsonify({
'status': 'success',
'squawks': get_emergency_squawks(),
})
@analytics_bp.route('/patterns')
def analytics_patterns():
"""Return detected temporal patterns."""
return jsonify({
'status': 'success',
'patterns': get_pattern_detector().get_all_patterns(),
})
@analytics_bp.route('/target')
def analytics_target():
"""Search entities across multiple modes for a target-centric view."""
query = (request.args.get('q') or '').strip()
requested_limit = request.args.get('limit', default=120, type=int) or 120
limit = max(1, min(500, requested_limit))
if not query:
return jsonify({
'status': 'success',
'query': '',
'results': [],
'mode_counts': {},
})
needle = query.lower()
results: list[dict[str, Any]] = []
mode_counts: dict[str, int] = {}
def push(mode: str, entity_id: str, title: str, subtitle: str, last_seen: str | None = None) -> None:
if len(results) >= limit:
return
results.append({
'mode': mode,
'id': entity_id,
'title': title,
'subtitle': subtitle,
'last_seen': last_seen,
})
mode_counts[mode] = mode_counts.get(mode, 0) + 1
# ADS-B
for icao, aircraft in app_module.adsb_aircraft.items():
if not isinstance(aircraft, dict):
continue
fields = [
icao,
aircraft.get('icao'),
aircraft.get('hex'),
aircraft.get('callsign'),
aircraft.get('registration'),
aircraft.get('flight'),
]
if not _matches_query(needle, fields):
continue
title = str(aircraft.get('callsign') or icao or 'Aircraft').strip()
subtitle = f"ICAO {aircraft.get('icao') or icao} | Alt {aircraft.get('altitude', '--')} | Speed {aircraft.get('speed', '--')}"
push('adsb', str(icao), title, subtitle, aircraft.get('lastSeen') or aircraft.get('last_seen'))
if len(results) >= limit:
break
# AIS
if len(results) < limit:
for mmsi, vessel in app_module.ais_vessels.items():
if not isinstance(vessel, dict):
continue
fields = [
mmsi,
vessel.get('mmsi'),
vessel.get('name'),
vessel.get('shipname'),
vessel.get('callsign'),
vessel.get('imo'),
]
if not _matches_query(needle, fields):
continue
vessel_name = vessel.get('name') or vessel.get('shipname') or mmsi or 'Vessel'
subtitle = f"MMSI {vessel.get('mmsi') or mmsi} | Type {vessel.get('ship_type') or vessel.get('type') or '--'}"
push('ais', str(mmsi), str(vessel_name), subtitle, vessel.get('lastSeen') or vessel.get('last_seen'))
if len(results) >= limit:
break
# WiFi networks and clients
if len(results) < limit:
for bssid, net in app_module.wifi_networks.items():
if not isinstance(net, dict):
continue
fields = [bssid, net.get('bssid'), net.get('ssid'), net.get('vendor')]
if not _matches_query(needle, fields):
continue
title = str(net.get('ssid') or net.get('bssid') or bssid or 'WiFi Network')
subtitle = f"BSSID {net.get('bssid') or bssid} | CH {net.get('channel', '--')} | RSSI {net.get('signal', '--')}"
push('wifi', str(bssid), title, subtitle, net.get('lastSeen') or net.get('last_seen'))
if len(results) >= limit:
break
if len(results) < limit:
for client_mac, client in app_module.wifi_clients.items():
if not isinstance(client, dict):
continue
fields = [client_mac, client.get('mac'), client.get('bssid'), client.get('ssid'), client.get('vendor')]
if not _matches_query(needle, fields):
continue
title = str(client.get('mac') or client_mac or 'WiFi Client')
subtitle = f"BSSID {client.get('bssid') or '--'} | Probe {client.get('ssid') or '--'}"
push('wifi', str(client_mac), title, subtitle, client.get('lastSeen') or client.get('last_seen'))
if len(results) >= limit:
break
# Bluetooth
if len(results) < limit:
for address, dev in app_module.bt_devices.items():
if not isinstance(dev, dict):
continue
fields = [
address,
dev.get('address'),
dev.get('mac'),
dev.get('name'),
dev.get('manufacturer'),
dev.get('vendor'),
]
if not _matches_query(needle, fields):
continue
title = str(dev.get('name') or dev.get('address') or address or 'Bluetooth Device')
subtitle = f"MAC {dev.get('address') or address} | RSSI {dev.get('rssi', '--')} | Vendor {dev.get('manufacturer') or dev.get('vendor') or '--'}"
push('bluetooth', str(address), title, subtitle, dev.get('lastSeen') or dev.get('last_seen'))
if len(results) >= limit:
break
# DSC recent messages
if len(results) < limit:
for msg_id, msg in app_module.dsc_messages.items():
if not isinstance(msg, dict):
continue
fields = [
msg_id,
msg.get('mmsi'),
msg.get('from_mmsi'),
msg.get('to_mmsi'),
msg.get('from_callsign'),
msg.get('to_callsign'),
msg.get('category'),
]
if not _matches_query(needle, fields):
continue
title = str(msg.get('from_mmsi') or msg.get('mmsi') or msg_id or 'DSC Message')
subtitle = f"To {msg.get('to_mmsi') or '--'} | Cat {msg.get('category') or '--'} | Freq {msg.get('frequency') or '--'}"
push('dsc', str(msg_id), title, subtitle, msg.get('timestamp') or msg.get('lastSeen') or msg.get('last_seen'))
if len(results) >= limit:
break
return jsonify({
'status': 'success',
'query': query,
'results': results,
'mode_counts': mode_counts,
})
@analytics_bp.route('/insights')
def analytics_insights():
"""Return actionable insight cards and top changes."""
counts = get_cross_mode_summary()
tracker = get_activity_tracker()
sparklines = tracker.get_all_sparklines()
squawks = get_emergency_squawks()
patterns = get_pattern_detector().get_all_patterns()
alerts = get_alert_manager().list_events(limit=120)
top_changes = _compute_mode_changes(sparklines)
busiest_mode, busiest_count = _get_busiest_mode(counts)
critical_1h = _count_recent_alerts(alerts, severities={'critical', 'high'}, max_age_seconds=3600)
recurring_emitters = sum(1 for p in patterns if float(p.get('confidence') or 0.0) >= 0.7)
cards = []
if top_changes:
lead = top_changes[0]
direction = 'up' if lead['delta'] >= 0 else 'down'
cards.append({
'id': 'fastest_change',
'title': 'Fastest Change',
'value': f"{lead['mode_label']} ({lead['signed_delta']})",
'label': 'last window vs prior',
'severity': 'high' if lead['delta'] > 0 else 'low',
'detail': f"Traffic is trending {direction} in {lead['mode_label']}.",
})
else:
cards.append({
'id': 'fastest_change',
'title': 'Fastest Change',
'value': 'Insufficient data',
'label': 'wait for activity history',
'severity': 'low',
'detail': 'Sparklines need more samples to score momentum.',
})
cards.append({
'id': 'busiest_mode',
'title': 'Busiest Mode',
'value': f"{busiest_mode} ({busiest_count})",
'label': 'current observed entities',
'severity': 'medium' if busiest_count > 0 else 'low',
'detail': 'Highest live entity count across monitoring modes.',
})
cards.append({
'id': 'critical_alerts',
'title': 'Critical Alerts (1h)',
'value': str(critical_1h),
'label': 'critical/high severities',
'severity': 'critical' if critical_1h > 0 else 'low',
'detail': 'Prioritize triage if this count is non-zero.',
})
cards.append({
'id': 'emergency_squawks',
'title': 'Emergency Squawks',
'value': str(len(squawks)),
'label': 'active ADS-B emergency codes',
'severity': 'critical' if squawks else 'low',
'detail': 'Immediate aviation anomalies currently visible.',
})
cards.append({
'id': 'recurring_emitters',
'title': 'Recurring Emitters',
'value': str(recurring_emitters),
'label': 'pattern confidence >= 0.70',
'severity': 'medium' if recurring_emitters > 0 else 'low',
'detail': 'Potentially stationary or periodic emitters detected.',
})
return jsonify({
'status': 'success',
'generated_at': datetime.now(timezone.utc).isoformat(),
'cards': cards,
'top_changes': top_changes[:5],
})
def _compute_mode_changes(sparklines: dict[str, list[int]]) -> list[dict]:
mode_labels = {
'adsb': 'ADS-B',
'ais': 'AIS',
'wifi': 'WiFi',
'bluetooth': 'Bluetooth',
'dsc': 'DSC',
'acars': 'ACARS',
'vdl2': 'VDL2',
'aprs': 'APRS',
'meshtastic': 'Meshtastic',
}
rows = []
for mode, samples in (sparklines or {}).items():
if not isinstance(samples, list) or len(samples) < 4:
continue
window = max(2, min(12, len(samples) // 2))
recent = samples[-window:]
previous = samples[-(window * 2):-window]
if not previous:
continue
recent_avg = sum(recent) / len(recent)
prev_avg = sum(previous) / len(previous)
delta = round(recent_avg - prev_avg, 1)
rows.append({
'mode': mode,
'mode_label': mode_labels.get(mode, mode.upper()),
'delta': delta,
'signed_delta': ('+' if delta >= 0 else '') + str(delta),
'recent_avg': round(recent_avg, 1),
'previous_avg': round(prev_avg, 1),
'direction': 'up' if delta > 0 else ('down' if delta < 0 else 'flat'),
})
rows.sort(key=lambda r: abs(r['delta']), reverse=True)
return rows
def _matches_query(needle: str, values: list[Any]) -> bool:
for value in values:
if value is None:
continue
if needle in str(value).lower():
return True
return False
def _count_recent_alerts(alerts: list[dict], severities: set[str], max_age_seconds: int) -> int:
now = datetime.now(timezone.utc)
count = 0
for event in alerts:
sev = str(event.get('severity') or '').lower()
if sev not in severities:
continue
created_raw = event.get('created_at')
if not created_raw:
continue
try:
created = datetime.fromisoformat(str(created_raw).replace('Z', '+00:00'))
except ValueError:
continue
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
age = (now - created).total_seconds()
if 0 <= age <= max_age_seconds:
count += 1
return count
def _get_busiest_mode(counts: dict[str, int]) -> tuple[str, int]:
mode_labels = {
'adsb': 'ADS-B',
'ais': 'AIS',
'wifi': 'WiFi',
'bluetooth': 'Bluetooth',
'dsc': 'DSC',
'acars': 'ACARS',
'vdl2': 'VDL2',
'aprs': 'APRS',
'meshtastic': 'Meshtastic',
}
filtered = {k: int(v or 0) for k, v in (counts or {}).items() if k in mode_labels}
if not filtered:
return ('None', 0)
mode = max(filtered, key=filtered.get)
return (mode_labels.get(mode, mode.upper()), filtered[mode])
@analytics_bp.route('/export/<mode>')
def analytics_export(mode: str):
"""Export current DataStore contents as JSON or CSV."""
fmt = request.args.get('format', 'json').lower()
if mode == 'sensor':
# Sensor doesn't use DataStore; return recent queue-based data
return jsonify({'status': 'success', 'data': [], 'message': 'Sensor data is stream-only'})
store_names = MODE_STORES.get(mode)
if not store_names:
return jsonify({'status': 'error', 'message': f'Unknown mode: {mode}'}), 400
all_items: list[dict] = []
# Try v2 scanners first for wifi/bluetooth
if mode == 'wifi':
try:
from utils.wifi.scanner import _scanner_instance as wifi_scanner
if wifi_scanner is not None:
for ap in wifi_scanner.access_points:
all_items.append(ap.to_dict())
for client in wifi_scanner.clients:
item = client.to_dict()
item['_store'] = 'wifi_clients'
all_items.append(item)
except Exception:
pass
elif mode == 'bluetooth':
try:
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
if bt_scanner is not None:
for dev in bt_scanner.get_devices():
all_items.append(dev.to_dict())
except Exception:
pass
# Fall back to legacy DataStores if v2 scanners yielded nothing
if not all_items:
for store_name in store_names:
store = getattr(app_module, store_name, None)
if store is None:
continue
for key, value in store.items():
item = dict(value) if isinstance(value, dict) else {'id': key, 'value': value}
item.setdefault('_store', store_name)
all_items.append(item)
if fmt == 'csv':
if not all_items:
output = ''
else:
# Collect all keys across items
fieldnames: list[str] = []
seen: set[str] = set()
for item in all_items:
for k in item:
if k not in seen:
fieldnames.append(k)
seen.add(k)
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction='ignore')
writer.writeheader()
for item in all_items:
# Serialize non-scalar values
row = {}
for k in fieldnames:
v = item.get(k)
if isinstance(v, (dict, list)):
row[k] = json.dumps(v)
else:
row[k] = v
writer.writerow(row)
output = buf.getvalue()
response = Response(output, mimetype='text/csv')
response.headers['Content-Disposition'] = f'attachment; filename={mode}_export.csv'
return response
# Default: JSON
return jsonify({'status': 'success', 'mode': mode, 'count': len(all_items), 'data': all_items})
# =========================================================================
# Geofence CRUD
# =========================================================================
@analytics_bp.route('/geofences')
def list_geofences():
return jsonify({
'status': 'success',
'zones': get_geofence_manager().list_zones(),
})
@analytics_bp.route('/geofences', methods=['POST'])
def create_geofence():
data = request.get_json() or {}
name = data.get('name')
lat = data.get('lat')
lon = data.get('lon')
radius_m = data.get('radius_m')
if not all([name, lat is not None, lon is not None, radius_m is not None]):
return jsonify({'status': 'error', 'message': 'name, lat, lon, radius_m are required'}), 400
try:
lat = float(lat)
lon = float(lon)
radius_m = float(radius_m)
except (TypeError, ValueError):
return jsonify({'status': 'error', 'message': 'lat, lon, radius_m must be numbers'}), 400
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
if radius_m <= 0:
return jsonify({'status': 'error', 'message': 'radius_m must be positive'}), 400
alert_on = data.get('alert_on', 'enter_exit')
zone_id = get_geofence_manager().add_zone(name, lat, lon, radius_m, alert_on)
return jsonify({'status': 'success', 'zone_id': zone_id})
@analytics_bp.route('/geofences/<int:zone_id>', methods=['DELETE'])
def delete_geofence(zone_id: int):
ok = get_geofence_manager().delete_zone(zone_id)
if not ok:
return jsonify({'status': 'error', 'message': 'Zone not found'}), 404
return jsonify({'status': 'success'})
+18 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+352
View File
@@ -0,0 +1,352 @@
"""Signal identification enrichment routes (SigID Wiki proxy lookup)."""
from __future__ import annotations
import json
import time
import urllib.parse
import urllib.request
from typing import Any
from flask import Blueprint, Response, jsonify, request
from utils.logging import get_logger
logger = get_logger('intercept.signalid')
signalid_bp = Blueprint('signalid', __name__, url_prefix='/signalid')
SIGID_API_URL = 'https://www.sigidwiki.com/api.php'
SIGID_USER_AGENT = 'INTERCEPT-SignalID/1.0'
SIGID_TIMEOUT_SECONDS = 12
SIGID_CACHE_TTL_SECONDS = 600
_cache: dict[str, dict[str, Any]] = {}
def _cache_get(key: str) -> Any | None:
entry = _cache.get(key)
if not entry:
return None
if time.time() >= entry['expires']:
_cache.pop(key, None)
return None
return entry['data']
def _cache_set(key: str, data: Any, ttl_seconds: int = SIGID_CACHE_TTL_SECONDS) -> None:
_cache[key] = {
'data': data,
'expires': time.time() + ttl_seconds,
}
def _fetch_api_json(params: dict[str, str]) -> dict[str, Any] | None:
query = urllib.parse.urlencode(params, doseq=True)
url = f'{SIGID_API_URL}?{query}'
req = urllib.request.Request(url, headers={'User-Agent': SIGID_USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=SIGID_TIMEOUT_SECONDS) as resp:
payload = resp.read().decode('utf-8', errors='replace')
data = json.loads(payload)
except Exception as exc:
logger.warning('SigID API request failed: %s', exc)
return None
if isinstance(data, dict) and data.get('error'):
logger.warning('SigID API returned error: %s', data.get('error'))
return None
return data if isinstance(data, dict) else None
def _ask_query(query: str) -> dict[str, Any] | None:
return _fetch_api_json({
'action': 'ask',
'query': query,
'format': 'json',
})
def _search_query(search_text: str, limit: int) -> dict[str, Any] | None:
return _fetch_api_json({
'action': 'query',
'list': 'search',
'srsearch': search_text,
'srlimit': str(limit),
'format': 'json',
})
def _to_float_list(values: Any) -> list[float]:
if not isinstance(values, list):
return []
out: list[float] = []
for value in values:
try:
out.append(float(value))
except (TypeError, ValueError):
continue
return out
def _to_text_list(values: Any) -> list[str]:
if not isinstance(values, list):
return []
out: list[str] = []
for value in values:
text = str(value or '').strip()
if text:
out.append(text)
return out
def _normalize_modes(values: list[str]) -> list[str]:
out: list[str] = []
for value in values:
for token in str(value).replace('/', ',').split(','):
mode = token.strip().upper()
if mode and mode not in out:
out.append(mode)
return out
def _extract_matches_from_ask(data: dict[str, Any]) -> list[dict[str, Any]]:
results = data.get('query', {}).get('results', {})
if not isinstance(results, dict):
return []
matches: list[dict[str, Any]] = []
for title, entry in results.items():
if not isinstance(entry, dict):
continue
printouts = entry.get('printouts', {})
if not isinstance(printouts, dict):
printouts = {}
frequencies_hz = _to_float_list(printouts.get('Frequencies'))
frequencies_mhz = [round(v / 1e6, 6) for v in frequencies_hz if v > 0]
modes = _normalize_modes(_to_text_list(printouts.get('Mode')))
modulations = _normalize_modes(_to_text_list(printouts.get('Modulation')))
match = {
'title': str(entry.get('fulltext') or title),
'url': str(entry.get('fullurl') or ''),
'frequencies_mhz': frequencies_mhz,
'modes': modes,
'modulations': modulations,
'source': 'SigID Wiki',
}
matches.append(match)
return matches
def _dedupe_matches(matches: list[dict[str, Any]]) -> list[dict[str, Any]]:
deduped: dict[str, dict[str, Any]] = {}
for match in matches:
key = f"{match.get('title', '')}|{match.get('url', '')}"
if key not in deduped:
deduped[key] = match
continue
# Merge frequencies/modes/modulations from duplicates.
existing = deduped[key]
for field in ('frequencies_mhz', 'modes', 'modulations'):
base = existing.get(field, [])
extra = match.get(field, [])
if not isinstance(base, list):
base = []
if not isinstance(extra, list):
extra = []
merged = list(base)
for item in extra:
if item not in merged:
merged.append(item)
existing[field] = merged
return list(deduped.values())
def _rank_matches(
matches: list[dict[str, Any]],
*,
frequency_mhz: float,
modulation: str,
) -> list[dict[str, Any]]:
target_hz = frequency_mhz * 1e6
wanted_mod = str(modulation or '').strip().upper()
def score(match: dict[str, Any]) -> tuple[int, float, str]:
score_value = 0
freqs_mhz = match.get('frequencies_mhz') or []
distances_hz: list[float] = []
for f_mhz in freqs_mhz:
try:
distances_hz.append(abs((float(f_mhz) * 1e6) - target_hz))
except (TypeError, ValueError):
continue
min_distance_hz = min(distances_hz) if distances_hz else 1e12
if min_distance_hz <= 100:
score_value += 120
elif min_distance_hz <= 1_000:
score_value += 90
elif min_distance_hz <= 10_000:
score_value += 70
elif min_distance_hz <= 100_000:
score_value += 40
if wanted_mod:
modes = [str(v).upper() for v in (match.get('modes') or [])]
modulations = [str(v).upper() for v in (match.get('modulations') or [])]
if wanted_mod in modes:
score_value += 25
if wanted_mod in modulations:
score_value += 25
title = str(match.get('title') or '')
title_lower = title.lower()
if 'unidentified' in title_lower or 'unknown' in title_lower:
score_value -= 10
return (score_value, min_distance_hz, title.lower())
ranked = sorted(matches, key=score, reverse=True)
for match in ranked:
try:
nearest = min(abs((float(f) * 1e6) - target_hz) for f in (match.get('frequencies_mhz') or []))
match['distance_hz'] = int(round(nearest))
except Exception:
match['distance_hz'] = None
return ranked
def _format_freq_variants_mhz(freq_mhz: float) -> list[str]:
variants = [
f'{freq_mhz:.6f}'.rstrip('0').rstrip('.'),
f'{freq_mhz:.4f}'.rstrip('0').rstrip('.'),
f'{freq_mhz:.3f}'.rstrip('0').rstrip('.'),
]
out: list[str] = []
for value in variants:
if value and value not in out:
out.append(value)
return out
def _lookup_sigidwiki_matches(frequency_mhz: float, modulation: str, limit: int) -> dict[str, Any]:
all_matches: list[dict[str, Any]] = []
exact_queries: list[str] = []
for freq_token in _format_freq_variants_mhz(frequency_mhz):
query = (
f'[[Category:Signal]][[Frequencies::{freq_token} MHz]]'
f'|?Frequencies|?Mode|?Modulation|limit={max(10, limit * 2)}'
)
exact_queries.append(query)
data = _ask_query(query)
if data:
all_matches.extend(_extract_matches_from_ask(data))
if all_matches:
break
search_used = False
if not all_matches:
search_used = True
search_terms = [f'{frequency_mhz:.4f} MHz']
if modulation:
search_terms.insert(0, f'{frequency_mhz:.4f} MHz {modulation.upper()}')
seen_titles: set[str] = set()
for term in search_terms:
search_data = _search_query(term, max(5, min(limit * 2, 10)))
search_results = search_data.get('query', {}).get('search', []) if isinstance(search_data, dict) else []
if not isinstance(search_results, list) or not search_results:
continue
for item in search_results:
title = str(item.get('title') or '').strip()
if not title or title in seen_titles:
continue
seen_titles.add(title)
page_query = f'[[{title}]]|?Frequencies|?Mode|?Modulation|limit=1'
page_data = _ask_query(page_query)
if page_data:
all_matches.extend(_extract_matches_from_ask(page_data))
if len(all_matches) >= max(limit * 3, 12):
break
if all_matches:
break
deduped = _dedupe_matches(all_matches)
ranked = _rank_matches(deduped, frequency_mhz=frequency_mhz, modulation=modulation)
return {
'matches': ranked[:limit],
'search_used': search_used,
'exact_queries': exact_queries,
}
@signalid_bp.route('/sigidwiki', methods=['POST'])
def sigidwiki_lookup() -> Response:
"""Lookup likely signal types from SigID Wiki by tuned frequency."""
payload = request.get_json(silent=True) or {}
freq_raw = payload.get('frequency_mhz')
if freq_raw is None:
return jsonify({'status': 'error', 'message': 'frequency_mhz is required'}), 400
try:
frequency_mhz = float(freq_raw)
except (TypeError, ValueError):
return jsonify({'status': 'error', 'message': 'Invalid frequency_mhz'}), 400
if frequency_mhz <= 0:
return jsonify({'status': 'error', 'message': 'frequency_mhz must be positive'}), 400
modulation = str(payload.get('modulation') or '').strip().upper()
if modulation and len(modulation) > 16:
modulation = modulation[:16]
limit_raw = payload.get('limit', 8)
try:
limit = int(limit_raw)
except (TypeError, ValueError):
limit = 8
limit = max(1, min(limit, 20))
cache_key = f'{round(frequency_mhz, 6)}|{modulation}|{limit}'
cached = _cache_get(cache_key)
if cached is not None:
return jsonify({
'status': 'ok',
'source': 'sigidwiki',
'frequency_mhz': round(frequency_mhz, 6),
'modulation': modulation or None,
'cached': True,
**cached,
})
try:
lookup = _lookup_sigidwiki_matches(frequency_mhz, modulation, limit)
except Exception as exc:
logger.error('SigID lookup failed: %s', exc)
return jsonify({'status': 'error', 'message': 'SigID lookup failed'}), 502
response_payload = {
'matches': lookup.get('matches', []),
'match_count': len(lookup.get('matches', [])),
'search_used': bool(lookup.get('search_used')),
'exact_queries': lookup.get('exact_queries', []),
}
_cache_set(cache_key, response_payload)
return jsonify({
'status': 'ok',
'source': 'sigidwiki',
'frequency_mhz': round(frequency_mhz, 6),
'modulation': modulation or None,
'cached': False,
**response_payload,
})
+5
View File
@@ -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
View File
@@ -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")
+2 -121
View File
@@ -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
+86
View File
@@ -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;
+2 -4
View File
@@ -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 */
+20
View File
@@ -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 {
-500
View File
@@ -1,500 +0,0 @@
/* Analytics Dashboard Styles */
/* Analytics is a sidebar-only mode — hide the output panel and expand the sidebar */
@media (min-width: 1024px) {
.main-content.analytics-active {
grid-template-columns: 1fr !important;
}
.main-content.analytics-active > .output-panel {
display: none !important;
}
.main-content.analytics-active > .sidebar {
max-width: 100% !important;
width: 100% !important;
}
.main-content.analytics-active .sidebar-collapse-btn {
display: none !important;
}
}
@media (max-width: 1023px) {
.main-content.analytics-active > .output-panel {
display: none !important;
}
}
.analytics-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--space-3, 12px);
margin-bottom: var(--space-4, 16px);
}
.analytics-insight-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: var(--space-3, 12px);
}
.analytics-insight-card {
background: var(--bg-card, #151f2b);
border: 1px solid var(--border-color, #1e2d3d);
border-radius: var(--radius-md, 8px);
padding: 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.analytics-insight-card.low {
border-color: rgba(90, 106, 122, 0.5);
}
.analytics-insight-card.medium {
border-color: rgba(74, 163, 255, 0.45);
}
.analytics-insight-card.high {
border-color: rgba(214, 168, 94, 0.55);
}
.analytics-insight-card.critical {
border-color: rgba(226, 93, 93, 0.65);
}
.analytics-insight-card .insight-title {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-dim, #5a6a7a);
}
.analytics-insight-card .insight-value {
font-size: 16px;
font-weight: 700;
color: var(--text-primary, #e0e6ed);
line-height: 1.2;
}
.analytics-insight-card .insight-label {
font-size: 10px;
color: var(--text-secondary, #9aabba);
}
.analytics-insight-card .insight-detail {
font-size: 10px;
color: var(--text-dim, #5a6a7a);
}
.analytics-top-changes {
margin-top: 12px;
}
.analytics-change-row {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 0;
border-bottom: 1px solid var(--border-color, #1e2d3d);
font-size: 10px;
}
.analytics-change-row:last-child {
border-bottom: none;
}
.analytics-change-row .mode {
min-width: 84px;
color: var(--text-primary, #e0e6ed);
font-weight: 600;
}
.analytics-change-row .delta {
min-width: 48px;
font-family: var(--font-mono, monospace);
}
.analytics-change-row .delta.up {
color: var(--accent-green, #38c180);
}
.analytics-change-row .delta.down {
color: var(--accent-red, #e25d5d);
}
.analytics-change-row .delta.flat {
color: var(--text-dim, #5a6a7a);
}
.analytics-change-row .avg {
color: var(--text-dim, #5a6a7a);
}
.analytics-card {
background: var(--bg-card, #151f2b);
border: 1px solid var(--border-color, #1e2d3d);
border-radius: var(--radius-md, 8px);
padding: var(--space-3, 12px);
text-align: center;
transition: var(--transition-fast, 150ms ease);
}
.analytics-card:hover {
border-color: var(--accent-cyan, #4aa3ff);
}
.analytics-card .card-count {
font-size: var(--text-2xl, 24px);
font-weight: 700;
color: var(--text-primary, #e0e6ed);
line-height: 1.2;
}
.analytics-card .card-label {
font-size: var(--text-xs, 10px);
color: var(--text-dim, #5a6a7a);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: var(--space-1, 4px);
}
.analytics-card .card-sparkline {
height: 24px;
margin-top: var(--space-2, 8px);
}
.analytics-card .card-sparkline svg {
width: 100%;
height: 100%;
}
.analytics-card .card-sparkline polyline {
fill: none;
stroke: var(--accent-cyan, #4aa3ff);
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Health indicators */
.analytics-health {
display: flex;
flex-wrap: wrap;
gap: var(--space-2, 8px);
margin-bottom: var(--space-4, 16px);
}
.health-item {
display: flex;
align-items: center;
gap: var(--space-1, 4px);
font-size: var(--text-xs, 10px);
color: var(--text-dim, #5a6a7a);
text-transform: uppercase;
}
.health-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-red, #e25d5d);
}
.health-dot.running {
background: var(--accent-green, #38c180);
}
/* Emergency squawk panel */
.squawk-emergency {
background: rgba(226, 93, 93, 0.1);
border: 1px solid var(--accent-red, #e25d5d);
border-radius: var(--radius-md, 8px);
padding: var(--space-3, 12px);
margin-bottom: var(--space-3, 12px);
}
.squawk-emergency .squawk-title {
color: var(--accent-red, #e25d5d);
font-weight: 700;
font-size: var(--text-sm, 12px);
text-transform: uppercase;
margin-bottom: var(--space-2, 8px);
}
.squawk-emergency .squawk-item {
font-size: var(--text-sm, 12px);
color: var(--text-primary, #e0e6ed);
padding: var(--space-1, 4px) 0;
border-bottom: 1px solid rgba(226, 93, 93, 0.2);
}
.squawk-emergency .squawk-item:last-child {
border-bottom: none;
}
/* Alert feed */
.analytics-alert-feed {
max-height: 200px;
overflow-y: auto;
margin-bottom: var(--space-4, 16px);
}
.analytics-alert-item {
display: flex;
align-items: flex-start;
gap: var(--space-2, 8px);
padding: var(--space-2, 8px);
border-bottom: 1px solid var(--border-color, #1e2d3d);
font-size: var(--text-xs, 10px);
}
.analytics-alert-item .alert-severity {
padding: 1px 6px;
border-radius: var(--radius-sm, 4px);
font-weight: 600;
text-transform: uppercase;
font-size: 9px;
white-space: nowrap;
}
.alert-severity.critical { background: var(--accent-red, #e25d5d); color: #fff; }
.alert-severity.high { background: var(--accent-orange, #d6a85e); color: #000; }
.alert-severity.medium { background: var(--accent-cyan, #4aa3ff); color: #fff; }
.alert-severity.low { background: var(--border-color, #1e2d3d); color: var(--text-dim, #5a6a7a); }
/* Correlation panel */
.analytics-correlation-pair {
display: flex;
align-items: center;
gap: var(--space-2, 8px);
padding: var(--space-2, 8px);
border-bottom: 1px solid var(--border-color, #1e2d3d);
font-size: var(--text-xs, 10px);
}
.analytics-correlation-pair .confidence-bar {
height: 4px;
background: var(--bg-secondary, #101823);
border-radius: 2px;
flex: 1;
max-width: 60px;
}
.analytics-correlation-pair .confidence-fill {
height: 100%;
background: var(--accent-green, #38c180);
border-radius: 2px;
}
.analytics-pattern-item {
padding: 8px;
border-bottom: 1px solid var(--border-color, #1e2d3d);
display: flex;
flex-direction: column;
gap: 4px;
}
.analytics-pattern-item:last-child {
border-bottom: none;
}
.analytics-pattern-item .pattern-main {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.analytics-pattern-item .pattern-mode {
font-size: 10px;
font-weight: 600;
color: var(--text-primary, #e0e6ed);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.analytics-pattern-item .pattern-device {
font-size: 10px;
color: var(--text-dim, #5a6a7a);
font-family: var(--font-mono, monospace);
}
.analytics-pattern-item .pattern-meta {
display: flex;
gap: 10px;
font-size: 10px;
color: var(--text-dim, #5a6a7a);
flex-wrap: wrap;
}
.analytics-pattern-item .pattern-confidence {
color: var(--accent-green, #38c180);
font-weight: 600;
}
/* Geofence zone list */
.geofence-zone-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2, 8px);
border-bottom: 1px solid var(--border-color, #1e2d3d);
font-size: var(--text-xs, 10px);
}
.geofence-zone-item .zone-name {
font-weight: 600;
color: var(--text-primary, #e0e6ed);
}
.geofence-zone-item .zone-radius {
color: var(--text-dim, #5a6a7a);
}
.geofence-zone-item .zone-delete {
cursor: pointer;
color: var(--accent-red, #e25d5d);
padding: 2px 6px;
border: 1px solid var(--accent-red, #e25d5d);
border-radius: var(--radius-sm, 4px);
background: transparent;
font-size: 9px;
}
/* Export controls */
.export-controls {
display: flex;
gap: var(--space-2, 8px);
align-items: center;
flex-wrap: wrap;
}
.export-controls select,
.export-controls button {
font-size: var(--text-xs, 10px);
padding: var(--space-1, 4px) var(--space-2, 8px);
background: var(--bg-card, #151f2b);
color: var(--text-primary, #e0e6ed);
border: 1px solid var(--border-color, #1e2d3d);
border-radius: var(--radius-sm, 4px);
}
.export-controls button {
cursor: pointer;
background: var(--accent-cyan, #4aa3ff);
color: #fff;
border-color: var(--accent-cyan, #4aa3ff);
}
.export-controls button:hover {
opacity: 0.9;
}
/* Section headers */
.analytics-section-header {
font-size: var(--text-xs, 10px);
font-weight: 600;
color: var(--text-dim, #5a6a7a);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-2, 8px);
padding-bottom: var(--space-1, 4px);
border-bottom: 1px solid var(--border-color, #1e2d3d);
}
/* Empty state */
.analytics-empty {
text-align: center;
color: var(--text-dim, #5a6a7a);
font-size: var(--text-xs, 10px);
padding: var(--space-4, 16px);
font-style: italic;
}
.analytics-target-toolbar,
.analytics-replay-toolbar {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 10px;
}
.analytics-target-toolbar input {
flex: 1;
min-width: 220px;
background: var(--bg-card, #151f2b);
color: var(--text-primary, #e0e6ed);
border: 1px solid var(--border-color, #1e2d3d);
border-radius: 4px;
padding: 6px 8px;
font-size: 11px;
}
.analytics-target-toolbar button,
.analytics-replay-toolbar button,
.analytics-replay-toolbar select {
font-size: 10px;
padding: 5px 9px;
border-radius: 4px;
border: 1px solid var(--border-color, #1e2d3d);
background: var(--bg-card, #151f2b);
color: var(--text-primary, #e0e6ed);
}
.analytics-target-toolbar button,
.analytics-replay-toolbar button {
cursor: pointer;
background: rgba(74, 163, 255, 0.2);
border-color: rgba(74, 163, 255, 0.45);
}
.analytics-target-summary {
font-size: 10px;
color: var(--text-dim, #5a6a7a);
margin-bottom: 8px;
}
.analytics-target-item,
.analytics-replay-item {
border-bottom: 1px solid var(--border-color, #1e2d3d);
padding: 7px 0;
display: grid;
gap: 4px;
}
.analytics-target-item:last-child,
.analytics-replay-item:last-child {
border-bottom: none;
}
.analytics-target-item .title,
.analytics-replay-item .title {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 11px;
color: var(--text-primary, #e0e6ed);
font-weight: 600;
}
.analytics-target-item .mode,
.analytics-replay-item .mode {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.05em;
border: 1px solid rgba(74, 163, 255, 0.35);
color: var(--accent-cyan, #4aa3ff);
border-radius: 4px;
padding: 1px 6px;
}
.analytics-target-item .meta,
.analytics-replay-item .meta {
font-size: 10px;
color: var(--text-dim, #5a6a7a);
display: flex;
gap: 10px;
flex-wrap: wrap;
}
+71 -3
View File
@@ -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;
}
}
+56 -5
View File
@@ -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

+21
View File
@@ -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

+2 -2
View File
@@ -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';
+225 -78
View File
@@ -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;
});
});
} }
/** /**
-13
View File
@@ -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
View File
@@ -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();
} }
+74
View File
@@ -0,0 +1,74 @@
/* INTERCEPT Per-Mode Cheat Sheets */
const CheatSheets = (function () {
'use strict';
const CONTENT = {
pager: { title: 'Pager Decoder', icon: '📟', hardware: 'RTL-SDR dongle', description: 'Decodes POCSAG and FLEX pager protocols via rtl_fm + multimon-ng.', whatToExpect: 'Numeric and alphanumeric pager messages with address codes.', tips: ['Try frequencies 152.240, 157.450, 462.9625 MHz', 'Gain 3845 dB works well for most dongles', 'POCSAG 512/1200/2400 baud are common'] },
sensor: { title: '433MHz Sensors', icon: '🌡️', hardware: 'RTL-SDR dongle', description: 'Decodes 433MHz IoT sensors via rtl_433.', whatToExpect: 'JSON events from weather stations, door sensors, car key fobs.', tips: ['Leave gain on AUTO', 'Walk around to discover hidden sensors', 'Protocol filter narrows false positives'] },
wifi: { title: 'WiFi Scanner', icon: '📡', hardware: 'WiFi adapter (monitor mode)', description: 'Scans WiFi networks and clients via airodump-ng or nmcli.', whatToExpect: 'SSIDs, BSSIDs, channel, signal strength, encryption type.', tips: ['Run airmon-ng check kill before monitoring', 'Proximity radar shows signal strength', 'TSCM baseline detects rogue APs'] },
bluetooth: { title: 'Bluetooth Scanner', icon: '🔵', hardware: 'Built-in or USB Bluetooth adapter', description: 'Scans BLE and classic Bluetooth devices. Identifies trackers.', whatToExpect: 'Device names, MACs, RSSI, manufacturer, tracker type.', tips: ['Proximity radar shows device distance', 'Known tracker DB has 47K+ fingerprints', 'Use BT Locate to physically find a tracker'] },
bt_locate: { title: 'BT Locate (SAR)', icon: '🎯', hardware: 'Bluetooth adapter + optional GPS', description: 'SAR Bluetooth locator. Tracks RSSI over time to triangulate position.', whatToExpect: 'RSSI chart, proximity band (IMMEDIATE/NEAR/FAR), GPS trail.', tips: ['Handoff from Bluetooth mode to lock onto a device', 'Indoor n=3.0 gives better distance estimates', 'Follow the heat trail toward stronger signal'] },
meshtastic: { title: 'Meshtastic', icon: '🕸️', hardware: 'Meshtastic LoRa node (USB)', description: 'Monitors Meshtastic LoRa mesh network messages and positions.', whatToExpect: 'Text messages, node map, telemetry.', tips: ['Default channel must match your mesh', 'Long-Fast has best range', 'GPS nodes appear on map automatically'] },
adsb: { title: 'ADS-B Aircraft', icon: '✈️', hardware: 'RTL-SDR + 1090MHz antenna', description: 'Tracks aircraft via ADS-B Mode S transponders using dump1090.', whatToExpect: 'Flight numbers, positions, altitude, speed, squawk codes.', tips: ['1090MHz — use a dedicated antenna', 'Emergency squawks: 7500 hijack, 7600 radio fail, 7700 emergency', 'Full Dashboard shows map view'] },
ais: { title: 'AIS Vessels', icon: '🚢', hardware: 'RTL-SDR + VHF antenna (162 MHz)', description: 'Tracks marine vessels via AIS using AIS-catcher.', whatToExpect: 'MMSI, vessel names, positions, speed, heading, cargo type.', tips: ['VHF antenna centered at 162MHz works best', 'DSC distress alerts appear in red', 'Coastline range ~40 nautical miles'] },
aprs: { title: 'APRS', icon: '📻', hardware: 'RTL-SDR + VHF + direwolf', description: 'Decodes APRS amateur packet radio via direwolf TNC modem.', whatToExpect: 'Station positions, weather reports, messages, telemetry.', tips: ['Primary APRS frequency: 144.390 MHz (North America)', 'direwolf must be running', 'Positions appear on the map'] },
satellite: { title: 'Satellite Tracker', icon: '🛰️', hardware: 'None (pass prediction only)', description: 'Predicts satellite pass times using TLE data from CelesTrak.', whatToExpect: 'Pass windows with AOS/LOS times, max elevation, bearing.', tips: ['Set observer location in Settings', 'Plan ISS SSTV using pass times', 'TLEs auto-update every 24 hours'] },
sstv: { title: 'ISS SSTV', icon: '🖼️', hardware: 'RTL-SDR + 145MHz antenna', description: 'Receives ISS SSTV images via slowrx.', whatToExpect: 'Color images during ISS SSTV events (PD180 mode).', tips: ['ISS SSTV: 145.800 MHz', 'Check ARISS for active event dates', 'ISS must be overhead — check pass times'] },
weathersat: { title: 'Weather Satellites', icon: '🌤️', hardware: 'RTL-SDR + 137MHz turnstile/QFH antenna', description: 'Decodes NOAA APT and Meteor LRPT weather imagery via SatDump.', whatToExpect: 'Infrared/visible cloud imagery.', tips: ['NOAA 15/18/19: 137.1137.9 MHz APT', 'Meteor M2-3: 137.9 MHz LRPT', 'Use circular polarized antenna (QFH or turnstile)'] },
sstv_general:{ title: 'HF SSTV', icon: '📷', hardware: 'RTL-SDR + HF upconverter', description: 'Receives HF SSTV transmissions.', whatToExpect: 'Amateur radio images on 14.230 MHz (USB mode).', tips: ['14.230 MHz USB is primary HF SSTV frequency', 'Scottie 1 and Martin 1 most common', 'Best during daylight hours'] },
gps: { title: 'GPS Receiver', icon: '🗺️', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction', 'Verify a 3D fix before relying on altitude'] },
spaceweather:{ title: 'Space Weather', icon: '☀️', hardware: 'None (NOAA/SpaceWeatherLive data)', description: 'Monitors solar activity and geomagnetic storm indices.', whatToExpect: 'Kp index, solar flux, X-ray flare alerts, CME tracking.', tips: ['High Kp (≥5) = geomagnetic storm', 'X-class flares cause HF radio blackouts', 'Check before HF or satellite operations'] },
tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] },
spystations: { title: 'Spy Stations', icon: '🕵️', hardware: 'RTL-SDR + HF antenna', description: 'Database of known number stations, military, and diplomatic HF signals.', whatToExpect: 'Scheduled broadcasts, frequency database, tune-to links.', tips: ['Numbers stations often broadcast on the hour', 'Use Spectrum Waterfall to tune directly', 'STANAG and HF mil signals are common'] },
websdr: { title: 'WebSDR', icon: '🌐', hardware: 'None (uses remote SDR servers)', description: 'Access remote WebSDR receivers worldwide for HF shortwave listening.', whatToExpect: 'Live audio from global HF receivers, waterfall display.', tips: ['websdr.org lists available servers', 'Good for HF when local antenna is lacking', 'Use in-app player for seamless experience'] },
subghz: { title: 'SubGHz Transceiver', icon: '📡', hardware: 'HackRF One', description: 'Transmit and receive sub-GHz RF signals for IoT and industrial protocols.', whatToExpect: 'Raw signal capture, replay, and protocol analysis.', tips: ['Only use on licensed frequencies', 'Capture mode records raw IQ for replay', 'Common: garage doors, keyfobs, 315/433/868/915 MHz'] },
rtlamr: { title: 'Utility Meter Reader', icon: '⚡', hardware: 'RTL-SDR dongle', description: 'Reads AMI/AMR smart utility meter broadcasts via rtlamr.', whatToExpect: 'Meter IDs, consumption readings, interval data.', tips: ['Most meters broadcast on 915 MHz', 'MSG types 5, 7, 13, 21 most common', 'Consumption data is read-only public broadcast'] },
waterfall: { title: 'Spectrum Waterfall', icon: '🌊', hardware: 'RTL-SDR or HackRF (WebSocket)', description: 'Full-screen real-time FFT spectrum waterfall display.', whatToExpect: 'Color-coded signal intensity scrolling over time.', tips: ['Turbo palette has best contrast for weak signals', 'Peak hold shows max power in red', 'Hover over waterfall to see frequency'] },
};
function show(mode) {
const data = CONTENT[mode];
const modal = document.getElementById('cheatSheetModal');
const content = document.getElementById('cheatSheetContent');
if (!modal || !content) return;
if (!data) {
content.innerHTML = `<p style="color:var(--text-dim); font-family:var(--font-mono);">No cheat sheet for: ${mode}</p>`;
} else {
content.innerHTML = `
<div style="font-family:var(--font-mono, monospace);">
<div style="font-size:24px; margin-bottom:4px;">${data.icon}</div>
<h2 style="margin:0 0 8px; font-size:16px; color:var(--accent-cyan, #4aa3ff);">${data.title}</h2>
<div style="font-size:11px; color:var(--text-dim); margin-bottom:12px; border-bottom:1px solid rgba(255,255,255,0.08); padding-bottom:8px;">
Hardware: <span style="color:var(--text-secondary);">${data.hardware}</span>
</div>
<p style="font-size:12px; color:var(--text-secondary); margin:0 0 12px;">${data.description}</p>
<div style="margin-bottom:12px;">
<div style="font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.08em; color:var(--text-dim); margin-bottom:4px;">What to expect</div>
<p style="font-size:12px; color:var(--text-secondary); margin:0;">${data.whatToExpect}</p>
</div>
<div>
<div style="font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.08em; color:var(--text-dim); margin-bottom:6px;">Tips</div>
<ul style="margin:0; padding-left:16px; display:flex; flex-direction:column; gap:4px;">
${data.tips.map(t => `<li style="font-size:11px; color:var(--text-secondary);">${t}</li>`).join('')}
</ul>
</div>
</div>`;
}
modal.style.display = 'flex';
}
function hide() {
const modal = document.getElementById('cheatSheetModal');
if (modal) modal.style.display = 'none';
}
function showForCurrentMode() {
const mode = document.body.getAttribute('data-mode');
if (mode) show(mode);
}
return { show, hide, showForCurrentMode };
})();
window.CheatSheets = CheatSheets;
+29 -5
View File
@@ -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',
+6 -3
View File
@@ -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);
+12
View File
@@ -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;
} }
+72
View File
@@ -0,0 +1,72 @@
/* INTERCEPT Keyboard Shortcuts — global hotkey handler + help modal */
const KeyboardShortcuts = (function () {
'use strict';
const GUARD_SELECTOR = 'input, textarea, select, [contenteditable], .CodeMirror *';
let _handler = null;
function _handle(e) {
if (e.target.matches(GUARD_SELECTOR)) return;
if (e.altKey) {
switch (e.code) {
case 'KeyW': e.preventDefault(); window.switchMode && switchMode('waterfall'); break;
case 'KeyM': e.preventDefault(); window.VoiceAlerts && VoiceAlerts.toggleMute(); break;
case 'KeyS': e.preventDefault(); _toggleSidebar(); break;
case 'KeyK': e.preventDefault(); showHelp(); break;
case 'KeyC': e.preventDefault(); window.CheatSheets && CheatSheets.showForCurrentMode(); break;
default:
if (e.code >= 'Digit1' && e.code <= 'Digit9') {
e.preventDefault();
_switchToNthMode(parseInt(e.code.replace('Digit', '')) - 1);
}
}
} else if (!e.ctrlKey && !e.metaKey) {
if (e.key === '?') { showHelp(); }
if (e.key === 'Escape') {
const kbModal = document.getElementById('kbShortcutsModal');
if (kbModal && kbModal.style.display !== 'none') { hideHelp(); return; }
const csModal = document.getElementById('cheatSheetModal');
if (csModal && csModal.style.display !== 'none') {
window.CheatSheets && CheatSheets.hide(); return;
}
}
}
}
function _toggleSidebar() {
const mc = document.querySelector('.main-content');
if (mc) mc.classList.toggle('sidebar-collapsed');
}
function _switchToNthMode(n) {
if (!window.interceptModeCatalog) return;
const mode = document.body.getAttribute('data-mode');
if (!mode) return;
const catalog = window.interceptModeCatalog;
const entry = catalog[mode];
if (!entry) return;
const groupModes = Object.keys(catalog).filter(k => catalog[k].group === entry.group);
if (groupModes[n]) window.switchMode && switchMode(groupModes[n]);
}
function showHelp() {
const modal = document.getElementById('kbShortcutsModal');
if (modal) modal.style.display = 'flex';
}
function hideHelp() {
const modal = document.getElementById('kbShortcutsModal');
if (modal) modal.style.display = 'none';
}
function init() {
if (_handler) document.removeEventListener('keydown', _handler);
_handler = _handle;
document.addEventListener('keydown', _handler);
}
return { init, showHelp, hideHelp };
})();
window.KeyboardShortcuts = KeyboardShortcuts;
+1 -7
View File
@@ -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) {
+1 -3
View File
@@ -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';
} }
+72 -23
View File
@@ -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
*/ */
+255
View File
@@ -0,0 +1,255 @@
/* INTERCEPT Voice Alerts — Web Speech API queue with priority system */
const VoiceAlerts = (function () {
'use strict';
const PRIORITY = { LOW: 0, MEDIUM: 1, HIGH: 2 };
let _enabled = true;
let _muted = false;
let _queue = [];
let _speaking = false;
let _sources = {};
const STORAGE_KEY = 'intercept-voice-muted';
const CONFIG_KEY = 'intercept-voice-config';
const RATE_MIN = 0.5;
const RATE_MAX = 2.0;
const PITCH_MIN = 0.5;
const PITCH_MAX = 2.0;
// Default config
let _config = {
rate: 1.1,
pitch: 0.9,
voiceName: '',
streams: { pager: true, tscm: true, bluetooth: true },
};
function _toNumberInRange(value, fallback, min, max) {
const n = Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, n));
}
function _normalizeConfig() {
_config.rate = _toNumberInRange(_config.rate, 1.1, RATE_MIN, RATE_MAX);
_config.pitch = _toNumberInRange(_config.pitch, 0.9, PITCH_MIN, PITCH_MAX);
_config.voiceName = typeof _config.voiceName === 'string' ? _config.voiceName : '';
}
function _isSpeechSupported() {
return !!(window.speechSynthesis && typeof window.SpeechSynthesisUtterance !== 'undefined');
}
function _showVoiceToast(title, message, type) {
if (typeof window.showAppToast === 'function') {
window.showAppToast(title, message, type || 'warning');
}
}
function _loadConfig() {
_muted = localStorage.getItem(STORAGE_KEY) === 'true';
try {
const stored = localStorage.getItem(CONFIG_KEY);
if (stored) {
const parsed = JSON.parse(stored);
_config.rate = parsed.rate ?? _config.rate;
_config.pitch = parsed.pitch ?? _config.pitch;
_config.voiceName = parsed.voiceName ?? _config.voiceName;
if (parsed.streams) {
Object.assign(_config.streams, parsed.streams);
}
}
} catch (_) {}
_normalizeConfig();
_updateMuteButton();
}
function _updateMuteButton() {
const btn = document.getElementById('voiceMuteBtn');
if (!btn) return;
btn.classList.toggle('voice-muted', _muted);
btn.title = _muted ? 'Unmute voice alerts' : 'Mute voice alerts';
btn.style.opacity = _muted ? '0.4' : '1';
}
function _getVoice() {
if (!_config.voiceName) return null;
const voices = window.speechSynthesis ? speechSynthesis.getVoices() : [];
return voices.find(v => v.name === _config.voiceName) || null;
}
function _createUtterance(text) {
const utt = new SpeechSynthesisUtterance(text);
utt.rate = _toNumberInRange(_config.rate, 1.1, RATE_MIN, RATE_MAX);
utt.pitch = _toNumberInRange(_config.pitch, 0.9, PITCH_MIN, PITCH_MAX);
const voice = _getVoice();
if (voice) utt.voice = voice;
return utt;
}
function speak(text, priority) {
if (priority === undefined) priority = PRIORITY.MEDIUM;
if (!_enabled || _muted) return;
if (!window.speechSynthesis) return;
if (priority === PRIORITY.LOW && _speaking) return;
if (priority === PRIORITY.HIGH && _speaking) {
window.speechSynthesis.cancel();
_queue = [];
_speaking = false;
}
_queue.push({ text, priority });
if (!_speaking) _dequeue();
}
function _dequeue() {
if (_queue.length === 0) { _speaking = false; return; }
_speaking = true;
const item = _queue.shift();
const utt = _createUtterance(item.text);
utt.onend = () => { _speaking = false; _dequeue(); };
utt.onerror = () => { _speaking = false; _dequeue(); };
window.speechSynthesis.speak(utt);
}
function toggleMute() {
_muted = !_muted;
localStorage.setItem(STORAGE_KEY, _muted ? 'true' : 'false');
_updateMuteButton();
if (_muted && window.speechSynthesis) window.speechSynthesis.cancel();
}
function _openStream(url, handler, key) {
if (_sources[key]) return;
const es = new EventSource(url);
es.onmessage = handler;
es.onerror = () => { es.close(); delete _sources[key]; };
_sources[key] = es;
}
function _startStreams() {
if (!_enabled) return;
// Pager stream
if (_config.streams.pager) {
_openStream('/stream', (ev) => {
try {
const d = JSON.parse(ev.data);
if (d.address && d.message) {
speak(`Pager message to ${d.address}: ${String(d.message).slice(0, 60)}`, PRIORITY.MEDIUM);
}
} catch (_) {}
}, 'pager');
}
// TSCM stream
if (_config.streams.tscm) {
_openStream('/tscm/sweep/stream', (ev) => {
try {
const d = JSON.parse(ev.data);
if (d.threat_level && d.description) {
speak(`TSCM alert: ${d.threat_level}${d.description}`, PRIORITY.HIGH);
}
} catch (_) {}
}, 'tscm');
}
// Bluetooth stream — tracker detection only
if (_config.streams.bluetooth) {
_openStream('/api/bluetooth/stream', (ev) => {
try {
const d = JSON.parse(ev.data);
if (d.service_data && d.service_data.tracker_type) {
speak(`Tracker detected: ${d.service_data.tracker_type}`, PRIORITY.HIGH);
}
} catch (_) {}
}, 'bluetooth');
}
}
function _stopStreams() {
Object.values(_sources).forEach(es => { try { es.close(); } catch (_) {} });
_sources = {};
}
function init() {
_loadConfig();
if (_isSpeechSupported()) {
// Prime voices list early so user-triggered test calls are less likely to be silent.
speechSynthesis.getVoices();
}
_startStreams();
}
function setEnabled(val) {
_enabled = val;
if (!val) {
_stopStreams();
if (window.speechSynthesis) window.speechSynthesis.cancel();
} else {
_startStreams();
}
}
// ── Config API (used by Ops Center voice config panel) ─────────────
function getConfig() {
return JSON.parse(JSON.stringify(_config));
}
function setConfig(cfg) {
if (cfg.rate !== undefined) _config.rate = _toNumberInRange(cfg.rate, _config.rate, RATE_MIN, RATE_MAX);
if (cfg.pitch !== undefined) _config.pitch = _toNumberInRange(cfg.pitch, _config.pitch, PITCH_MIN, PITCH_MAX);
if (cfg.voiceName !== undefined) _config.voiceName = cfg.voiceName;
if (cfg.streams) Object.assign(_config.streams, cfg.streams);
_normalizeConfig();
localStorage.setItem(CONFIG_KEY, JSON.stringify(_config));
// Restart streams to apply per-stream toggle changes
_stopStreams();
_startStreams();
}
function getAvailableVoices() {
return new Promise(resolve => {
if (!window.speechSynthesis) { resolve([]); return; }
let voices = speechSynthesis.getVoices();
if (voices.length > 0) { resolve(voices); return; }
speechSynthesis.onvoiceschanged = () => {
resolve(speechSynthesis.getVoices());
};
// Timeout fallback
setTimeout(() => resolve(speechSynthesis.getVoices()), 500);
});
}
function testVoice(text) {
if (!_isSpeechSupported()) {
_showVoiceToast('Voice Unavailable', 'This browser does not support speech synthesis.', 'warning');
return;
}
// Make the test immediate and recover from a paused/stalled synthesis engine.
try {
speechSynthesis.getVoices();
if (speechSynthesis.paused) speechSynthesis.resume();
speechSynthesis.cancel();
} catch (_) {}
const utt = _createUtterance(text || 'Voice alert test. All systems nominal.');
let started = false;
utt.onstart = () => { started = true; };
utt.onerror = () => {
_showVoiceToast('Voice Test Failed', 'Speech synthesis failed to start. Check browser audio output.', 'warning');
};
speechSynthesis.speak(utt);
window.setTimeout(() => {
if (!started && !speechSynthesis.speaking && !speechSynthesis.pending) {
_showVoiceToast('No Voice Output', 'Test speech did not play. Verify browser audio and selected voice.', 'warning');
}
}, 1200);
}
return { init, speak, toggleMute, setEnabled, getConfig, setConfig, getAvailableVoices, testVoice, PRIORITY };
})();
window.VoiceAlerts = VoiceAlerts;
-549
View File
@@ -1,549 +0,0 @@
/**
* Analytics Dashboard Module
* Cross-mode summary, sparklines, alerts, correlations, target view, and replay.
*/
const Analytics = (function () {
'use strict';
let refreshTimer = null;
let replayTimer = null;
let replaySessions = [];
let replayEvents = [];
let replayIndex = 0;
function init() {
refresh();
loadReplaySessions();
if (!refreshTimer) {
refreshTimer = setInterval(refresh, 5000);
}
}
function destroy() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
pauseReplay();
}
function refresh() {
Promise.all([
fetch('/analytics/summary').then(r => r.json()).catch(() => null),
fetch('/analytics/activity').then(r => r.json()).catch(() => null),
fetch('/analytics/insights').then(r => r.json()).catch(() => null),
fetch('/analytics/patterns').then(r => r.json()).catch(() => null),
fetch('/alerts/events?limit=20').then(r => r.json()).catch(() => null),
fetch('/correlation').then(r => r.json()).catch(() => null),
fetch('/analytics/geofences').then(r => r.json()).catch(() => null),
]).then(([summary, activity, insights, patterns, alerts, correlations, geofences]) => {
if (summary) renderSummary(summary);
if (activity) renderSparklines(activity.sparklines || {});
if (insights) renderInsights(insights);
if (patterns) renderPatterns(patterns.patterns || []);
if (alerts) renderAlerts(alerts.events || []);
if (correlations) renderCorrelations(correlations);
if (geofences) renderGeofences(geofences.zones || []);
});
}
function renderSummary(data) {
const counts = data.counts || {};
_setText('analyticsCountAdsb', counts.adsb || 0);
_setText('analyticsCountAis', counts.ais || 0);
_setText('analyticsCountWifi', counts.wifi || 0);
_setText('analyticsCountBt', counts.bluetooth || 0);
_setText('analyticsCountDsc', counts.dsc || 0);
_setText('analyticsCountAcars', counts.acars || 0);
_setText('analyticsCountVdl2', counts.vdl2 || 0);
_setText('analyticsCountAprs', counts.aprs || 0);
_setText('analyticsCountMesh', counts.meshtastic || 0);
const health = data.health || {};
const container = document.getElementById('analyticsHealth');
if (container) {
let html = '';
const modeLabels = {
pager: 'Pager', sensor: '433MHz', adsb: 'ADS-B', ais: 'AIS',
acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', wifi: 'WiFi',
bluetooth: 'BT', dsc: 'DSC', meshtastic: 'Mesh'
};
for (const [mode, info] of Object.entries(health)) {
if (mode === 'sdr_devices') continue;
const running = info && info.running;
const label = modeLabels[mode] || mode;
html += '<div class="health-item"><span class="health-dot' + (running ? ' running' : '') + '"></span>' + _esc(label) + '</div>';
}
container.innerHTML = html;
}
const squawks = data.squawks || [];
const sqSection = document.getElementById('analyticsSquawkSection');
const sqList = document.getElementById('analyticsSquawkList');
if (sqSection && sqList) {
if (squawks.length > 0) {
sqSection.style.display = '';
sqList.innerHTML = squawks.map(s =>
'<div class="squawk-item"><strong>' + _esc(s.squawk) + '</strong> ' +
_esc(s.meaning) + ' - ' + _esc(s.callsign || s.icao) + '</div>'
).join('');
} else {
sqSection.style.display = 'none';
}
}
}
function renderSparklines(sparklines) {
const map = {
adsb: 'analyticsSparkAdsb',
ais: 'analyticsSparkAis',
wifi: 'analyticsSparkWifi',
bluetooth: 'analyticsSparkBt',
dsc: 'analyticsSparkDsc',
acars: 'analyticsSparkAcars',
vdl2: 'analyticsSparkVdl2',
aprs: 'analyticsSparkAprs',
meshtastic: 'analyticsSparkMesh',
};
for (const [mode, elId] of Object.entries(map)) {
const el = document.getElementById(elId);
if (!el) continue;
const data = sparklines[mode] || [];
if (data.length < 2) {
el.innerHTML = '';
continue;
}
const max = Math.max(...data, 1);
const w = 100;
const h = 24;
const step = w / (data.length - 1);
const points = data.map((v, i) =>
(i * step).toFixed(1) + ',' + (h - (v / max) * (h - 2)).toFixed(1)
).join(' ');
el.innerHTML = '<svg viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none"><polyline points="' + points + '"/></svg>';
}
}
function renderInsights(data) {
const cards = data.cards || [];
const topChanges = data.top_changes || [];
const cardsEl = document.getElementById('analyticsInsights');
const changesEl = document.getElementById('analyticsTopChanges');
if (cardsEl) {
if (!cards.length) {
cardsEl.innerHTML = '<div class="analytics-empty">No insight data available</div>';
} else {
cardsEl.innerHTML = cards.map(c => {
const sev = _esc(c.severity || 'low');
const title = _esc(c.title || 'Insight');
const value = _esc(c.value || '--');
const label = _esc(c.label || '');
const detail = _esc(c.detail || '');
return '<div class="analytics-insight-card ' + sev + '">' +
'<div class="insight-title">' + title + '</div>' +
'<div class="insight-value">' + value + '</div>' +
'<div class="insight-label">' + label + '</div>' +
'<div class="insight-detail">' + detail + '</div>' +
'</div>';
}).join('');
}
}
if (changesEl) {
if (!topChanges.length) {
changesEl.innerHTML = '<div class="analytics-empty">No change signals yet</div>';
} else {
changesEl.innerHTML = topChanges.map(item => {
const mode = _esc(item.mode_label || item.mode || '');
const deltaRaw = Number(item.delta || 0);
const trendClass = deltaRaw > 0 ? 'up' : (deltaRaw < 0 ? 'down' : 'flat');
const delta = _esc(item.signed_delta || String(deltaRaw));
const recentAvg = _esc(item.recent_avg);
const prevAvg = _esc(item.previous_avg);
return '<div class="analytics-change-row">' +
'<span class="mode">' + mode + '</span>' +
'<span class="delta ' + trendClass + '">' + delta + '</span>' +
'<span class="avg">avg ' + recentAvg + ' vs ' + prevAvg + '</span>' +
'</div>';
}).join('');
}
}
}
function renderPatterns(patterns) {
const container = document.getElementById('analyticsPatternList');
if (!container) return;
if (!patterns || patterns.length === 0) {
container.innerHTML = '<div class="analytics-empty">No recurring patterns detected</div>';
return;
}
const modeLabels = {
adsb: 'ADS-B', ais: 'AIS', wifi: 'WiFi', bluetooth: 'Bluetooth',
dsc: 'DSC', acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', meshtastic: 'Meshtastic',
};
const sorted = patterns
.slice()
.sort((a, b) => (b.confidence || 0) - (a.confidence || 0))
.slice(0, 20);
container.innerHTML = sorted.map(p => {
const confidencePct = Math.round((Number(p.confidence || 0)) * 100);
const mode = modeLabels[p.mode] || (p.mode || '--').toUpperCase();
const period = _humanPeriod(Number(p.period_seconds || 0));
const occurrences = Number(p.occurrences || 0);
const deviceId = _shortId(p.device_id || '--');
return '<div class="analytics-pattern-item">' +
'<div class="pattern-main">' +
'<span class="pattern-mode">' + _esc(mode) + '</span>' +
'<span class="pattern-device">' + _esc(deviceId) + '</span>' +
'</div>' +
'<div class="pattern-meta">' +
'<span>Period: ' + _esc(period) + '</span>' +
'<span>Hits: ' + _esc(occurrences) + '</span>' +
'<span class="pattern-confidence">' + _esc(confidencePct) + '%</span>' +
'</div>' +
'</div>';
}).join('');
}
function renderAlerts(events) {
const container = document.getElementById('analyticsAlertFeed');
if (!container) return;
if (!events || events.length === 0) {
container.innerHTML = '<div class="analytics-empty">No recent alerts</div>';
return;
}
container.innerHTML = events.slice(0, 20).map(e => {
const sev = e.severity || 'medium';
const title = e.title || e.event_type || 'Alert';
const time = e.created_at ? new Date(e.created_at).toLocaleTimeString() : '';
return '<div class="analytics-alert-item">' +
'<span class="alert-severity ' + _esc(sev) + '">' + _esc(sev) + '</span>' +
'<span>' + _esc(title) + '</span>' +
'<span style="margin-left:auto;color:var(--text-dim)">' + _esc(time) + '</span>' +
'</div>';
}).join('');
}
function renderCorrelations(data) {
const container = document.getElementById('analyticsCorrelations');
if (!container) return;
const pairs = (data && data.correlations) || [];
if (pairs.length === 0) {
container.innerHTML = '<div class="analytics-empty">No correlations detected</div>';
return;
}
container.innerHTML = pairs.slice(0, 20).map(p => {
const conf = Math.round((p.confidence || 0) * 100);
return '<div class="analytics-correlation-pair">' +
'<span>' + _esc(p.wifi_mac || '') + '</span>' +
'<span style="color:var(--text-dim)">&#8596;</span>' +
'<span>' + _esc(p.bt_mac || '') + '</span>' +
'<div class="confidence-bar"><div class="confidence-fill" style="width:' + conf + '%"></div></div>' +
'<span style="color:var(--text-dim)">' + conf + '%</span>' +
'</div>';
}).join('');
}
function renderGeofences(zones) {
const container = document.getElementById('analyticsGeofenceList');
if (!container) return;
if (!zones || zones.length === 0) {
container.innerHTML = '<div class="analytics-empty">No geofence zones defined</div>';
return;
}
container.innerHTML = zones.map(z =>
'<div class="geofence-zone-item">' +
'<span class="zone-name">' + _esc(z.name) + '</span>' +
'<span class="zone-radius">' + z.radius_m + 'm</span>' +
'<button class="zone-delete" onclick="Analytics.deleteGeofence(' + z.id + ')">DEL</button>' +
'</div>'
).join('');
}
function addGeofence() {
const name = prompt('Zone name:');
if (!name) return;
const lat = parseFloat(prompt('Latitude:', '0'));
const lon = parseFloat(prompt('Longitude:', '0'));
const radius = parseFloat(prompt('Radius (meters):', '1000'));
if (isNaN(lat) || isNaN(lon) || isNaN(radius)) {
alert('Invalid input');
return;
}
fetch('/analytics/geofences', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, lat, lon, radius_m: radius }),
})
.then(r => r.json())
.then(() => refresh());
}
function deleteGeofence(id) {
if (!confirm('Delete this geofence zone?')) return;
fetch('/analytics/geofences/' + id, { method: 'DELETE' })
.then(r => r.json())
.then(() => refresh());
}
function exportData(mode) {
const m = mode || (document.getElementById('exportMode') || {}).value || 'adsb';
const f = (document.getElementById('exportFormat') || {}).value || 'json';
window.open('/analytics/export/' + encodeURIComponent(m) + '?format=' + encodeURIComponent(f), '_blank');
}
function searchTarget() {
const input = document.getElementById('analyticsTargetQuery');
const summaryEl = document.getElementById('analyticsTargetSummary');
const q = (input && input.value || '').trim();
if (!q) {
if (summaryEl) summaryEl.textContent = 'Enter a search value to correlate entities';
renderTargetResults([]);
return;
}
fetch('/analytics/target?q=' + encodeURIComponent(q) + '&limit=120')
.then((r) => r.json())
.then((data) => {
const results = data.results || [];
if (summaryEl) {
const modeCounts = data.mode_counts || {};
const bits = Object.entries(modeCounts).map(([mode, count]) => `${mode}: ${count}`).join(' | ');
summaryEl.textContent = `${results.length} results${bits ? ' | ' + bits : ''}`;
}
renderTargetResults(results);
})
.catch((err) => {
if (summaryEl) summaryEl.textContent = 'Search failed';
if (typeof reportActionableError === 'function') {
reportActionableError('Target View Search', err, { onRetry: searchTarget });
}
});
}
function renderTargetResults(results) {
const container = document.getElementById('analyticsTargetResults');
if (!container) return;
if (!results || !results.length) {
container.innerHTML = '<div class="analytics-empty">No matching entities</div>';
return;
}
container.innerHTML = results.map((item) => {
const title = _esc(item.title || item.id || 'Entity');
const subtitle = _esc(item.subtitle || '');
const mode = _esc(item.mode || 'unknown');
const confidence = item.confidence != null ? `Confidence ${_esc(Math.round(Number(item.confidence) * 100))}%` : '';
const lastSeen = _esc(item.last_seen || '');
return '<div class="analytics-target-item">' +
'<div class="title"><span class="mode">' + mode + '</span><span>' + title + '</span></div>' +
'<div class="meta"><span>' + subtitle + '</span>' +
(lastSeen ? '<span>Last seen ' + lastSeen + '</span>' : '') +
(confidence ? '<span>' + confidence + '</span>' : '') +
'</div>' +
'</div>';
}).join('');
}
function loadReplaySessions() {
const select = document.getElementById('analyticsReplaySelect');
if (!select) return;
fetch('/recordings?limit=60')
.then((r) => r.json())
.then((data) => {
replaySessions = (data.recordings || []).filter((rec) => Number(rec.event_count || 0) > 0);
if (!replaySessions.length) {
select.innerHTML = '<option value="">No recordings</option>';
return;
}
select.innerHTML = replaySessions.map((rec) => {
const label = `${rec.mode} | ${(rec.label || 'session')} | ${new Date(rec.started_at).toLocaleString()}`;
return `<option value="${_esc(rec.id)}">${_esc(label)}</option>`;
}).join('');
const pendingReplay = localStorage.getItem('analyticsReplaySession');
if (pendingReplay && replaySessions.some((rec) => rec.id === pendingReplay)) {
select.value = pendingReplay;
localStorage.removeItem('analyticsReplaySession');
loadReplay();
}
})
.catch((err) => {
if (typeof reportActionableError === 'function') {
reportActionableError('Load Replay Sessions', err, { onRetry: loadReplaySessions });
}
});
}
function loadReplay() {
pauseReplay();
replayEvents = [];
replayIndex = 0;
const select = document.getElementById('analyticsReplaySelect');
const meta = document.getElementById('analyticsReplayMeta');
const timeline = document.getElementById('analyticsReplayTimeline');
if (!select || !meta || !timeline) return;
const id = select.value;
if (!id) {
meta.textContent = 'Select a recording';
timeline.innerHTML = '<div class="analytics-empty">No recording selected</div>';
return;
}
meta.textContent = 'Loading replay events...';
fetch('/recordings/' + encodeURIComponent(id) + '/events?limit=600')
.then((r) => r.json())
.then((data) => {
replayEvents = data.events || [];
replayIndex = 0;
if (!replayEvents.length) {
meta.textContent = 'No events found in selected recording';
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
return;
}
const rec = replaySessions.find((s) => s.id === id);
const mode = rec ? rec.mode : (data.recording && data.recording.mode) || 'unknown';
meta.textContent = `${replayEvents.length} events loaded | mode ${mode}`;
renderReplayWindow();
})
.catch((err) => {
meta.textContent = 'Replay load failed';
if (typeof reportActionableError === 'function') {
reportActionableError('Load Replay', err, { onRetry: loadReplay });
}
});
}
function playReplay() {
if (!replayEvents.length) {
loadReplay();
return;
}
if (replayTimer) return;
replayTimer = setInterval(() => {
if (replayIndex >= replayEvents.length - 1) {
pauseReplay();
return;
}
replayIndex += 1;
renderReplayWindow();
}, 260);
}
function pauseReplay() {
if (replayTimer) {
clearInterval(replayTimer);
replayTimer = null;
}
}
function stepReplay() {
if (!replayEvents.length) {
loadReplay();
return;
}
pauseReplay();
replayIndex = Math.min(replayIndex + 1, replayEvents.length - 1);
renderReplayWindow();
}
function renderReplayWindow() {
const timeline = document.getElementById('analyticsReplayTimeline');
const meta = document.getElementById('analyticsReplayMeta');
if (!timeline || !meta) return;
const total = replayEvents.length;
if (!total) {
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
return;
}
const start = Math.max(0, replayIndex - 15);
const end = Math.min(total, replayIndex + 20);
const windowed = replayEvents.slice(start, end);
timeline.innerHTML = windowed.map((row, i) => {
const absolute = start + i;
const active = absolute === replayIndex;
const eventType = _esc(row.event_type || 'event');
const mode = _esc(row.mode || '--');
const ts = _esc(row.timestamp ? new Date(row.timestamp).toLocaleTimeString() : '--');
const detail = summarizeReplayEvent(row.event || {});
return '<div class="analytics-replay-item" style="opacity:' + (active ? '1' : '0.65') + ';">' +
'<div class="title"><span class="mode">' + mode + '</span><span>' + eventType + '</span></div>' +
'<div class="meta"><span>' + ts + '</span><span>' + _esc(detail) + '</span></div>' +
'</div>';
}).join('');
meta.textContent = `Event ${replayIndex + 1}/${total}`;
}
function summarizeReplayEvent(event) {
if (!event || typeof event !== 'object') return 'No details';
if (event.callsign) return `Callsign ${event.callsign}`;
if (event.icao) return `ICAO ${event.icao}`;
if (event.ssid) return `SSID ${event.ssid}`;
if (event.bssid) return `BSSID ${event.bssid}`;
if (event.address) return `Address ${event.address}`;
if (event.name) return `Name ${event.name}`;
const keys = Object.keys(event);
if (!keys.length) return 'No fields';
return `${keys[0]}=${String(event[keys[0]]).slice(0, 40)}`;
}
function _setText(id, val) {
const el = document.getElementById(id);
if (el) el.textContent = val;
}
function _esc(s) {
if (typeof s !== 'string') s = String(s == null ? '' : s);
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function _shortId(value) {
const text = String(value || '');
if (text.length <= 18) return text;
return text.slice(0, 8) + '...' + text.slice(-6);
}
function _humanPeriod(seconds) {
if (!isFinite(seconds) || seconds <= 0) return '--';
if (seconds < 60) return Math.round(seconds) + 's';
const mins = seconds / 60;
if (mins < 60) return mins.toFixed(mins < 10 ? 1 : 0) + 'm';
const hours = mins / 60;
return hours.toFixed(hours < 10 ? 1 : 0) + 'h';
}
return {
init,
destroy,
refresh,
addGeofence,
deleteGeofence,
exportData,
searchTarget,
loadReplay,
playReplay,
pauseReplay,
stepReplay,
loadReplaySessions,
};
})();
+19 -4
View File
@@ -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
View File
@@ -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: '&copy; OSM &copy; CARTO' attribution: '&copy; OSM &copy; 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;
-852
View File
@@ -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
View File
@@ -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
+13 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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: '&copy; OpenStreetMap contributors &copy; 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: '&copy; OpenStreetMap contributors &copy; CARTO',
subdomains: 'abcd',
maxZoom: 19,
className: 'tile-layer-cyan',
}).addTo(websdrMap);
}
mapEl.style.background = '#1a1d29';
return true;
}
function setupWebsdrResizeHandling(mapEl) {
if (typeof ResizeObserver !== 'undefined') {
if (websdrResizeObserver) {
websdrResizeObserver.disconnect();
}
websdrResizeObserver = new ResizeObserver(() => invalidateWebSDRViewport());
websdrResizeObserver.observe(mapEl);
}
if (!websdrResizeHooked) {
window.addEventListener('resize', invalidateWebSDRViewport);
window.addEventListener('orientationchange', () => setTimeout(invalidateWebSDRViewport, 120));
websdrResizeHooked = true;
}
}
function invalidateWebSDRViewport() {
if (websdrMapType === 'globe') {
resizeWebsdrGlobe();
return;
}
if (websdrMap && typeof websdrMap.invalidateSize === 'function') {
websdrMap.invalidateSize({ pan: false, animate: false });
}
}
function resizeWebsdrGlobe() {
if (!websdrGlobe) return;
const mapEl = document.getElementById('websdrMap');
if (!mapEl) return;
const width = mapEl.clientWidth;
const height = mapEl.clientHeight;
if (!width || !height) return;
websdrGlobe.width(width);
websdrGlobe.height(height);
}
function plotReceiversOnGlobe(receivers) {
if (!websdrGlobe) return;
const points = [];
receivers.forEach((rx, idx) => {
const lat = Number(rx.lat);
const lon = Number(rx.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
const selected = idx === websdrSelectedReceiverIndex;
points.push({
lat: lat,
lng: lon,
receiverIndex: idx,
radius: selected ? 0.52 : 0.38,
altitude: selected ? 0.1 : 0.04,
color: selected ? '#00ff88' : (rx.available ? '#00d4ff' : '#5f6976'),
label: buildWebsdrPointLabel(rx, idx),
});
});
websdrGlobe.pointsData(points);
if (points.length > 0) {
if (websdrSelectedReceiverIndex != null) {
const selectedPoint = points.find(point => point.receiverIndex === websdrSelectedReceiverIndex);
if (selectedPoint) {
websdrGlobe.pointOfView({ lat: selectedPoint.lat, lng: selectedPoint.lng, altitude: 1.45 }, 900);
return;
}
}
const center = computeWebsdrGlobeCenter(points);
websdrGlobe.pointOfView(center, 900);
}
}
function computeWebsdrGlobeCenter(points) {
if (!points.length) return { lat: 20, lng: 0, altitude: 2.1 };
let x = 0;
let y = 0;
let z = 0;
points.forEach(point => {
const latRad = point.lat * Math.PI / 180;
const lonRad = point.lng * Math.PI / 180;
x += Math.cos(latRad) * Math.cos(lonRad);
y += Math.cos(latRad) * Math.sin(lonRad);
z += Math.sin(latRad);
});
const count = points.length;
x /= count;
y /= count;
z /= count;
const hyp = Math.sqrt((x * x) + (y * y));
const centerLat = Math.atan2(z, hyp) * 180 / Math.PI;
const centerLng = Math.atan2(y, x) * 180 / Math.PI;
let meanAngularDistance = 0;
const centerLatRad = centerLat * Math.PI / 180;
const centerLngRad = centerLng * Math.PI / 180;
points.forEach(point => {
const latRad = point.lat * Math.PI / 180;
const lonRad = point.lng * Math.PI / 180;
const cosAngle = (
(Math.sin(centerLatRad) * Math.sin(latRad)) +
(Math.cos(centerLatRad) * Math.cos(latRad) * Math.cos(lonRad - centerLngRad))
);
const safeCos = Math.max(-1, Math.min(1, cosAngle));
meanAngularDistance += Math.acos(safeCos) * 180 / Math.PI;
});
meanAngularDistance /= count;
const altitude = Math.min(2.9, Math.max(1.35, 1.35 + (meanAngularDistance / 45)));
return { lat: centerLat, lng: centerLng, altitude: altitude };
}
function ensureWebsdrGlobePopup(mapEl) {
if (websdrGlobePopup) {
if (websdrGlobePopup.parentElement !== mapEl) {
mapEl.appendChild(websdrGlobePopup);
}
return;
}
websdrGlobePopup = document.createElement('div');
websdrGlobePopup.id = 'websdrGlobePopup';
websdrGlobePopup.style.position = 'absolute';
websdrGlobePopup.style.minWidth = '220px';
websdrGlobePopup.style.maxWidth = '260px';
websdrGlobePopup.style.padding = '10px';
websdrGlobePopup.style.borderRadius = '8px';
websdrGlobePopup.style.border = '1px solid rgba(0, 212, 255, 0.35)';
websdrGlobePopup.style.background = 'rgba(5, 13, 20, 0.92)';
websdrGlobePopup.style.backdropFilter = 'blur(4px)';
websdrGlobePopup.style.boxShadow = '0 8px 24px rgba(0, 0, 0, 0.4)';
websdrGlobePopup.style.color = 'var(--text-primary)';
websdrGlobePopup.style.display = 'none';
websdrGlobePopup.style.zIndex = '20';
mapEl.appendChild(websdrGlobePopup);
if (!mapEl.dataset.websdrPopupHooked) {
mapEl.addEventListener('click', (event) => {
if (!websdrGlobePopup || websdrGlobePopup.style.display === 'none') return;
if (event.target.closest('#websdrGlobePopup')) return;
hideWebsdrGlobePopup();
});
mapEl.dataset.websdrPopupHooked = 'true';
}
}
function showWebsdrGlobePopup(point, event) {
if (!websdrGlobePopup || !point || point.receiverIndex == null) return;
const rx = websdrReceivers[point.receiverIndex];
if (!rx) return;
const mapEl = document.getElementById('websdrMap');
if (!mapEl) return;
websdrSelectedReceiverIndex = point.receiverIndex;
renderReceiverList(websdrReceivers);
plotReceiversOnGlobe(websdrReceivers);
websdrGlobePopup.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: start; gap: 10px; margin-bottom: 6px;">
<strong style="font-size: 12px; color: var(--accent-cyan);">${escapeHtmlWebsdr(rx.name)}</strong>
<button type="button" data-websdr-popup-close style="border: none; background: transparent; color: var(--text-muted); cursor: pointer; font-size: 14px; line-height: 1;">&times;</button>
</div>
${rx.location ? `<div style="font-size: 10px; color: var(--text-secondary); margin-bottom: 3px;">${escapeHtmlWebsdr(rx.location)}</div>` : ''}
<div style="font-size: 10px; color: var(--text-muted); margin-bottom: 2px;">Antenna: ${escapeHtmlWebsdr(rx.antenna || 'Unknown')}</div>
<div style="font-size: 10px; color: var(--text-muted); margin-bottom: 10px;">Users: ${rx.users}/${rx.users_max}</div>
<button type="button" data-websdr-listen style="width: 100%; padding: 5px 10px; background: #00d4ff; color: #041018; border: none; border-radius: 4px; cursor: pointer; font-weight: 700;">Listen</button>
`;
websdrGlobePopup.style.display = 'block';
const rect = mapEl.getBoundingClientRect();
const x = event && Number.isFinite(event.clientX) ? (event.clientX - rect.left) : (rect.width / 2);
const y = event && Number.isFinite(event.clientY) ? (event.clientY - rect.top) : (rect.height / 2);
const popupWidth = 260;
const popupHeight = 155;
const left = Math.max(12, Math.min(rect.width - popupWidth - 12, x + 12));
const top = Math.max(12, Math.min(rect.height - popupHeight - 12, y + 12));
websdrGlobePopup.style.left = `${left}px`;
websdrGlobePopup.style.top = `${top}px`;
const closeBtn = websdrGlobePopup.querySelector('[data-websdr-popup-close]');
if (closeBtn) {
closeBtn.onclick = () => hideWebsdrGlobePopup();
}
const listenBtn = websdrGlobePopup.querySelector('[data-websdr-listen]');
if (listenBtn) {
listenBtn.onclick = () => selectReceiver(point.receiverIndex);
}
if (event && typeof event.stopPropagation === 'function') {
event.stopPropagation();
}
}
function hideWebsdrGlobePopup() {
if (websdrGlobePopup) {
websdrGlobePopup.style.display = 'none';
}
}
function buildWebsdrPointLabel(rx, idx) {
const location = rx.location ? escapeHtmlWebsdr(rx.location) : 'Unknown location';
const antenna = escapeHtmlWebsdr(rx.antenna || 'Unknown antenna');
return `
<div style="padding: 4px 6px; font-size: 11px; background: rgba(4, 12, 19, 0.9); border: 1px solid rgba(0,212,255,0.28); border-radius: 4px;">
<div style="color: #00d4ff; font-weight: 600;">${escapeHtmlWebsdr(rx.name)}</div>
<div style="color: #a5b1c3;">${location}</div>
<div style="color: #8f9fb3;">${antenna} · ${rx.users}/${rx.users_max}</div>
<div style="color: #7a899b; margin-top: 2px;">Receiver #${idx + 1}</div>
</div>
`;
}
// ============== RECEIVER LIST ============== // ============== 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
View File
@@ -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) {
+297
View File
@@ -0,0 +1,297 @@
/*
* Leaflet.heat a tiny, fast Leaflet heatmap plugin
* https://github.com/Leaflet/Leaflet.heat
* (c) 2014, Vladimir Agafonkin
* MIT License
*
* Bundled local copy for INTERCEPT avoids CDN dependency.
* Includes simpleheat (https://github.com/mourner/simpleheat), MIT License.
*/
// ---- simpleheat ----
(function (global, factory) {
typeof define === 'function' && define.amd ? define(factory) :
typeof exports !== 'undefined' ? module.exports = factory() :
global.simpleheat = factory();
}(this, function () {
'use strict';
function simpleheat(canvas) {
if (!(this instanceof simpleheat)) return new simpleheat(canvas);
this._canvas = canvas = typeof canvas === 'string' ? document.getElementById(canvas) : canvas;
this._ctx = canvas.getContext('2d');
this._width = canvas.width;
this._height = canvas.height;
this._max = 1;
this._data = [];
}
simpleheat.prototype = {
defaultRadius: 25,
defaultGradient: {
0.4: 'blue',
0.6: 'cyan',
0.7: 'lime',
0.8: 'yellow',
1.0: 'red'
},
data: function (data) {
this._data = data;
return this;
},
max: function (max) {
this._max = max;
return this;
},
add: function (point) {
this._data.push(point);
return this;
},
clear: function () {
this._data = [];
return this;
},
radius: function (r, blur) {
blur = blur === undefined ? 15 : blur;
var circle = this._circle = this._createCanvas(),
ctx = circle.getContext('2d'),
r2 = this._r = r + blur;
circle.width = circle.height = r2 * 2;
ctx.shadowOffsetX = ctx.shadowOffsetY = r2 * 2;
ctx.shadowBlur = blur;
ctx.shadowColor = 'black';
ctx.beginPath();
ctx.arc(-r2, -r2, r, 0, Math.PI * 2, true);
ctx.closePath();
ctx.fill();
return this;
},
resize: function () {
this._width = this._canvas.width;
this._height = this._canvas.height;
},
gradient: function (grad) {
var canvas = this._createCanvas(),
ctx = canvas.getContext('2d'),
gradient = ctx.createLinearGradient(0, 0, 0, 256);
canvas.width = 1;
canvas.height = 256;
for (var i in grad) {
gradient.addColorStop(+i, grad[i]);
}
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 1, 256);
this._grad = ctx.getImageData(0, 0, 1, 256).data;
return this;
},
draw: function (minOpacity) {
if (!this._circle) this.radius(this.defaultRadius);
if (!this._grad) this.gradient(this.defaultGradient);
var ctx = this._ctx;
ctx.clearRect(0, 0, this._width, this._height);
for (var i = 0, len = this._data.length, p; i < len; i++) {
p = this._data[i];
ctx.globalAlpha = Math.min(Math.max(p[2] / this._max, minOpacity === undefined ? 0.05 : minOpacity), 1);
ctx.drawImage(this._circle, p[0] - this._r, p[1] - this._r);
}
var colored = ctx.getImageData(0, 0, this._width, this._height);
this._colorize(colored.data, this._grad);
ctx.putImageData(colored, 0, 0);
return this;
},
_colorize: function (pixels, gradient) {
for (var i = 3, len = pixels.length, j; i < len; i += 4) {
j = pixels[i] * 4;
if (j) {
pixels[i - 3] = gradient[j];
pixels[i - 2] = gradient[j + 1];
pixels[i - 1] = gradient[j + 2];
}
}
},
_createCanvas: function () {
if (typeof document !== 'undefined') {
return document.createElement('canvas');
}
return { getContext: function () {} };
}
};
return simpleheat;
}));
// ---- Leaflet.heat plugin ----
(function () {
if (typeof L === 'undefined') return;
L.HeatLayer = (L.Layer ? L.Layer : L.Class).extend({
initialize: function (latlngs, options) {
this._latlngs = latlngs;
L.setOptions(this, options);
},
setLatLngs: function (latlngs) {
this._latlngs = latlngs;
return this.redraw();
},
addLatLng: function (latlng) {
this._latlngs.push(latlng);
return this.redraw();
},
setOptions: function (options) {
L.setOptions(this, options);
if (this._heat) this._updateOptions();
return this.redraw();
},
redraw: function () {
if (this._heat && !this._frame && this._map && !this._map._animating) {
this._frame = L.Util.requestAnimFrame(this._redraw, this);
}
return this;
},
onAdd: function (map) {
this._map = map;
if (!this._canvas) this._initCanvas();
if (this.options.pane) this.getPane().appendChild(this._canvas);
else map._panes.overlayPane.appendChild(this._canvas);
map.on('moveend', this._reset, this);
if (map.options.zoomAnimation && L.Browser.any3d) {
map.on('zoomanim', this._animateZoom, this);
}
this._reset();
},
onRemove: function (map) {
if (this.options.pane) this.getPane().removeChild(this._canvas);
else map.getPanes().overlayPane.removeChild(this._canvas);
map.off('moveend', this._reset, this);
if (map.options.zoomAnimation) {
map.off('zoomanim', this._animateZoom, this);
}
},
addTo: function (map) {
map.addLayer(this);
return this;
},
_initCanvas: function () {
var canvas = this._canvas = L.DomUtil.create('canvas', 'leaflet-heatmap-layer leaflet-layer');
var originProp = L.DomUtil.testProp(['transformOrigin', 'WebkitTransformOrigin', 'msTransformOrigin']);
canvas.style[originProp] = '50% 50%';
var size = this._map.getSize();
canvas.width = size.x;
canvas.height = size.y;
var animated = this._map.options.zoomAnimation && L.Browser.any3d;
L.DomUtil.addClass(canvas, 'leaflet-zoom-' + (animated ? 'animated' : 'hide'));
this._heat = simpleheat(canvas);
this._updateOptions();
},
_updateOptions: function () {
this._heat.radius(this.options.radius || this._heat.defaultRadius, this.options.blur);
if (this.options.gradient) this._heat.gradient(this.options.gradient);
if (this.options.minOpacity) this._heat.minOpacity = this.options.minOpacity;
},
_reset: function () {
var topLeft = this._map.containerPointToLayerPoint([0, 0]);
L.DomUtil.setPosition(this._canvas, topLeft);
var size = this._map.getSize();
if (this._heat._width !== size.x) {
this._canvas.width = this._heat._width = size.x;
}
if (this._heat._height !== size.y) {
this._canvas.height = this._heat._height = size.y;
}
this._redraw();
},
_redraw: function () {
this._frame = null;
if (!this._map) return;
var data = [],
r = this._heat._r,
size = this._map.getSize(),
bounds = new L.Bounds(L.point([-r, -r]), size.add([r, r])),
max = this.options.max === undefined ? 1 : this.options.max,
maxZoom = this.options.maxZoom === undefined ? this._map.getMaxZoom() : this.options.maxZoom,
v = 1 / Math.pow(2, Math.max(0, Math.min(maxZoom - this._map.getZoom(), 12))),
cellSize = r / 2,
grid = [],
panePos = this._map._getMapPanePos(),
offsetX = panePos.x % cellSize,
offsetY = panePos.y % cellSize,
i, len, p, cell, x, y, j, len2, k;
for (i = 0, len = this._latlngs.length; i < len; i++) {
p = this._map.latLngToContainerPoint(this._latlngs[i]);
if (bounds.contains(p)) {
x = Math.floor((p.x - offsetX) / cellSize) + 2;
y = Math.floor((p.y - offsetY) / cellSize) + 2;
var alt = this._latlngs[i].alt !== undefined ? this._latlngs[i].alt :
this._latlngs[i][2] !== undefined ? +this._latlngs[i][2] : 1;
k = alt * v;
grid[y] = grid[y] || [];
cell = grid[y][x];
if (!cell) {
grid[y][x] = [p.x, p.y, k];
} else {
cell[0] = (cell[0] * cell[2] + p.x * k) / (cell[2] + k);
cell[1] = (cell[1] * cell[2] + p.y * k) / (cell[2] + k);
cell[2] += k;
}
}
}
for (i = 0, len = grid.length; i < len; i++) {
if (grid[i]) {
for (j = 0, len2 = grid[i].length; j < len2; j++) {
cell = grid[i][j];
if (cell) {
data.push([
Math.round(cell[0]),
Math.round(cell[1]),
Math.min(cell[2], max)
]);
}
}
}
}
this._heat.data(data).draw(this.options.minOpacity);
},
_animateZoom: function (e) {
var scale = this._map.getZoomScale(e.zoom),
offset = this._map._getCenterOffset(e.center)._multiplyBy(-scale).subtract(this._map._getMapPanePos());
if (L.DomUtil.setTransform) {
L.DomUtil.setTransform(this._canvas, offset, scale);
} else {
this._canvas.style[L.DomUtil.TRANSFORM] = L.DomUtil.getTranslateString(offset) + ' scale(' + scale + ')';
}
}
});
L.heatLayer = function (latlngs, options) {
return new L.HeatLayer(latlngs, options);
};
}());
+27
View File
@@ -0,0 +1,27 @@
{
"name": "INTERCEPT Signal Intelligence",
"short_name": "INTERCEPT",
"description": "Unified SIGINT platform for software-defined radio analysis",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0b1118",
"theme_color": "#0b1118",
"icons": [
{
"src": "/static/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/static/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml"
}
]
}
+122
View File
@@ -0,0 +1,122 @@
/* INTERCEPT Service Worker — cache-first static, network-only for API/SSE/WS */
const CACHE_NAME = 'intercept-v3';
const NETWORK_ONLY_PREFIXES = [
'/stream', '/ws/', '/api/', '/gps/', '/wifi/', '/bluetooth/',
'/adsb/', '/ais/', '/acars/', '/aprs/', '/tscm/', '/satellite/',
'/meshtastic/', '/bt_locate/', '/receiver/', '/sensor/', '/pager/',
'/sstv/', '/weather-sat/', '/subghz/', '/rtlamr/', '/dsc/', '/vdl2/',
'/spy/', '/space-weather/', '/websdr/', '/analytics/', '/correlation/',
'/recordings/', '/controller/', '/ops/',
];
const STATIC_PREFIXES = [
'/static/css/',
'/static/js/',
'/static/icons/',
'/static/fonts/',
];
const CACHE_EXACT = ['/manifest.json'];
function isHttpRequest(req) {
const url = new URL(req.url);
return url.protocol === 'http:' || url.protocol === 'https:';
}
function isNetworkOnly(req) {
if (req.method !== 'GET') return true;
const accept = req.headers.get('Accept') || '';
if (accept.includes('text/event-stream')) return true;
const url = new URL(req.url);
return NETWORK_ONLY_PREFIXES.some(p => url.pathname.startsWith(p));
}
function isStaticAsset(req) {
const url = new URL(req.url);
if (CACHE_EXACT.includes(url.pathname)) return true;
return STATIC_PREFIXES.some(p => url.pathname.startsWith(p));
}
function fallbackResponse(req, status = 503) {
const accept = req.headers.get('Accept') || '';
if (accept.includes('application/json')) {
return new Response(
JSON.stringify({ status: 'error', message: 'Network unavailable' }),
{
status,
headers: { 'Content-Type': 'application/json' },
}
);
}
if (accept.includes('text/event-stream')) {
return new Response('', {
status,
headers: { 'Content-Type': 'text/event-stream' },
});
}
return new Response('Offline', {
status,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
self.addEventListener('install', (e) => {
self.skipWaiting();
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (e) => {
const req = e.request;
// Ignore non-HTTP(S) requests so extensions/browser-internal URLs are untouched.
if (!isHttpRequest(req)) {
return;
}
// Always bypass service worker for non-GET and streaming routes
if (isNetworkOnly(req)) {
e.respondWith(
fetch(req).catch(() => fallbackResponse(req, 503))
);
return;
}
// Cache-first for static assets
if (isStaticAsset(req)) {
e.respondWith(
caches.open(CACHE_NAME).then(cache =>
cache.match(req).then(cached => {
if (cached) {
// Revalidate in background
fetch(req).then(res => {
if (res && res.status === 200) cache.put(req, res.clone());
}).catch(() => {});
return cached;
}
return fetch(req).then(res => {
if (res && res.status === 200) cache.put(req, res.clone());
return res;
}).catch(() => fallbackResponse(req, 504));
})
)
);
return;
}
// Network-first for HTML pages
e.respondWith(
fetch(req).catch(() =>
caches.match(req).then(cached => cached || new Response('Offline', { status: 503 }))
)
);
});
+163 -8
View File
@@ -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;
+2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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 %}
+25 -7
View File
@@ -43,7 +43,7 @@
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span><span class="desc">Aircraft - ADS-B tracking &amp; history</span></div> <div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span><span class="desc">Aircraft - ADS-B tracking &amp; history</span></div>
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg></span><span class="desc">Vessels - AIS &amp; VHF DSC distress</span></div> <div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg></span><span class="desc">Vessels - AIS &amp; VHF DSC distress</span></div>
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span><span class="desc">APRS - Amateur radio tracking</span></div> <div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>
-211
View File
@@ -1,211 +0,0 @@
<!-- ANALYTICS MODE -->
<div id="analyticsMode" class="mode-content">
{# Analytics Dashboard Sidebar Panel #}
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Summary</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="analytics-grid" id="analyticsSummaryCards">
<div class="analytics-card" data-mode="adsb">
<div class="card-count" id="analyticsCountAdsb">0</div>
<div class="card-label">Aircraft</div>
<div class="card-sparkline" id="analyticsSparkAdsb"></div>
</div>
<div class="analytics-card" data-mode="ais">
<div class="card-count" id="analyticsCountAis">0</div>
<div class="card-label">Vessels</div>
<div class="card-sparkline" id="analyticsSparkAis"></div>
</div>
<div class="analytics-card" data-mode="wifi">
<div class="card-count" id="analyticsCountWifi">0</div>
<div class="card-label">WiFi</div>
<div class="card-sparkline" id="analyticsSparkWifi"></div>
</div>
<div class="analytics-card" data-mode="bluetooth">
<div class="card-count" id="analyticsCountBt">0</div>
<div class="card-label">Bluetooth</div>
<div class="card-sparkline" id="analyticsSparkBt"></div>
</div>
<div class="analytics-card" data-mode="dsc">
<div class="card-count" id="analyticsCountDsc">0</div>
<div class="card-label">DSC</div>
<div class="card-sparkline" id="analyticsSparkDsc"></div>
</div>
<div class="analytics-card" data-mode="acars">
<div class="card-count" id="analyticsCountAcars">0</div>
<div class="card-label">ACARS</div>
<div class="card-sparkline" id="analyticsSparkAcars"></div>
</div>
<div class="analytics-card" data-mode="vdl2">
<div class="card-count" id="analyticsCountVdl2">0</div>
<div class="card-label">VDL2</div>
<div class="card-sparkline" id="analyticsSparkVdl2"></div>
</div>
<div class="analytics-card" data-mode="aprs">
<div class="card-count" id="analyticsCountAprs">0</div>
<div class="card-label">APRS</div>
<div class="card-sparkline" id="analyticsSparkAprs"></div>
</div>
<div class="analytics-card" data-mode="meshtastic">
<div class="card-count" id="analyticsCountMesh">0</div>
<div class="card-label">Mesh</div>
<div class="card-sparkline" id="analyticsSparkMesh"></div>
</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Operational Insights</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="analytics-insight-grid" id="analyticsInsights">
<div class="analytics-empty">Insights loading...</div>
</div>
<div class="analytics-top-changes">
<div class="analytics-section-header">Top Changes</div>
<div id="analyticsTopChanges">
<div class="analytics-empty">No change signals yet</div>
</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Mode Health</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="analytics-health" id="analyticsHealth"></div>
</div>
</div>
<div class="section" id="analyticsSquawkSection" style="display:none;">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Emergency Squawks</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="squawk-emergency" id="analyticsSquawkPanel">
<div class="squawk-title">Active Emergency Codes</div>
<div id="analyticsSquawkList"></div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Temporal Patterns</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div id="analyticsPatternList">
<div class="analytics-empty">No recurring patterns detected</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Recent Alerts</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="analytics-alert-feed" id="analyticsAlertFeed">
<div class="analytics-empty">No recent alerts</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Correlations</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div id="analyticsCorrelations">
<div class="analytics-empty">No correlations detected</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Geofences</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div id="analyticsGeofenceList"></div>
<button class="btn btn-sm" onclick="Analytics.addGeofence()" style="margin-top:8px; font-size:10px; padding:4px 10px; background:var(--accent-cyan); color:#fff; border:none; border-radius:4px; cursor:pointer;">
+ Add Zone
</button>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Target View</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="analytics-target-toolbar">
<input id="analyticsTargetQuery" type="text" placeholder="Search callsign, ICAO, MMSI, MAC, SSID, node..." onkeydown="if(event.key==='Enter'){Analytics.searchTarget();}">
<button onclick="Analytics.searchTarget()">Search</button>
</div>
<div id="analyticsTargetSummary" class="analytics-target-summary">Search to correlate entities across modes</div>
<div id="analyticsTargetResults">
<div class="analytics-empty">No target selected</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Session Replay</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="analytics-replay-toolbar">
<select id="analyticsReplaySelect"></select>
<button onclick="Analytics.loadReplay()">Load</button>
<button onclick="Analytics.playReplay()">Play</button>
<button onclick="Analytics.pauseReplay()">Pause</button>
<button onclick="Analytics.stepReplay()">Step</button>
</div>
<div id="analyticsReplayMeta" class="analytics-target-summary">No replay loaded</div>
<div id="analyticsReplayTimeline">
<div class="analytics-empty">Select a recording to replay key events</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Export Data</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="export-controls">
<select id="exportMode">
<option value="adsb">ADS-B</option>
<option value="ais">AIS</option>
<option value="wifi">WiFi</option>
<option value="bluetooth">Bluetooth</option>
<option value="dsc">DSC</option>
</select>
<select id="exportFormat">
<option value="json">JSON</option>
<option value="csv">CSV</option>
</select>
<button onclick="Analytics.exportData()">Export</button>
</div>
</div>
</div>
</div>
-114
View File
@@ -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>
+327
View File
@@ -0,0 +1,327 @@
<!-- WATERFALL MODE -->
<div id="waterfallMode" class="mode-content wf-side">
<div class="section wf-side-hero">
<div class="wf-side-hero-title-row">
<div class="wf-side-hero-title">Spectrum Waterfall</div>
<div class="wf-side-chip" id="wfHeroVisualStatus">CONNECTING</div>
</div>
<div class="wf-side-hero-subtext">
Click spectrum/waterfall to tune. Scroll to step-tune. Ctrl/Cmd + scroll to zoom span.
</div>
<div class="wf-side-hero-stats">
<div class="wf-side-stat">
<div class="wf-side-stat-label">Tuned</div>
<div class="wf-side-stat-value" id="wfHeroFreq">100.0000 MHz</div>
</div>
<div class="wf-side-stat">
<div class="wf-side-stat-label">Mode</div>
<div class="wf-side-stat-value" id="wfHeroMode">WFM</div>
</div>
<div class="wf-side-stat">
<div class="wf-side-stat-label">Scan</div>
<div class="wf-side-stat-value" id="wfHeroScan">Idle</div>
</div>
<div class="wf-side-stat">
<div class="wf-side-stat-label">Hits</div>
<div class="wf-side-stat-value" id="wfHeroHits">0</div>
</div>
</div>
<div class="wf-side-hero-actions">
<button class="run-btn" id="wfStartBtn" onclick="Waterfall.start()">Start Waterfall</button>
<button class="stop-btn" id="wfStopBtn" onclick="Waterfall.stop()" style="display:none;">Stop Waterfall</button>
</div>
<div id="wfStatus" class="wf-side-status-line"></div>
</div>
<div class="section">
<h3>Device</h3>
<div class="form-group">
<label>SDR Device</label>
<select id="wfDevice" onchange="Waterfall && Waterfall.onDeviceChange && Waterfall.onDeviceChange()">
<option value="">Loading devices...</option>
</select>
</div>
<div id="wfDeviceInfo" class="wf-side-box" style="display:none;">
<div class="wf-side-kv">
<span class="wf-side-kv-label">Type</span>
<span id="wfDeviceType" class="wf-side-kv-value">--</span>
</div>
<div class="wf-side-kv">
<span class="wf-side-kv-label">Range</span>
<span id="wfDeviceRange" class="wf-side-kv-value">--</span>
</div>
<div class="wf-side-kv">
<span class="wf-side-kv-label">Capture SR</span>
<span id="wfDeviceBw" class="wf-side-kv-value">--</span>
</div>
</div>
</div>
<div class="section">
<h3>Tuning</h3>
<div class="form-group">
<label>Center Frequency (MHz)</label>
<input type="number" id="wfCenterFreq" value="100.0000" step="0.001" min="0.001" max="6000">
</div>
<div class="form-group">
<label>Span (MHz)</label>
<input type="number" id="wfSpanMhz" value="2.4" step="0.1" min="0.05" max="30">
</div>
<div class="wf-side-grid-2">
<button class="preset-btn" onclick="Waterfall.applyPreset('fm')">FM Broadcast</button>
<button class="preset-btn" onclick="Waterfall.applyPreset('air')">Airband</button>
<button class="preset-btn" onclick="Waterfall.applyPreset('marine')">Marine</button>
<button class="preset-btn" onclick="Waterfall.applyPreset('ham2m')">2m Ham</button>
</div>
</div>
<div class="section">
<h3>Quick Tune & Bookmarks</h3>
<div class="wf-side-grid-2">
<button class="preset-btn" onclick="Waterfall.quickTune(121.5, 'am')">121.5 Guard</button>
<button class="preset-btn" onclick="Waterfall.quickTune(156.8, 'fm')">156.8 CH16</button>
<button class="preset-btn" onclick="Waterfall.quickTune(145.5, 'fm')">145.5 2m</button>
<button class="preset-btn" onclick="Waterfall.quickTune(98.1, 'wfm')">98.1 FM</button>
<button class="preset-btn" onclick="Waterfall.quickTune(462.5625, 'fm')">462.56 FRS</button>
<button class="preset-btn" onclick="Waterfall.quickTune(446.0, 'fm')">446.0 PMR</button>
</div>
<div class="wf-side-divider"></div>
<div class="wf-bookmark-row">
<input type="number" id="wfBookmarkFreqInput" step="0.0001" min="0.001" max="6000" placeholder="Frequency MHz">
<select id="wfBookmarkMode">
<option value="auto" selected>Auto</option>
<option value="wfm">WFM</option>
<option value="fm">NFM</option>
<option value="am">AM</option>
<option value="usb">USB</option>
<option value="lsb">LSB</option>
</select>
</div>
<div class="wf-side-grid-2">
<button class="preset-btn" onclick="Waterfall.useTuneForBookmark()">Use Tuned</button>
<button class="preset-btn" onclick="Waterfall.addBookmarkFromInput()">Save Bookmark</button>
</div>
<div id="wfBookmarkList" class="wf-bookmark-list">
<div class="wf-empty">No bookmarks saved</div>
</div>
<div class="wf-side-inline-label">Recent Hits</div>
<div id="wfRecentSignals" class="wf-recent-list">
<div class="wf-empty">No recent signal hits</div>
</div>
</div>
<div class="section">
<h3>Handoff</h3>
<div class="wf-side-help">
Send current tuned frequency to another decoder/workflow.
</div>
<div class="wf-side-grid-2">
<button class="preset-btn" onclick="Waterfall.handoff('pager')">To Pager</button>
<button class="preset-btn" onclick="Waterfall.handoff('subghz')">To SubGHz</button>
<button class="preset-btn" onclick="Waterfall.handoff('subghz433')">433 Profile</button>
<button class="preset-btn" onclick="Waterfall.handoff('signalid')">Signal ID</button>
</div>
<div id="wfHandoffStatus" class="wf-side-status-line">Ready</div>
</div>
<div class="section">
<h3>Signal Identification</h3>
<div class="wf-side-help">
Identify current frequency using local catalog and SigID Wiki matches.
</div>
<div class="form-group">
<label>Frequency (MHz)</label>
<input type="number" id="wfSigIdFreq" value="100.0000" step="0.0001" min="0.001" max="6000">
</div>
<div class="form-group">
<label>Mode Hint</label>
<select id="wfSigIdMode">
<option value="auto" selected>Auto (Current Mode)</option>
<option value="wfm">WFM</option>
<option value="fm">NFM</option>
<option value="am">AM</option>
<option value="usb">USB</option>
<option value="lsb">LSB</option>
</select>
</div>
<div class="wf-side-grid-2">
<button class="preset-btn" onclick="Waterfall.useTuneForSignalId()">Use Tuned</button>
<button class="preset-btn" onclick="Waterfall.identifySignal()">Identify</button>
</div>
<div id="wfSigIdStatus" class="wf-side-status-line">Ready</div>
<div id="wfSigIdResult" class="wf-side-box" style="display:none;"></div>
<div id="wfSigIdExternal" class="wf-side-box wf-side-box-muted" style="display:none;"></div>
</div>
<div class="section">
<h3>Scan</h3>
<div class="form-group">
<label>Range Start (MHz)</label>
<input type="number" id="wfScanStart" value="98.8000" step="0.001" min="0.001" max="6000">
</div>
<div class="form-group">
<label>Range End (MHz)</label>
<input type="number" id="wfScanEnd" value="101.2000" step="0.001" min="0.001" max="6000">
</div>
<div class="form-group">
<label>Step (kHz)</label>
<select id="wfScanStepKhz">
<option value="5">5</option>
<option value="10">10</option>
<option value="12.5">12.5</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="200">200</option>
</select>
</div>
<div class="form-group">
<label>Dwell (ms)</label>
<input type="number" id="wfScanDwellMs" value="450" min="60" max="10000" step="10">
</div>
<div class="form-group">
<label>Signal Threshold <span id="wfScanThresholdValue" class="wf-inline-value">170</span></label>
<input type="range" id="wfScanThreshold" min="0" max="255" value="170">
</div>
<div class="form-group">
<label>Hold On Hit (ms)</label>
<input type="number" id="wfScanHoldMs" value="2500" min="0" max="30000" step="100">
</div>
<div class="checkbox-group wf-scan-checkboxes">
<label>
<input type="checkbox" id="wfScanStopOnSignal" checked>
Pause scan when signal is above threshold
</label>
</div>
<div class="wf-side-grid-2 wf-side-grid-gap-top">
<button class="preset-btn" onclick="Waterfall.setScanRangeFromView()">Use Current Span</button>
<button class="preset-btn" id="wfScanStartBtn" onclick="Waterfall.startScan()">Start Scan</button>
<button class="preset-btn" id="wfScanStopBtn" onclick="Waterfall.stopScan()" disabled>Stop Scan</button>
</div>
<div id="wfScanState" class="wf-side-status-line">Scan idle</div>
</div>
<div class="section">
<h3>Scan Activity</h3>
<div class="wf-scan-metric-grid">
<div class="wf-scan-metric-card">
<div class="wf-scan-metric-label">Signals</div>
<div class="wf-scan-metric-value" id="wfScanSignalsCount">0</div>
</div>
<div class="wf-scan-metric-card">
<div class="wf-scan-metric-label">Scanned</div>
<div class="wf-scan-metric-value" id="wfScanStepsCount">0</div>
</div>
<div class="wf-scan-metric-card">
<div class="wf-scan-metric-label">Cycles</div>
<div class="wf-scan-metric-value" id="wfScanCyclesCount">0</div>
</div>
</div>
<div class="wf-side-grid-2 wf-side-grid-gap-top">
<button class="preset-btn" onclick="Waterfall.exportScanLog()">Export Log</button>
<button class="preset-btn" onclick="Waterfall.clearScanHistory()">Clear History</button>
</div>
<div class="wf-hit-table-wrap">
<table class="wf-hit-table">
<thead>
<tr>
<th>Time</th>
<th>Frequency</th>
<th>Level</th>
<th>Mode</th>
<th>Action</th>
</tr>
</thead>
<tbody id="wfSignalHitsBody">
<tr><td colspan="5" class="wf-empty">No signals detected</td></tr>
</tbody>
</table>
</div>
<div id="wfSignalHitCount" class="wf-side-inline-label">0 signals found</div>
<div id="wfActivityLog" class="wf-activity-log">
<div class="wf-empty">Ready</div>
</div>
</div>
<div class="section">
<h3>Capture</h3>
<div class="form-group">
<label>Gain <span class="wf-inline-value">(dB or AUTO)</span></label>
<input type="text" id="wfGain" value="AUTO" placeholder="AUTO or numeric">
</div>
<div class="form-group">
<label>FFT Size</label>
<select id="wfFftSize">
<option value="256">256</option>
<option value="512">512</option>
<option value="1024" selected>1024</option>
<option value="2048">2048</option>
<option value="4096">4096</option>
</select>
</div>
<div class="form-group">
<label>Frame Rate</label>
<select id="wfFps">
<option value="10">10 fps</option>
<option value="20" selected>20 fps</option>
<option value="30">30 fps</option>
<option value="40">40 fps</option>
</select>
</div>
<div class="form-group">
<label>FFT Averaging</label>
<select id="wfAvgCount">
<option value="1">1 (none)</option>
<option value="2">2</option>
<option value="4" selected>4</option>
<option value="8">8</option>
<option value="16">16</option>
</select>
</div>
<div class="form-group">
<label>PPM Correction</label>
<input type="number" id="wfPpm" value="0" step="1" min="-200" max="200" placeholder="0">
</div>
<div class="checkbox-group wf-scan-checkboxes">
<label>
<input type="checkbox" id="wfBiasT">
Bias-T (antenna power)
</label>
</div>
</div>
<div class="section">
<h3>Display</h3>
<div class="form-group">
<label>Color Palette</label>
<select id="wfPalette" onchange="Waterfall.setPalette(this.value)">
<option value="turbo" selected>Turbo</option>
<option value="plasma">Plasma</option>
<option value="inferno">Inferno</option>
<option value="viridis">Viridis</option>
</select>
</div>
<div class="form-group">
<label>Noise Floor (dB)</label>
<input type="number" id="wfDbMin" value="-100" step="5" disabled>
</div>
<div class="form-group">
<label>Ceiling (dB)</label>
<input type="number" id="wfDbMax" value="-20" step="5" disabled>
</div>
<div class="checkbox-group wf-scan-checkboxes">
<label>
<input type="checkbox" id="wfPeakHold" onchange="Waterfall.togglePeakHold(this.checked)">
Peak Hold
</label>
<label>
<input type="checkbox" id="wfBandAnnotations" checked onchange="Waterfall.toggleAnnotations(this.checked)">
Band Labels
</label>
<label>
<input type="checkbox" id="wfAutoRange" checked onchange="Waterfall.toggleAutoRange(this.checked)">
Auto Range
</label>
</div>
</div>
</div>
+68 -4
View File
@@ -65,8 +65,8 @@
{{ mode_item('pager', 'Pager', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg>') }} {{ mode_item('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) {
+87 -3
View File
@@ -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>
+2
View File
@@ -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 -->
-202
View File
@@ -1,202 +0,0 @@
"""Tests for analytics endpoints, export, and squawk detection."""
import json
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture(scope='session')
def app():
"""Create application for testing."""
import app as app_module
import utils.database as db_mod
from routes import register_blueprints
app_module.app.config['TESTING'] = True
# Use temp directory for test database
tmp_dir = Path(tempfile.mkdtemp())
db_mod.DB_DIR = tmp_dir
db_mod.DB_PATH = tmp_dir / 'test_intercept.db'
# Reset thread-local connection so it picks up new path
if hasattr(db_mod._local, 'connection') and db_mod._local.connection:
db_mod._local.connection.close()
db_mod._local.connection = None
db_mod.init_db()
if 'pager' not in app_module.app.blueprints:
register_blueprints(app_module.app)
return app_module.app
@pytest.fixture
def client(app):
client = app.test_client()
# Set session login to bypass require_login before_request hook
with client.session_transaction() as sess:
sess['logged_in'] = True
return client
class TestAnalyticsSummary:
"""Tests for /analytics/summary endpoint."""
def test_summary_returns_json(self, client):
response = client.get('/analytics/summary')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'counts' in data
assert 'health' in data
assert 'squawks' in data
def test_summary_counts_structure(self, client):
response = client.get('/analytics/summary')
data = json.loads(response.data)
counts = data['counts']
assert 'adsb' in counts
assert 'ais' in counts
assert 'wifi' in counts
assert 'bluetooth' in counts
assert 'dsc' in counts
# All should be integers
for val in counts.values():
assert isinstance(val, int)
def test_summary_health_structure(self, client):
response = client.get('/analytics/summary')
data = json.loads(response.data)
health = data['health']
# Should have process statuses
assert 'pager' in health
assert 'sensor' in health
assert 'adsb' in health
# Each should have a running flag
for mode_info in health.values():
if isinstance(mode_info, dict) and 'running' in mode_info:
assert isinstance(mode_info['running'], bool)
class TestAnalyticsExport:
"""Tests for /analytics/export/<mode> endpoint."""
def test_export_adsb_json(self, client):
response = client.get('/analytics/export/adsb?format=json')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['mode'] == 'adsb'
assert 'data' in data
assert isinstance(data['data'], list)
def test_export_adsb_csv(self, client):
response = client.get('/analytics/export/adsb?format=csv')
assert response.status_code == 200
assert response.content_type.startswith('text/csv')
assert 'Content-Disposition' in response.headers
def test_export_invalid_mode(self, client):
response = client.get('/analytics/export/invalid_mode')
assert response.status_code == 400
data = json.loads(response.data)
assert data['status'] == 'error'
def test_export_wifi_json(self, client):
response = client.get('/analytics/export/wifi?format=json')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['mode'] == 'wifi'
class TestAnalyticsSquawks:
"""Tests for squawk detection."""
def test_squawks_endpoint(self, client):
response = client.get('/analytics/squawks')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert isinstance(data['squawks'], list)
def test_get_emergency_squawks_detects_7700(self):
from utils.analytics import get_emergency_squawks
# Mock the adsb_aircraft DataStore
mock_store = MagicMock()
mock_store.items.return_value = [
('ABC123', {'squawk': '7700', 'callsign': 'TEST01', 'altitude': 35000}),
('DEF456', {'squawk': '1200', 'callsign': 'TEST02'}),
]
with patch('utils.analytics.app_module') as mock_app:
mock_app.adsb_aircraft = mock_store
squawks = get_emergency_squawks()
assert len(squawks) == 1
assert squawks[0]['squawk'] == '7700'
assert squawks[0]['meaning'] == 'General Emergency'
assert squawks[0]['icao'] == 'ABC123'
class TestGeofenceCRUD:
"""Tests for geofence CRUD endpoints."""
def test_list_geofences(self, client):
response = client.get('/analytics/geofences')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert isinstance(data['zones'], list)
def test_create_geofence(self, client):
response = client.post('/analytics/geofences',
data=json.dumps({
'name': 'Test Zone',
'lat': 51.5074,
'lon': -0.1278,
'radius_m': 500,
}),
content_type='application/json')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'zone_id' in data
def test_create_geofence_missing_fields(self, client):
response = client.post('/analytics/geofences',
data=json.dumps({'name': 'No coords'}),
content_type='application/json')
assert response.status_code == 400
def test_create_geofence_invalid_coords(self, client):
response = client.post('/analytics/geofences',
data=json.dumps({
'name': 'Bad',
'lat': 100,
'lon': 0,
'radius_m': 100,
}),
content_type='application/json')
assert response.status_code == 400
def test_delete_geofence_not_found(self, client):
response = client.delete('/analytics/geofences/99999')
assert response.status_code == 404
class TestAnalyticsActivity:
"""Tests for /analytics/activity endpoint."""
def test_activity_returns_sparklines(self, client):
response = client.get('/analytics/activity')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'sparklines' in data
assert isinstance(data['sparklines'], dict)
+21
View File
@@ -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'))
-311
View File
@@ -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
+47
View File
@@ -0,0 +1,47 @@
"""Tests for pager scope waveform payload generation."""
from __future__ import annotations
import io
import queue
import struct
import threading
from routes.pager import _encode_scope_waveform, audio_relay_thread
def test_encode_scope_waveform_respects_window_and_range():
samples = (-32768, -16384, 0, 16384, 32767)
waveform = _encode_scope_waveform(samples, window_size=4)
assert len(waveform) == 4
assert waveform[0] == -64
assert waveform[1] == 0
assert waveform[2] == 64
assert waveform[3] == 127
assert max(waveform) <= 127
assert min(waveform) >= -127
def test_audio_relay_thread_emits_scope_waveform(monkeypatch):
base_samples = (0, 32767, -32768, 16384) * 512
pcm = struct.pack(f"<{len(base_samples)}h", *base_samples)
rtl_stdout = io.BytesIO(pcm)
multimon_stdin = io.BytesIO()
output_queue: queue.Queue = queue.Queue()
stop_event = threading.Event()
ticks = iter([0.0, 0.2, 0.2, 0.2])
monkeypatch.setattr("routes.pager.time.monotonic", lambda: next(ticks, 0.2))
audio_relay_thread(rtl_stdout, multimon_stdin, output_queue, stop_event)
scope_event = output_queue.get_nowait()
assert scope_event["type"] == "scope"
assert scope_event["rms"] > 0
assert scope_event["peak"] > 0
assert "waveform" in scope_event
assert len(scope_event["waveform"]) > 0
assert max(scope_event["waveform"]) <= 127
assert min(scope_event["waveform"]) >= -127
-11
View File
@@ -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."""
+46
View File
@@ -0,0 +1,46 @@
"""Tests for RTL-SDR detection parsing."""
from unittest.mock import MagicMock, patch
from utils.sdr.base import SDRType
from utils.sdr.detection import detect_rtlsdr_devices
@patch('utils.sdr.detection._check_tool', return_value=True)
@patch('utils.sdr.detection.subprocess.run')
def test_detect_rtlsdr_devices_filters_empty_serial_entries(mock_run, _mock_check_tool):
"""Ignore malformed rtl_test rows that have an empty SN field."""
mock_result = MagicMock()
mock_result.stdout = ""
mock_result.stderr = (
"Found 3 device(s):\n"
" 0: ??C?, , SN:\n"
" 1: ??C?, , SN:\n"
" 2: RTLSDRBlog, Blog V4, SN: 1\n"
)
mock_run.return_value = mock_result
devices = detect_rtlsdr_devices()
assert len(devices) == 1
assert devices[0].sdr_type == SDRType.RTL_SDR
assert devices[0].index == 2
assert devices[0].name == "RTLSDRBlog, Blog V4"
assert devices[0].serial == "1"
@patch('utils.sdr.detection._check_tool', return_value=True)
@patch('utils.sdr.detection.subprocess.run')
def test_detect_rtlsdr_devices_uses_replace_decode_mode(mock_run, _mock_check_tool):
"""Run rtl_test with tolerant decoding for malformed output bytes."""
mock_result = MagicMock()
mock_result.stdout = ""
mock_result.stderr = "Found 0 device(s):"
mock_run.return_value = mock_result
detect_rtlsdr_devices()
_, kwargs = mock_run.call_args
assert kwargs["text"] is True
assert kwargs["encoding"] == "utf-8"
assert kwargs["errors"] == "replace"
+21
View File
@@ -0,0 +1,21 @@
"""Tests for synthesized 433 MHz scope waveform payload."""
from __future__ import annotations
from routes.sensor import _build_scope_waveform
def test_build_scope_waveform_has_expected_shape_and_bounds():
waveform = _build_scope_waveform(rssi=-8.5, snr=11.2, noise=-26.0, points=96)
assert len(waveform) == 96
assert max(waveform) <= 127
assert min(waveform) >= -127
assert any(sample != 0 for sample in waveform)
def test_build_scope_waveform_changes_with_signal_profile():
low_snr = _build_scope_waveform(rssi=-14.0, snr=2.0, noise=-12.0, points=64)
high_snr = _build_scope_waveform(rssi=-14.0, snr=20.0, noise=-12.0, points=64)
assert low_snr != high_snr
+99
View File
@@ -0,0 +1,99 @@
"""Tests for the SigID Wiki lookup API endpoint."""
from unittest.mock import patch
import pytest
import routes.signalid as signalid_module
@pytest.fixture
def auth_client(client):
"""Client with logged-in session."""
with client.session_transaction() as sess:
sess['logged_in'] = True
return client
def test_sigidwiki_lookup_missing_frequency(auth_client):
"""frequency_mhz is required."""
resp = auth_client.post('/signalid/sigidwiki', json={})
assert resp.status_code == 400
data = resp.get_json()
assert data['status'] == 'error'
def test_sigidwiki_lookup_invalid_frequency(auth_client):
"""frequency_mhz must be numeric and positive."""
resp = auth_client.post('/signalid/sigidwiki', json={'frequency_mhz': 'abc'})
assert resp.status_code == 400
resp = auth_client.post('/signalid/sigidwiki', json={'frequency_mhz': -1})
assert resp.status_code == 400
def test_sigidwiki_lookup_success(auth_client):
"""Endpoint returns normalized SigID lookup structure."""
signalid_module._cache.clear()
fake_lookup = {
'matches': [
{
'title': 'POCSAG',
'url': 'https://www.sigidwiki.com/wiki/POCSAG',
'frequencies_mhz': [929.6625],
'modes': ['NFM'],
'modulations': ['FSK'],
'distance_hz': 0,
'source': 'SigID Wiki',
}
],
'search_used': False,
'exact_queries': ['[[Category:Signal]][[Frequencies::929.6625 MHz]]|?Frequencies|?Mode|?Modulation|limit=10'],
}
with patch('routes.signalid._lookup_sigidwiki_matches', return_value=fake_lookup) as lookup_mock:
resp = auth_client.post('/signalid/sigidwiki', json={
'frequency_mhz': 929.6625,
'modulation': 'fm',
'limit': 5,
})
assert lookup_mock.call_count == 1
assert resp.status_code == 200
data = resp.get_json()
assert data['status'] == 'ok'
assert data['source'] == 'sigidwiki'
assert data['cached'] is False
assert data['match_count'] == 1
assert data['matches'][0]['title'] == 'POCSAG'
def test_sigidwiki_lookup_cached_response(auth_client):
"""Second identical lookup should be served from cache."""
signalid_module._cache.clear()
fake_lookup = {
'matches': [{'title': 'Test Signal', 'url': 'https://www.sigidwiki.com/wiki/Test_Signal'}],
'search_used': True,
'exact_queries': [],
}
payload = {'frequency_mhz': 433.92, 'modulation': 'nfm', 'limit': 5}
with patch('routes.signalid._lookup_sigidwiki_matches', return_value=fake_lookup) as lookup_mock:
first = auth_client.post('/signalid/sigidwiki', json=payload)
second = auth_client.post('/signalid/sigidwiki', json=payload)
assert lookup_mock.call_count == 1
assert first.status_code == 200
assert second.status_code == 200
assert first.get_json()['cached'] is False
assert second.get_json()['cached'] is True
def test_sigidwiki_lookup_backend_failure(auth_client):
"""Unexpected lookup failures should return 502."""
signalid_module._cache.clear()
with patch('routes.signalid._lookup_sigidwiki_matches', side_effect=RuntimeError('boom')):
resp = auth_client.post('/signalid/sigidwiki', json={'frequency_mhz': 433.92})
assert resp.status_code == 502
data = resp.get_json()
assert data['status'] == 'error'
+25
View File
@@ -0,0 +1,25 @@
"""Tests for SSTV scope waveform encoding."""
from __future__ import annotations
import numpy as np
from utils.sstv.sstv_decoder import _encode_scope_waveform
def test_encode_scope_waveform_respects_window_and_bounds():
samples = np.array([-32768, -16384, 0, 16384, 32767], dtype=np.int16)
waveform = _encode_scope_waveform(samples, window_size=4)
assert len(waveform) == 4
assert waveform[0] == -64
assert waveform[1] == 0
assert waveform[2] == 64
assert waveform[3] == 127
assert max(waveform) <= 127
assert min(waveform) >= -127
def test_encode_scope_waveform_empty_input():
waveform = _encode_scope_waveform(np.array([], dtype=np.int16))
assert waveform == []
+54
View File
@@ -0,0 +1,54 @@
"""Tests for waterfall WebSocket configuration helpers."""
from routes.waterfall_websocket import (
_parse_center_freq_mhz,
_parse_span_mhz,
_pick_sample_rate,
)
from utils.sdr import SDRType
from utils.sdr.base import SDRCapabilities
def _caps(sample_rates):
return SDRCapabilities(
sdr_type=SDRType.RTL_SDR,
freq_min_mhz=24.0,
freq_max_mhz=1766.0,
gain_min=0.0,
gain_max=49.6,
sample_rates=sample_rates,
supports_bias_t=True,
supports_ppm=True,
tx_capable=False,
)
def test_parse_center_prefers_center_freq_mhz():
assert _parse_center_freq_mhz({'center_freq_mhz': 162.55, 'center_freq': 144000000}) == 162.55
def test_parse_center_supports_center_freq_hz():
assert _parse_center_freq_mhz({'center_freq_hz': 915000000}) == 915.0
def test_parse_center_supports_legacy_hz_payload():
assert _parse_center_freq_mhz({'center_freq': 109000000}) == 109.0
def test_parse_center_supports_legacy_mhz_payload():
assert _parse_center_freq_mhz({'center_freq': 433.92}) == 433.92
def test_parse_span_from_hz_and_mhz():
assert _parse_span_mhz({'span_hz': 2400000}) == 2.4
assert _parse_span_mhz({'span_mhz': 10.0}) == 10.0
def test_pick_sample_rate_chooses_nearest_declared_rate():
caps = _caps([250000, 1024000, 1800000, 2048000, 2400000])
assert _pick_sample_rate(700000, caps, SDRType.RTL_SDR) == 1024000
def test_pick_sample_rate_falls_back_to_max_bandwidth():
caps = _caps([])
assert _pick_sample_rate(10_000_000, caps, SDRType.RTL_SDR) == 2_400_000
+8 -7
View File
@@ -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:
""" """
+6
View File
@@ -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
-231
View File
@@ -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
View File
@@ -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:
+1
View File
@@ -2302,3 +2302,4 @@ def remove_tracked_satellite(norad_id: str) -> tuple[bool, str]:
) )
return True, 'Removed' return True, 'Removed'
-1
View File
@@ -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)
+5 -2
View File
@@ -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
)) ))
-16
View File
@@ -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",
+25 -4
View File
@@ -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
View File
@@ -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