mirror of
https://github.com/smittix/intercept.git
synced 2026-06-14 00:33:35 -07:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a407c7708d | |||
| 1466fc2d30 | |||
| 963bcdf9fa | |||
| cfe03317c9 | |||
| 37ba12daaa | |||
| 5c47e9f10a | |||
| 694786d4e0 | |||
| 06a00ca6b5 | |||
| bbc25ddaa0 | |||
| 02a94281c3 | |||
| cbe5faab3b | |||
| cacfbf5713 | |||
| 2faed68af4 | |||
| bec0881018 | |||
| da2a700bcc | |||
| cd3ed9a03b | |||
| f7fad076c2 | |||
| a397271553 | |||
| 83a54ccb20 | |||
| 2e9bab75b1 | |||
| 0dc40bbea3 | |||
| 17f6947648 | |||
| 481651c88d | |||
| ad4903d4ac | |||
| 3a962ca207 | |||
| f29ae3d5a8 | |||
| 37d24a539d | |||
| 622f23c091 | |||
| b70db887b1 | |||
| 7f13af3fcd | |||
| 9afff0f4b2 | |||
| 5a7a6ce522 | |||
| 36b6539044 | |||
| 6c6cd8a280 | |||
| 4df112e712 | |||
| 3d8b8bbfdc | |||
| 076339024f | |||
| e82f0f36d2 | |||
| f4ade209f9 | |||
| b0652595fa | |||
| 332172881e | |||
| e05ac97749 | |||
| 615a83c23f | |||
| d017375f64 | |||
| 0b5235f619 |
+22
-5
@@ -1,10 +1,27 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to iNTERCEPT will be documented in this file.
|
All notable changes to iNTERCEPT will be documented in this file.
|
||||||
|
|
||||||
## [2.15.0] - 2026-02-09
|
## [2.21.0] - 2026-02-20
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Analytics panels for operational insights and temporal pattern analysis
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Global map theme refresh with improved contrast and cross-dashboard consistency
|
||||||
|
- Cross-app UX refinements for accessibility, mode consistency, and render performance
|
||||||
|
- BT Locate enhancements including improved continuity, smoothing, and confidence reporting
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Weather satellite auto-scheduler and Mercator tracking reliability issues
|
||||||
|
- Bluetooth/WiFi runtime health issues affecting scanner continuity
|
||||||
|
- ADS-B SSE multi-client fanout stability and remote VDL2 streaming reliability
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.15.0] - 2026-02-09
|
||||||
|
|
||||||
|
### Added
|
||||||
- **Real-time WebSocket Waterfall** - I/Q capture with server-side FFT
|
- **Real-time WebSocket Waterfall** - I/Q capture with server-side FFT
|
||||||
- Click-to-tune, zoom controls, and auto-scaling quantization
|
- Click-to-tune, zoom controls, and auto-scaling quantization
|
||||||
- Shared waterfall UI across SDR modes with function bar controls
|
- Shared waterfall UI across SDR modes with function bar controls
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"version": "2026-02-01_ba81b697",
|
"version": "2026-02-15_ae16bb62",
|
||||||
"downloaded": "2026-02-04T17:06:54.806043Z"
|
"downloaded": "2026-02-20T00:29:06.228007Z"
|
||||||
}
|
}
|
||||||
@@ -661,47 +661,103 @@ def _get_subghz_active() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _get_dmr_active() -> bool:
|
def _get_dmr_active() -> bool:
|
||||||
"""Check if Digital Voice decoder has an active process."""
|
"""Check if Digital Voice decoder has an active process."""
|
||||||
try:
|
try:
|
||||||
from routes import dmr as dmr_module
|
from routes import dmr as dmr_module
|
||||||
proc = dmr_module.dmr_dsd_process
|
proc = dmr_module.dmr_dsd_process
|
||||||
return bool(dmr_module.dmr_running and proc and proc.poll() is None)
|
return bool(dmr_module.dmr_running and proc and proc.poll() is None)
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@app.route('/health')
|
def _get_bluetooth_health() -> tuple[bool, int]:
|
||||||
def health_check() -> Response:
|
"""Return Bluetooth active state and best-effort device count."""
|
||||||
"""Health check endpoint for monitoring."""
|
legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False)
|
||||||
import time
|
scanner_running = False
|
||||||
return jsonify({
|
scanner_count = 0
|
||||||
'status': 'healthy',
|
|
||||||
'version': VERSION,
|
try:
|
||||||
'uptime_seconds': round(time.time() - _app_start_time, 2),
|
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
||||||
'processes': {
|
if bt_scanner is not None:
|
||||||
|
scanner_running = bool(bt_scanner.is_scanning)
|
||||||
|
scanner_count = int(bt_scanner.device_count)
|
||||||
|
except Exception:
|
||||||
|
scanner_running = False
|
||||||
|
scanner_count = 0
|
||||||
|
|
||||||
|
locate_running = False
|
||||||
|
try:
|
||||||
|
from utils.bt_locate import get_locate_session
|
||||||
|
session = get_locate_session()
|
||||||
|
if session and getattr(session, 'active', False):
|
||||||
|
scanner = getattr(session, '_scanner', None)
|
||||||
|
locate_running = bool(scanner and scanner.is_scanning)
|
||||||
|
except Exception:
|
||||||
|
locate_running = False
|
||||||
|
|
||||||
|
return (legacy_running or scanner_running or locate_running), max(len(bt_devices), scanner_count)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_wifi_health() -> tuple[bool, int, int]:
|
||||||
|
"""Return WiFi active state and best-effort network/client counts."""
|
||||||
|
legacy_running = wifi_process is not None and (wifi_process.poll() is None if wifi_process else False)
|
||||||
|
scanner_running = False
|
||||||
|
scanner_networks = 0
|
||||||
|
scanner_clients = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
||||||
|
if wifi_scanner is not None:
|
||||||
|
status = wifi_scanner.get_status()
|
||||||
|
scanner_running = bool(status.is_scanning)
|
||||||
|
scanner_networks = int(status.networks_found or 0)
|
||||||
|
scanner_clients = int(status.clients_found or 0)
|
||||||
|
except Exception:
|
||||||
|
scanner_running = False
|
||||||
|
scanner_networks = 0
|
||||||
|
scanner_clients = 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
legacy_running or scanner_running,
|
||||||
|
max(len(wifi_networks), scanner_networks),
|
||||||
|
max(len(wifi_clients), scanner_clients),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/health')
|
||||||
|
def health_check() -> Response:
|
||||||
|
"""Health check endpoint for monitoring."""
|
||||||
|
import time
|
||||||
|
bt_active, bt_device_count = _get_bluetooth_health()
|
||||||
|
wifi_active, wifi_network_count, wifi_client_count = _get_wifi_health()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'healthy',
|
||||||
|
'version': VERSION,
|
||||||
|
'uptime_seconds': round(time.time() - _app_start_time, 2),
|
||||||
|
'processes': {
|
||||||
'pager': current_process is not None and (current_process.poll() is None if current_process else False),
|
'pager': current_process is not None and (current_process.poll() is None if current_process else False),
|
||||||
'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
|
'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
|
||||||
'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False),
|
'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False),
|
||||||
'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False),
|
'ais': ais_process is not None and (ais_process.poll() is None if ais_process else False),
|
||||||
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
|
'acars': acars_process is not None and (acars_process.poll() is None if acars_process else False),
|
||||||
'vdl2': vdl2_process is not None and (vdl2_process.poll() is None if vdl2_process else False),
|
'vdl2': vdl2_process is not None and (vdl2_process.poll() is None if vdl2_process else False),
|
||||||
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
|
'aprs': aprs_process is not None and (aprs_process.poll() is None if aprs_process else False),
|
||||||
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
|
'wifi': wifi_active,
|
||||||
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
|
'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(),
|
'dmr': _get_dmr_active(),
|
||||||
'subghz': _get_subghz_active(),
|
'subghz': _get_subghz_active(),
|
||||||
},
|
},
|
||||||
'data': {
|
'data': {
|
||||||
'aircraft_count': len(adsb_aircraft),
|
'aircraft_count': len(adsb_aircraft),
|
||||||
'vessel_count': len(ais_vessels),
|
'vessel_count': len(ais_vessels),
|
||||||
'wifi_networks_count': len(wifi_networks),
|
'wifi_networks_count': wifi_network_count,
|
||||||
'wifi_clients_count': len(wifi_clients),
|
'wifi_clients_count': wifi_client_count,
|
||||||
'bt_devices_count': len(bt_devices),
|
'bt_devices_count': bt_device_count,
|
||||||
'dsc_messages_count': len(dsc_messages),
|
'dsc_messages_count': len(dsc_messages),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/killall', methods=['POST'])
|
@app.route('/killall', methods=['POST'])
|
||||||
|
|||||||
@@ -6,14 +6,26 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "2.20.0"
|
VERSION = "2.21.0"
|
||||||
|
|
||||||
# Changelog - latest release notes (shown on welcome screen)
|
# Changelog - latest release notes (shown on welcome screen)
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
{
|
{
|
||||||
"version": "2.20.0",
|
"version": "2.21.0",
|
||||||
"date": "February 2026",
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Global map theme refresh with improved contrast and cross-dashboard consistency",
|
||||||
|
"Cross-app UX updates for accessibility, mode consistency, and render performance",
|
||||||
|
"Weather satellite reliability fixes for auto-scheduler and Mercator pass tracking",
|
||||||
|
"Bluetooth/WiFi runtime health fixes with BT Locate continuity and confidence improvements",
|
||||||
|
"ADS-B/VDL2 streaming reliability upgrades for multi-client SSE fanout and remote decoding",
|
||||||
|
"Analytics enhancements with operational insights and temporal pattern panels",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.20.0",
|
||||||
|
"date": "February 2026",
|
||||||
"highlights": [
|
"highlights": [
|
||||||
"Space Weather mode: real-time solar and geomagnetic monitoring from NOAA SWPC, NASA SDO, and HamQSL",
|
"Space Weather mode: real-time solar and geomagnetic monitoring from NOAA SWPC, NASA SDO, and HamQSL",
|
||||||
"Kp index, solar wind, X-ray flux charts with Chart.js visualization",
|
"Kp index, solar wind, X-ray flux charts with Chart.js visualization",
|
||||||
|
|||||||
+5
-4
@@ -219,10 +219,11 @@ Extended base for full-screen dashboards (maps, visualizations).
|
|||||||
### Navigation Groups
|
### Navigation Groups
|
||||||
|
|
||||||
The navigation is organized into groups:
|
The navigation is organized into groups:
|
||||||
- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic, WebSDR, SubGHz
|
- **Signals**: Pager, 433MHz, Meters, Listening Post, SubGHz
|
||||||
- **Wireless**: WiFi, Bluetooth, BT Locate
|
- **Tracking**: Aircraft, Vessels, APRS, GPS
|
||||||
- **Security**: TSCM, Analytics
|
- **Space**: Satellite, ISS SSTV, Weather Sat, HF SSTV, Space Weather
|
||||||
- **Space**: Satellite, ISS SSTV, Weather Sat, HF SSTV, GPS, Space Weather
|
- **Wireless**: WiFi, Bluetooth, BT Locate, Meshtastic
|
||||||
|
- **Intel**: TSCM, Analytics, Spy Stations, WebSDR
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 570 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
+31
-18
@@ -61,72 +61,73 @@
|
|||||||
|
|
||||||
<div class="carousel-filters">
|
<div class="carousel-filters">
|
||||||
<button class="filter-btn active" data-filter="all">All</button>
|
<button class="filter-btn active" data-filter="all">All</button>
|
||||||
<button class="filter-btn" data-filter="sdr">SDR / RF</button>
|
<button class="filter-btn" data-filter="signals">Signals</button>
|
||||||
<button class="filter-btn" data-filter="aviation">Aviation & Maritime</button>
|
<button class="filter-btn" data-filter="tracking">Tracking</button>
|
||||||
<button class="filter-btn" data-filter="space">Space & Satellite</button>
|
<button class="filter-btn" data-filter="space">Space</button>
|
||||||
<button class="filter-btn" data-filter="wireless">Wireless & Security</button>
|
<button class="filter-btn" data-filter="wireless">Wireless</button>
|
||||||
|
<button class="filter-btn" data-filter="intel">Intel</button>
|
||||||
<button class="filter-btn" data-filter="platform">Platform</button>
|
<button class="filter-btn" data-filter="platform">Platform</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="carousel-wrapper">
|
<div class="carousel-wrapper">
|
||||||
<button class="carousel-arrow carousel-arrow-left" aria-label="Scroll left">‹</button>
|
<button class="carousel-arrow carousel-arrow-left" aria-label="Scroll left">‹</button>
|
||||||
<div class="carousel-track">
|
<div class="carousel-track">
|
||||||
<div class="feature-card" data-category="sdr">
|
<div class="feature-card" data-category="signals">
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="6" y1="9" x2="6" y2="15"/><line x1="10" y1="9" x2="10" y2="15"/><line x1="14" y1="11" x2="18" y2="11"/><line x1="14" y1="13" x2="18" y2="13"/></svg></div>
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="6" y1="9" x2="6" y2="15"/><line x1="10" y1="9" x2="10" y2="15"/><line x1="14" y1="11" x2="18" y2="11"/><line x1="14" y1="13" x2="18" y2="13"/></svg></div>
|
||||||
<h3>Pager Decoding</h3>
|
<h3>Pager Decoding</h3>
|
||||||
<p>Decode POCSAG and FLEX pager messages using rtl_fm and multimon-ng. Monitor emergency services and legacy paging systems.</p>
|
<p>Decode POCSAG and FLEX pager messages using rtl_fm and multimon-ng. Monitor emergency services and legacy paging systems.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="sdr">
|
<div class="feature-card" data-category="signals">
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="M12 18v4"/><circle cx="12" cy="12" r="4"/><path d="M4.93 4.93l2.83 2.83"/><path d="M16.24 16.24l2.83 2.83"/><path d="M2 12h4"/><path d="M18 12h4"/><path d="M4.93 19.07l2.83-2.83"/><path d="M16.24 7.76l2.83-2.83"/></svg></div>
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="M12 18v4"/><circle cx="12" cy="12" r="4"/><path d="M4.93 4.93l2.83 2.83"/><path d="M16.24 16.24l2.83 2.83"/><path d="M2 12h4"/><path d="M18 12h4"/><path d="M4.93 19.07l2.83-2.83"/><path d="M16.24 7.76l2.83-2.83"/></svg></div>
|
||||||
<h3>433MHz Sensors</h3>
|
<h3>433MHz Sensors</h3>
|
||||||
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
|
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="sdr">
|
<div class="feature-card" data-category="signals">
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-9 6 18 3-9h4"/></svg></div>
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-9 6 18 3-9h4"/></svg></div>
|
||||||
<h3>Sub-GHz Analyzer</h3>
|
<h3>Sub-GHz Analyzer</h3>
|
||||||
<p>HackRF-based signal capture and protocol decoding for 300-928 MHz ISM bands with spectrum analysis and replay.</p>
|
<p>HackRF-based signal capture and protocol decoding for 300-928 MHz ISM bands with spectrum analysis and replay.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="sdr">
|
<div class="feature-card" data-category="signals">
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 18.5a6.5 6.5 0 1 1 0-13"/><path d="M17 12h5"/><path d="M12 7V2"/><circle cx="12" cy="12" r="2"/><path d="M8.5 8.5L5 5"/></svg></div>
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 18.5a6.5 6.5 0 1 1 0-13"/><path d="M17 12h5"/><path d="M12 7V2"/><circle cx="12" cy="12" r="2"/><path d="M8.5 8.5L5 5"/></svg></div>
|
||||||
<h3>Listening Post</h3>
|
<h3>Listening Post</h3>
|
||||||
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
|
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="sdr">
|
<div class="feature-card" data-category="intel">
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
|
||||||
<h3>WebSDR</h3>
|
<h3>WebSDR</h3>
|
||||||
<p>Remote HF/shortwave listening via the KiwiSDR network. Access receivers worldwide with real-time audio streaming.</p>
|
<p>Remote HF/shortwave listening via the KiwiSDR network. Access receivers worldwide with real-time audio streaming.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="sdr">
|
<div class="feature-card" data-category="intel">
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M8 8h2v2H8z"/><path d="M14 8h2v2h-2z"/><path d="M8 14h2v2H8z"/><path d="M14 14h2v2h-2z"/><path d="M11 8h2v2h-2z"/><path d="M11 11h2v2h-2z"/></svg></div>
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M8 8h2v2H8z"/><path d="M14 8h2v2h-2z"/><path d="M8 14h2v2H8z"/><path d="M14 14h2v2h-2z"/><path d="M11 8h2v2h-2z"/><path d="M11 11h2v2h-2z"/></svg></div>
|
||||||
<h3>Spy Stations</h3>
|
<h3>Spy Stations</h3>
|
||||||
<p>Number stations and diplomatic HF network database. Frequencies, schedules, and background info from priyom.org.</p>
|
<p>Number stations and diplomatic HF network database. Frequencies, schedules, and background info from priyom.org.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="sdr">
|
<div class="feature-card" data-category="tracking">
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></div>
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></div>
|
||||||
<h3>APRS</h3>
|
<h3>APRS</h3>
|
||||||
<p>Amateur packet radio position reports and telemetry via direwolf. Track amateur radio operators on an interactive map.</p>
|
<p>Amateur packet radio position reports and telemetry via direwolf. Track amateur radio operators on an interactive map.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="sdr">
|
<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 rtl_amr. Receive electric, gas, and water meter broadcasts in real time.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="aviation">
|
<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>
|
||||||
<h3>Aircraft Tracking</h3>
|
<h3>Aircraft Tracking</h3>
|
||||||
<p>Real-time ADS-B tracking with interactive maps, aircraft photos, emergency squawk detection, and range visualization.</p>
|
<p>Real-time ADS-B tracking with interactive maps, aircraft photos, emergency squawk detection, and range visualization.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="aviation">
|
<div class="feature-card" data-category="tracking">
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 8h10"/><path d="M7 12h6"/><path d="M7 16h8"/></svg></div>
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 8h10"/><path d="M7 12h6"/><path d="M7 16h8"/></svg></div>
|
||||||
<h3>ACARS</h3>
|
<h3>ACARS</h3>
|
||||||
<p>Aircraft datalink messages via acarsdec. Decode operational, weather, and position reports from commercial flights.</p>
|
<p>Aircraft datalink messages via acarsdec. Decode operational, weather, and position reports from commercial flights.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="aviation">
|
<div class="feature-card" data-category="tracking">
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/><circle cx="12" cy="12" r="9"/><path d="M3.5 9h17"/><path d="M3.5 15h17"/></svg></div>
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/><circle cx="12" cy="12" r="9"/><path d="M3.5 9h17"/><path d="M3.5 15h17"/></svg></div>
|
||||||
<h3>VDL2</h3>
|
<h3>VDL2</h3>
|
||||||
<p>VHF Data Link Mode 2 aircraft datalink decoding via dumpvdl2. Real-time ACARS-over-AVLC message capture with signal analysis.</p>
|
<p>VHF Data Link Mode 2 aircraft datalink decoding via dumpvdl2. Real-time ACARS-over-AVLC message capture with signal analysis.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="aviation">
|
<div class="feature-card" data-category="tracking">
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20l4-4h3l4-7 2 4h2l5-9"/><path d="M22 20H2"/><path d="M6 16v4"/></svg></div>
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20l4-4h3l4-7 2 4h2l5-9"/><path d="M22 20H2"/><path d="M6 16v4"/></svg></div>
|
||||||
<h3>Vessel Tracking</h3>
|
<h3>Vessel Tracking</h3>
|
||||||
<p>Real-time AIS ship tracking via AIS-catcher. Monitor maritime traffic with vessel details, course, speed, and destination.</p>
|
<p>Real-time AIS ship tracking via AIS-catcher. Monitor maritime traffic with vessel details, course, speed, and destination.</p>
|
||||||
@@ -151,7 +152,7 @@
|
|||||||
<h3>HF SSTV</h3>
|
<h3>HF SSTV</h3>
|
||||||
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
|
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="space">
|
<div class="feature-card" data-category="tracking">
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/><path d="M12 8l4 4-4 4"/></svg></div>
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/><path d="M12 8l4 4-4 4"/></svg></div>
|
||||||
<h3>GPS Tracking</h3>
|
<h3>GPS Tracking</h3>
|
||||||
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
|
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
|
||||||
@@ -176,7 +177,7 @@
|
|||||||
<h3>BT Locate</h3>
|
<h3>BT Locate</h3>
|
||||||
<p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p>
|
<p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card" data-category="wireless">
|
<div class="feature-card" data-category="intel">
|
||||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s-8-4.5-8-11.8A8 8 0 0 1 12 2a8 8 0 0 1 8 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/><path d="M12 2v3"/><path d="M4.93 4.93l2.12 2.12"/><path d="M20 12h-3"/></svg></div>
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s-8-4.5-8-11.8A8 8 0 0 1 12 2a8 8 0 0 1 8 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/><path d="M12 2v3"/><path d="M4.93 4.93l2.12 2.12"/><path d="M20 12h-3"/></svg></div>
|
||||||
<h3>TSCM</h3>
|
<h3>TSCM</h3>
|
||||||
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
|
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
|
||||||
@@ -262,6 +263,10 @@
|
|||||||
<img src="images/websdr.png" alt="WebSDR Remote Listening">
|
<img src="images/websdr.png" alt="WebSDR Remote Listening">
|
||||||
<span class="screenshot-label">WebSDR</span>
|
<span class="screenshot-label">WebSDR</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/aprs.png" alt="APRS Tracker">
|
||||||
|
<span class="screenshot-label">APRS Tracker</span>
|
||||||
|
</div>
|
||||||
<div class="screenshot-item">
|
<div class="screenshot-item">
|
||||||
<img src="images/vdl2.png" alt="VDL2 Aircraft Datalink">
|
<img src="images/vdl2.png" alt="VDL2 Aircraft Datalink">
|
||||||
<span class="screenshot-label">VDL2 Aircraft Datalink</span>
|
<span class="screenshot-label">VDL2 Aircraft Datalink</span>
|
||||||
@@ -270,6 +275,14 @@
|
|||||||
<img src="images/weather-satellite.png" alt="Weather Satellite Decoder">
|
<img src="images/weather-satellite.png" alt="Weather Satellite Decoder">
|
||||||
<span class="screenshot-label">Weather Satellite</span>
|
<span class="screenshot-label">Weather Satellite</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/space-weather-1.png" alt="Space Weather Dashboard">
|
||||||
|
<span class="screenshot-label">Space Weather</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/space-weather-2.png" alt="Space Weather Solar Imagery">
|
||||||
|
<span class="screenshot-label">Space Weather — Solar & Aurora</span>
|
||||||
|
</div>
|
||||||
<div class="screenshot-item">
|
<div class="screenshot-item">
|
||||||
<img src="images/satellite-tracker.png" alt="Satellite Tracker">
|
<img src="images/satellite-tracker.png" alt="Satellite Tracker">
|
||||||
<span class="screenshot-label">Satellite Tracker</span>
|
<span class="screenshot-label">Satellite Tracker</span>
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "intercept"
|
name = "intercept"
|
||||||
version = "2.20.0"
|
version = "2.21.0"
|
||||||
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"
|
||||||
|
|||||||
+20
-26
@@ -21,7 +21,7 @@ import app as app_module
|
|||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
@@ -411,31 +411,25 @@ def stop_acars() -> Response:
|
|||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@acars_bp.route('/stream')
|
@acars_bp.route('/stream')
|
||||||
def stream_acars() -> Response:
|
def stream_acars() -> Response:
|
||||||
"""SSE stream for ACARS messages."""
|
"""SSE stream for ACARS messages."""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('acars', msg, msg.get('type'))
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
source_queue=app_module.acars_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='acars',
|
||||||
try:
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
process_event('acars', msg, msg.get('type'))
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
except queue.Empty:
|
)
|
||||||
now = time.time()
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
yield format_sse({'type': 'keepalive'})
|
return response
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@acars_bp.route('/frequencies')
|
@acars_bp.route('/frequencies')
|
||||||
|
|||||||
+62
-16
@@ -77,6 +77,11 @@ _sbs_error_logged = False # Suppress repeated connection error logs
|
|||||||
# Track ICAOs already looked up in aircraft database (avoid repeated lookups)
|
# Track ICAOs already looked up in aircraft database (avoid repeated lookups)
|
||||||
_looked_up_icaos: set[str] = set()
|
_looked_up_icaos: set[str] = set()
|
||||||
|
|
||||||
|
# Per-client SSE queues for ADS-B stream fanout.
|
||||||
|
_adsb_stream_subscribers: set[queue.Queue] = set()
|
||||||
|
_adsb_stream_subscribers_lock = threading.Lock()
|
||||||
|
_ADSB_STREAM_CLIENT_QUEUE_SIZE = 500
|
||||||
|
|
||||||
# Load aircraft database at module init
|
# Load aircraft database at module init
|
||||||
aircraft_db.load_database()
|
aircraft_db.load_database()
|
||||||
|
|
||||||
@@ -203,6 +208,31 @@ def _parse_int_param(value: str | None, default: int, min_value: int | None = No
|
|||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _broadcast_adsb_update(payload: dict[str, Any]) -> None:
|
||||||
|
"""Fan out a payload to all active ADS-B SSE subscribers."""
|
||||||
|
with _adsb_stream_subscribers_lock:
|
||||||
|
subscribers = tuple(_adsb_stream_subscribers)
|
||||||
|
|
||||||
|
for subscriber in subscribers:
|
||||||
|
try:
|
||||||
|
subscriber.put_nowait(payload)
|
||||||
|
except queue.Full:
|
||||||
|
# Drop oldest queued event for that client and try once more.
|
||||||
|
try:
|
||||||
|
subscriber.get_nowait()
|
||||||
|
subscriber.put_nowait(payload)
|
||||||
|
except (queue.Empty, queue.Full):
|
||||||
|
# Client queue remains saturated; skip this payload.
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
def _adsb_stream_queue_depth() -> int:
|
||||||
|
"""Best-effort aggregate queue depth across connected ADS-B SSE clients."""
|
||||||
|
with _adsb_stream_subscribers_lock:
|
||||||
|
subscribers = tuple(_adsb_stream_subscribers)
|
||||||
|
return sum(subscriber.qsize() for subscriber in subscribers)
|
||||||
|
|
||||||
|
|
||||||
def _get_active_session() -> dict[str, Any] | None:
|
def _get_active_session() -> dict[str, Any] | None:
|
||||||
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE:
|
||||||
return None
|
return None
|
||||||
@@ -481,7 +511,7 @@ def parse_sbs_stream(service_addr):
|
|||||||
for update_icao in pending_updates:
|
for update_icao in pending_updates:
|
||||||
if update_icao in app_module.adsb_aircraft:
|
if update_icao in app_module.adsb_aircraft:
|
||||||
snapshot = app_module.adsb_aircraft[update_icao]
|
snapshot = app_module.adsb_aircraft[update_icao]
|
||||||
app_module.adsb_queue.put({
|
_broadcast_adsb_update({
|
||||||
'type': 'aircraft',
|
'type': 'aircraft',
|
||||||
**snapshot
|
**snapshot
|
||||||
})
|
})
|
||||||
@@ -505,7 +535,7 @@ def parse_sbs_stream(service_addr):
|
|||||||
# Geofence check
|
# Geofence check
|
||||||
_gf_lat = snapshot.get('lat')
|
_gf_lat = snapshot.get('lat')
|
||||||
_gf_lon = snapshot.get('lon')
|
_gf_lon = snapshot.get('lon')
|
||||||
if _gf_lat and _gf_lon:
|
if _gf_lat is not None and _gf_lon is not None:
|
||||||
try:
|
try:
|
||||||
from utils.geofence import get_geofence_manager
|
from utils.geofence import get_geofence_manager
|
||||||
for _gf_evt in get_geofence_manager().check_position(
|
for _gf_evt in get_geofence_manager().check_position(
|
||||||
@@ -580,7 +610,7 @@ def adsb_status():
|
|||||||
'last_message_time': adsb_last_message_time,
|
'last_message_time': adsb_last_message_time,
|
||||||
'aircraft_count': len(app_module.adsb_aircraft),
|
'aircraft_count': len(app_module.adsb_aircraft),
|
||||||
'aircraft': dict(app_module.adsb_aircraft), # Full aircraft data
|
'aircraft': dict(app_module.adsb_aircraft), # Full aircraft data
|
||||||
'queue_size': app_module.adsb_queue.qsize(),
|
'queue_size': _adsb_stream_queue_depth(),
|
||||||
'dump1090_path': find_dump1090(),
|
'dump1090_path': find_dump1090(),
|
||||||
'dump1090_running': dump1090_running,
|
'dump1090_running': dump1090_running,
|
||||||
'port_30003_open': check_dump1090_service() is not None
|
'port_30003_open': check_dump1090_service() is not None
|
||||||
@@ -871,23 +901,39 @@ def stop_adsb():
|
|||||||
@adsb_bp.route('/stream')
|
@adsb_bp.route('/stream')
|
||||||
def stream_adsb():
|
def stream_adsb():
|
||||||
"""SSE stream for ADS-B aircraft."""
|
"""SSE stream for ADS-B aircraft."""
|
||||||
|
client_queue: queue.Queue = queue.Queue(maxsize=_ADSB_STREAM_CLIENT_QUEUE_SIZE)
|
||||||
|
with _adsb_stream_subscribers_lock:
|
||||||
|
_adsb_stream_subscribers.add(client_queue)
|
||||||
|
|
||||||
|
# Prime new clients with current known aircraft so they don't wait for the
|
||||||
|
# next positional update before rendering.
|
||||||
|
for snapshot in list(app_module.adsb_aircraft.values()):
|
||||||
|
try:
|
||||||
|
client_queue.put_nowait({'type': 'aircraft', **snapshot})
|
||||||
|
except queue.Full:
|
||||||
|
break
|
||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
|
||||||
while True:
|
try:
|
||||||
try:
|
while True:
|
||||||
msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
|
||||||
last_keepalive = time.time()
|
|
||||||
try:
|
try:
|
||||||
process_event('adsb', msg, msg.get('type'))
|
msg = client_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
except Exception:
|
last_keepalive = time.time()
|
||||||
pass
|
try:
|
||||||
yield format_sse(msg)
|
process_event('adsb', msg, msg.get('type'))
|
||||||
except queue.Empty:
|
except Exception:
|
||||||
now = time.time()
|
pass
|
||||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
yield format_sse(msg)
|
||||||
yield format_sse({'type': 'keepalive'})
|
except queue.Empty:
|
||||||
last_keepalive = now
|
now = time.time()
|
||||||
|
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
finally:
|
||||||
|
with _adsb_stream_subscribers_lock:
|
||||||
|
_adsb_stream_subscribers.discard(client_queue)
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
|||||||
+13
-19
@@ -18,7 +18,7 @@ import app as app_module
|
|||||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.validation import validate_device_index, validate_gain
|
from utils.validation import validate_device_index, validate_gain
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
@@ -502,25 +502,19 @@ def stop_ais():
|
|||||||
@ais_bp.route('/stream')
|
@ais_bp.route('/stream')
|
||||||
def stream_ais():
|
def stream_ais():
|
||||||
"""SSE stream for AIS vessels."""
|
"""SSE stream for AIS vessels."""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('ais', msg, msg.get('type'))
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = app_module.ais_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
source_queue=app_module.ais_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='ais',
|
||||||
try:
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
process_event('ais', msg, msg.get('type'))
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
except queue.Empty:
|
)
|
||||||
now = time.time()
|
|
||||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
|
||||||
yield format_sse({'type': 'keepalive'})
|
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
return response
|
return response
|
||||||
|
|||||||
+349
-28
@@ -1,33 +1,36 @@
|
|||||||
"""Analytics dashboard: cross-mode summary, activity sparklines, export, geofence CRUD."""
|
"""Analytics dashboard: cross-mode summary, activity sparklines, export, geofence CRUD."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
from flask import Blueprint, Response, jsonify, request
|
from typing import Any
|
||||||
|
|
||||||
import app as app_module
|
from flask import Blueprint, Response, jsonify, request
|
||||||
from utils.analytics import (
|
|
||||||
|
import app as app_module
|
||||||
|
from utils.analytics import (
|
||||||
get_activity_tracker,
|
get_activity_tracker,
|
||||||
get_cross_mode_summary,
|
get_cross_mode_summary,
|
||||||
get_emergency_squawks,
|
get_emergency_squawks,
|
||||||
get_mode_health,
|
get_mode_health,
|
||||||
)
|
)
|
||||||
from utils.flight_correlator import get_flight_correlator
|
from utils.alerts import get_alert_manager
|
||||||
from utils.geofence import get_geofence_manager
|
from utils.flight_correlator import get_flight_correlator
|
||||||
from utils.temporal_patterns import get_pattern_detector
|
from utils.geofence import get_geofence_manager
|
||||||
|
from utils.temporal_patterns import get_pattern_detector
|
||||||
|
|
||||||
analytics_bp = Blueprint('analytics', __name__, url_prefix='/analytics')
|
analytics_bp = Blueprint('analytics', __name__, url_prefix='/analytics')
|
||||||
|
|
||||||
|
|
||||||
# Map mode names to DataStore attribute(s)
|
# Map mode names to DataStore attribute(s)
|
||||||
MODE_STORES: dict[str, list[str]] = {
|
MODE_STORES: dict[str, list[str]] = {
|
||||||
'adsb': ['adsb_aircraft'],
|
'adsb': ['adsb_aircraft'],
|
||||||
'ais': ['ais_vessels'],
|
'ais': ['ais_vessels'],
|
||||||
'wifi': ['wifi_networks', 'wifi_clients'],
|
'wifi': ['wifi_networks', 'wifi_clients'],
|
||||||
'bluetooth': ['bt_devices'],
|
'bluetooth': ['bt_devices'],
|
||||||
'dsc': ['dsc_messages'],
|
'dsc': ['dsc_messages'],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,13 +69,331 @@ def analytics_squawks():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@analytics_bp.route('/patterns')
|
@analytics_bp.route('/patterns')
|
||||||
def analytics_patterns():
|
def analytics_patterns():
|
||||||
"""Return detected temporal patterns."""
|
"""Return detected temporal patterns."""
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'patterns': get_pattern_detector().get_all_patterns(),
|
'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>')
|
@analytics_bp.route('/export/<mode>')
|
||||||
|
|||||||
+21
-26
@@ -21,7 +21,7 @@ from flask import Blueprint, jsonify, request, Response
|
|||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
@@ -48,6 +48,7 @@ APRS_FREQUENCIES = {
|
|||||||
'japan': '144.640',
|
'japan': '144.640',
|
||||||
'china': '144.640',
|
'china': '144.640',
|
||||||
'iss': '145.825',
|
'iss': '145.825',
|
||||||
|
'sonate2': '145.825',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Statistics
|
# Statistics
|
||||||
@@ -1762,31 +1763,25 @@ def stop_aprs() -> Response:
|
|||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@aprs_bp.route('/stream')
|
@aprs_bp.route('/stream')
|
||||||
def stream_aprs() -> Response:
|
def stream_aprs() -> Response:
|
||||||
"""SSE stream for APRS packets."""
|
"""SSE stream for APRS packets."""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('aprs', msg, msg.get('type'))
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = app_module.aprs_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
source_queue=app_module.aprs_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='aprs',
|
||||||
try:
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
process_event('aprs', msg, msg.get('type'))
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
except queue.Empty:
|
)
|
||||||
now = time.time()
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
yield format_sse({'type': 'keepalive'})
|
return response
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@aprs_bp.route('/frequencies')
|
@aprs_bp.route('/frequencies')
|
||||||
|
|||||||
+20
-27
@@ -20,7 +20,7 @@ from flask import Blueprint, jsonify, request, Response
|
|||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.dependencies import check_tool
|
from utils.dependencies import check_tool
|
||||||
from utils.logging import bluetooth_logger as logger
|
from utils.logging import bluetooth_logger as logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.validation import validate_bluetooth_interface
|
from utils.validation import validate_bluetooth_interface
|
||||||
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||||
@@ -553,30 +553,23 @@ def get_bt_devices():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@bluetooth_bp.route('/stream')
|
@bluetooth_bp.route('/stream')
|
||||||
def stream_bt():
|
def stream_bt():
|
||||||
"""SSE stream for Bluetooth events."""
|
"""SSE stream for Bluetooth events."""
|
||||||
def generate():
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('bluetooth', msg, msg.get('type'))
|
||||||
keepalive_interval = 30.0
|
|
||||||
|
response = Response(
|
||||||
while True:
|
sse_stream_fanout(
|
||||||
try:
|
source_queue=app_module.bt_queue,
|
||||||
msg = app_module.bt_queue.get(timeout=1)
|
channel_key='bluetooth',
|
||||||
last_keepalive = time.time()
|
timeout=1.0,
|
||||||
try:
|
keepalive_interval=30.0,
|
||||||
process_event('bluetooth', msg, msg.get('type'))
|
on_message=_on_msg,
|
||||||
except Exception:
|
),
|
||||||
pass
|
mimetype='text/event-stream',
|
||||||
yield format_sse(msg)
|
)
|
||||||
except queue.Empty:
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
now = time.time()
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
if now - last_keepalive >= keepalive_interval:
|
response.headers['Connection'] = 'keep-alive'
|
||||||
yield format_sse({'type': 'keepalive'})
|
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
|
||||||
response.headers['Connection'] = 'keep-alive'
|
|
||||||
return response
|
return response
|
||||||
|
|||||||
+37
-21
@@ -33,16 +33,18 @@ def start_session():
|
|||||||
"""
|
"""
|
||||||
Start a locate session.
|
Start a locate session.
|
||||||
|
|
||||||
Request JSON:
|
Request JSON:
|
||||||
- mac_address: Target MAC address (optional)
|
- mac_address: Target MAC address (optional)
|
||||||
- name_pattern: Target name substring (optional)
|
- name_pattern: Target name substring (optional)
|
||||||
- irk_hex: Identity Resolving Key hex string (optional)
|
- irk_hex: Identity Resolving Key hex string (optional)
|
||||||
- device_id: Device ID from Bluetooth scanner (optional)
|
- device_id: Device ID from Bluetooth scanner (optional)
|
||||||
- known_name: Hand-off device name (optional)
|
- device_key: Stable device key from Bluetooth scanner (optional)
|
||||||
- known_manufacturer: Hand-off manufacturer (optional)
|
- fingerprint_id: Payload fingerprint ID from Bluetooth scanner (optional)
|
||||||
- last_known_rssi: Hand-off last RSSI (optional)
|
- known_name: Hand-off device name (optional)
|
||||||
- environment: 'FREE_SPACE', 'OUTDOOR', 'INDOOR', 'CUSTOM' (default: OUTDOOR)
|
- known_manufacturer: Hand-off manufacturer (optional)
|
||||||
- custom_exponent: Path loss exponent for CUSTOM environment (optional)
|
- last_known_rssi: Hand-off last RSSI (optional)
|
||||||
|
- environment: 'FREE_SPACE', 'OUTDOOR', 'INDOOR', 'CUSTOM' (default: OUTDOOR)
|
||||||
|
- custom_exponent: Path loss exponent for CUSTOM environment (optional)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON with session status.
|
JSON with session status.
|
||||||
@@ -50,19 +52,33 @@ def start_session():
|
|||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
|
|
||||||
# Build target
|
# Build target
|
||||||
target = LocateTarget(
|
target = LocateTarget(
|
||||||
mac_address=data.get('mac_address'),
|
mac_address=data.get('mac_address'),
|
||||||
name_pattern=data.get('name_pattern'),
|
name_pattern=data.get('name_pattern'),
|
||||||
irk_hex=data.get('irk_hex'),
|
irk_hex=data.get('irk_hex'),
|
||||||
device_id=data.get('device_id'),
|
device_id=data.get('device_id'),
|
||||||
known_name=data.get('known_name'),
|
device_key=data.get('device_key'),
|
||||||
known_manufacturer=data.get('known_manufacturer'),
|
fingerprint_id=data.get('fingerprint_id'),
|
||||||
last_known_rssi=data.get('last_known_rssi'),
|
known_name=data.get('known_name'),
|
||||||
)
|
known_manufacturer=data.get('known_manufacturer'),
|
||||||
|
last_known_rssi=data.get('last_known_rssi'),
|
||||||
|
)
|
||||||
|
|
||||||
# At least one identifier required
|
# At least one identifier required
|
||||||
if not any([target.mac_address, target.name_pattern, target.irk_hex, target.device_id]):
|
if not any([
|
||||||
return jsonify({'error': 'At least one target identifier required (mac_address, name_pattern, irk_hex, or device_id)'}), 400
|
target.mac_address,
|
||||||
|
target.name_pattern,
|
||||||
|
target.irk_hex,
|
||||||
|
target.device_id,
|
||||||
|
target.device_key,
|
||||||
|
target.fingerprint_id,
|
||||||
|
]):
|
||||||
|
return jsonify({
|
||||||
|
'error': (
|
||||||
|
'At least one target identifier required '
|
||||||
|
'(mac_address, name_pattern, irk_hex, device_id, device_key, or fingerprint_id)'
|
||||||
|
)
|
||||||
|
}), 400
|
||||||
|
|
||||||
# Parse environment
|
# Parse environment
|
||||||
env_str = data.get('environment', 'OUTDOOR').upper()
|
env_str = data.get('environment', 'OUTDOOR').upper()
|
||||||
|
|||||||
+56
-32
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import queue
|
import queue
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
@@ -36,10 +37,28 @@ from utils.trilateration import (
|
|||||||
|
|
||||||
logger = logging.getLogger('intercept.controller')
|
logger = logging.getLogger('intercept.controller')
|
||||||
|
|
||||||
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
controller_bp = Blueprint('controller', __name__, url_prefix='/controller')
|
||||||
|
|
||||||
# Multi-agent data queue for combined SSE stream
|
# Multi-agent SSE fanout state (per-client queues).
|
||||||
agent_data_queue: queue.Queue = queue.Queue(maxsize=1000)
|
_agent_stream_subscribers: set[queue.Queue] = set()
|
||||||
|
_agent_stream_subscribers_lock = threading.Lock()
|
||||||
|
_AGENT_STREAM_CLIENT_QUEUE_SIZE = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _broadcast_agent_data(payload: dict) -> None:
|
||||||
|
"""Fan out an ingested payload to all active /controller/stream/all clients."""
|
||||||
|
with _agent_stream_subscribers_lock:
|
||||||
|
subscribers = tuple(_agent_stream_subscribers)
|
||||||
|
|
||||||
|
for subscriber in subscribers:
|
||||||
|
try:
|
||||||
|
subscriber.put_nowait(payload)
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
subscriber.get_nowait()
|
||||||
|
subscriber.put_nowait(payload)
|
||||||
|
except (queue.Empty, queue.Full):
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -625,19 +644,16 @@ def ingest_push_data():
|
|||||||
received_at=data.get('received_at')
|
received_at=data.get('received_at')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Emit to SSE stream
|
# Emit to SSE stream (fanout to all connected clients)
|
||||||
try:
|
_broadcast_agent_data({
|
||||||
agent_data_queue.put_nowait({
|
'type': 'agent_data',
|
||||||
'type': 'agent_data',
|
'agent_id': agent['id'],
|
||||||
'agent_id': agent['id'],
|
'agent_name': agent_name,
|
||||||
'agent_name': agent_name,
|
'scan_type': data.get('scan_type'),
|
||||||
'scan_type': data.get('scan_type'),
|
'interface': data.get('interface'),
|
||||||
'interface': data.get('interface'),
|
'payload': data.get('payload'),
|
||||||
'payload': data.get('payload'),
|
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
|
||||||
'received_at': data.get('received_at') or datetime.now(timezone.utc).isoformat()
|
})
|
||||||
})
|
|
||||||
except queue.Full:
|
|
||||||
logger.warning("Agent data queue full, data may be lost")
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'accepted',
|
'status': 'accepted',
|
||||||
@@ -674,27 +690,35 @@ def get_payloads():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@controller_bp.route('/stream/all')
|
@controller_bp.route('/stream/all')
|
||||||
def stream_all_agents():
|
def stream_all_agents():
|
||||||
"""
|
"""
|
||||||
Combined SSE stream for data from all agents.
|
Combined SSE stream for data from all agents.
|
||||||
|
|
||||||
This endpoint streams push data as it arrives from agents.
|
This endpoint streams push data as it arrives from agents.
|
||||||
Each message is tagged with agent_id and agent_name.
|
Each message is tagged with agent_id and agent_name.
|
||||||
"""
|
"""
|
||||||
def generate() -> Generator[str, None, None]:
|
client_queue: queue.Queue = queue.Queue(maxsize=_AGENT_STREAM_CLIENT_QUEUE_SIZE)
|
||||||
last_keepalive = time.time()
|
with _agent_stream_subscribers_lock:
|
||||||
keepalive_interval = 30.0
|
_agent_stream_subscribers.add(client_queue)
|
||||||
|
|
||||||
while True:
|
def generate() -> Generator[str, None, None]:
|
||||||
try:
|
last_keepalive = time.time()
|
||||||
msg = agent_data_queue.get(timeout=1.0)
|
keepalive_interval = 30.0
|
||||||
last_keepalive = time.time()
|
|
||||||
yield format_sse(msg)
|
try:
|
||||||
except queue.Empty:
|
while True:
|
||||||
now = time.time()
|
try:
|
||||||
if now - last_keepalive >= keepalive_interval:
|
msg = client_queue.get(timeout=1.0)
|
||||||
yield format_sse({'type': 'keepalive'})
|
last_keepalive = time.time()
|
||||||
last_keepalive = now
|
yield format_sse(msg)
|
||||||
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
finally:
|
||||||
|
with _agent_stream_subscribers_lock:
|
||||||
|
_agent_stream_subscribers.discard(client_queue)
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
|||||||
+13
-18
@@ -17,7 +17,7 @@ from flask import Blueprint, jsonify, request, Response
|
|||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.process import register_process, unregister_process
|
from utils.process import register_process, unregister_process
|
||||||
from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm
|
from utils.validation import validate_frequency, validate_gain, validate_device_index, validate_ppm
|
||||||
@@ -735,24 +735,19 @@ def stream_dmr_audio() -> Response:
|
|||||||
@dmr_bp.route('/stream')
|
@dmr_bp.route('/stream')
|
||||||
def stream_dmr() -> Response:
|
def stream_dmr() -> Response:
|
||||||
"""SSE stream for DMR decoder events."""
|
"""SSE stream for DMR decoder events."""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('dmr', msg, msg.get('type'))
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
msg = dmr_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
|
||||||
last_keepalive = time.time()
|
|
||||||
try:
|
|
||||||
process_event('dmr', msg, msg.get('type'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
yield format_sse(msg)
|
|
||||||
except queue.Empty:
|
|
||||||
now = time.time()
|
|
||||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
|
||||||
yield format_sse({'type': 'keepalive'})
|
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
response = 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['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
return response
|
return response
|
||||||
|
|||||||
+13
-20
@@ -35,7 +35,7 @@ from utils.database import (
|
|||||||
get_dsc_alert_summary,
|
get_dsc_alert_summary,
|
||||||
)
|
)
|
||||||
from utils.dsc.parser import parse_dsc_message
|
from utils.dsc.parser import parse_dsc_message
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.validation import validate_device_index, validate_gain
|
from utils.validation import validate_device_index, validate_gain
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
@@ -518,26 +518,19 @@ def stop_decoding() -> Response:
|
|||||||
@dsc_bp.route('/stream')
|
@dsc_bp.route('/stream')
|
||||||
def stream() -> Response:
|
def stream() -> Response:
|
||||||
"""SSE stream for real-time DSC messages."""
|
"""SSE stream for real-time DSC messages."""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('dsc', msg, msg.get('type'))
|
||||||
keepalive_interval = 30.0
|
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = app_module.dsc_queue.get(timeout=1)
|
source_queue=app_module.dsc_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='dsc',
|
||||||
try:
|
timeout=1.0,
|
||||||
process_event('dsc', msg, msg.get('type'))
|
keepalive_interval=30.0,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
except queue.Empty:
|
)
|
||||||
now = time.time()
|
|
||||||
if now - last_keepalive >= keepalive_interval:
|
|
||||||
yield format_sse({'type': 'keepalive'})
|
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
+16
-23
@@ -21,7 +21,7 @@ from utils.gps import (
|
|||||||
stop_gpsd_daemon,
|
stop_gpsd_daemon,
|
||||||
)
|
)
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
|
|
||||||
logger = get_logger('intercept.gps')
|
logger = get_logger('intercept.gps')
|
||||||
|
|
||||||
@@ -228,26 +228,19 @@ def get_satellites():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@gps_bp.route('/stream')
|
@gps_bp.route('/stream')
|
||||||
def stream_gps():
|
def stream_gps():
|
||||||
"""SSE stream of GPS position and sky updates."""
|
"""SSE stream of GPS position and sky updates."""
|
||||||
def generate() -> Generator[str, None, None]:
|
response = Response(
|
||||||
last_keepalive = time.time()
|
sse_stream_fanout(
|
||||||
keepalive_interval = 30.0
|
source_queue=_gps_queue,
|
||||||
|
channel_key='gps',
|
||||||
while True:
|
timeout=1.0,
|
||||||
try:
|
keepalive_interval=30.0,
|
||||||
data = _gps_queue.get(timeout=1)
|
),
|
||||||
last_keepalive = time.time()
|
mimetype='text/event-stream',
|
||||||
yield format_sse(data)
|
)
|
||||||
except queue.Empty:
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
now = time.time()
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
if now - last_keepalive >= keepalive_interval:
|
response.headers['Connection'] = 'keep-alive'
|
||||||
yield format_sse({'type': 'keepalive'})
|
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
|
||||||
response.headers['Connection'] = 'keep-alive'
|
|
||||||
return response
|
return response
|
||||||
|
|||||||
+39
-50
@@ -19,7 +19,7 @@ from flask import Blueprint, jsonify, request, Response
|
|||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
SSE_QUEUE_TIMEOUT,
|
SSE_QUEUE_TIMEOUT,
|
||||||
@@ -1179,31 +1179,25 @@ def scanner_status() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/stream')
|
@listening_post_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 generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('listening_scanner', msg, msg.get('type'))
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = scanner_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
source_queue=scanner_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='listening_scanner',
|
||||||
try:
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
process_event('listening_scanner', msg, msg.get('type'))
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
except queue.Empty:
|
)
|
||||||
now = time.time()
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
yield format_sse({'type': 'keepalive'})
|
return response
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/scanner/log')
|
@listening_post_bp.route('/scanner/log')
|
||||||
@@ -1831,30 +1825,25 @@ def stop_waterfall() -> Response:
|
|||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@listening_post_bp.route('/waterfall/stream')
|
@listening_post_bp.route('/waterfall/stream')
|
||||||
def stream_waterfall() -> Response:
|
def stream_waterfall() -> Response:
|
||||||
"""SSE stream for waterfall data."""
|
"""SSE stream for waterfall data."""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('waterfall', msg, msg.get('type'))
|
||||||
while True:
|
|
||||||
try:
|
response = Response(
|
||||||
msg = waterfall_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
sse_stream_fanout(
|
||||||
last_keepalive = time.time()
|
source_queue=waterfall_queue,
|
||||||
try:
|
channel_key='listening_waterfall',
|
||||||
process_event('waterfall', msg, msg.get('type'))
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
except Exception:
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
pass
|
on_message=_on_msg,
|
||||||
yield format_sse(msg)
|
),
|
||||||
except queue.Empty:
|
mimetype='text/event-stream',
|
||||||
now = time.time()
|
)
|
||||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
yield format_sse({'type': 'keepalive'})
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
last_keepalive = now
|
return response
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
|
||||||
return response
|
|
||||||
def _downsample_bins(values: list[float], target: int) -> list[float]:
|
def _downsample_bins(values: list[float], target: int) -> list[float]:
|
||||||
"""Downsample bins to a target length using simple averaging."""
|
"""Downsample bins to a target length using simple averaging."""
|
||||||
if target <= 0 or len(values) <= target:
|
if target <= 0 or len(values) <= target:
|
||||||
|
|||||||
+15
-22
@@ -17,7 +17,7 @@ from typing import Generator
|
|||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.meshtastic import (
|
from utils.meshtastic import (
|
||||||
get_meshtastic_client,
|
get_meshtastic_client,
|
||||||
start_meshtastic,
|
start_meshtastic,
|
||||||
@@ -453,8 +453,8 @@ def get_messages():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@meshtastic_bp.route('/stream')
|
@meshtastic_bp.route('/stream')
|
||||||
def stream_messages():
|
def stream_messages():
|
||||||
"""
|
"""
|
||||||
SSE stream of Meshtastic messages.
|
SSE stream of Meshtastic messages.
|
||||||
|
|
||||||
@@ -469,25 +469,18 @@ def stream_messages():
|
|||||||
Returns:
|
Returns:
|
||||||
SSE stream (text/event-stream)
|
SSE stream (text/event-stream)
|
||||||
"""
|
"""
|
||||||
def generate() -> Generator[str, None, None]:
|
response = Response(
|
||||||
last_keepalive = time.time()
|
sse_stream_fanout(
|
||||||
keepalive_interval = 30.0
|
source_queue=_mesh_queue,
|
||||||
|
channel_key='meshtastic',
|
||||||
while True:
|
timeout=1.0,
|
||||||
try:
|
keepalive_interval=30.0,
|
||||||
msg = _mesh_queue.get(timeout=1)
|
),
|
||||||
last_keepalive = time.time()
|
mimetype='text/event-stream',
|
||||||
yield format_sse(msg)
|
)
|
||||||
except queue.Empty:
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
now = time.time()
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
if now - last_keepalive >= keepalive_interval:
|
response.headers['Connection'] = 'keep-alive'
|
||||||
yield format_sse({'type': 'keepalive'})
|
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
|
||||||
response.headers['Connection'] = 'keep-alive'
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+20
-29
@@ -24,7 +24,7 @@ from utils.validation import (
|
|||||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
||||||
@@ -538,31 +538,22 @@ def toggle_logging() -> Response:
|
|||||||
return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
|
return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
|
||||||
|
|
||||||
|
|
||||||
@pager_bp.route('/stream')
|
@pager_bp.route('/stream')
|
||||||
def stream() -> Response:
|
def stream() -> Response:
|
||||||
import json
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
|
process_event('pager', msg, msg.get('type'))
|
||||||
def generate() -> Generator[str, None, None]:
|
|
||||||
last_keepalive = time.time()
|
response = Response(
|
||||||
keepalive_interval = 30.0 # Send keepalive every 30 seconds instead of 1 second
|
sse_stream_fanout(
|
||||||
|
source_queue=app_module.output_queue,
|
||||||
while True:
|
channel_key='pager',
|
||||||
try:
|
timeout=1.0,
|
||||||
msg = app_module.output_queue.get(timeout=1)
|
keepalive_interval=30.0,
|
||||||
last_keepalive = time.time()
|
on_message=_on_msg,
|
||||||
try:
|
),
|
||||||
process_event('pager', msg, msg.get('type'))
|
mimetype='text/event-stream',
|
||||||
except Exception:
|
)
|
||||||
pass
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
yield format_sse(msg)
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
except queue.Empty:
|
response.headers['Connection'] = 'keep-alive'
|
||||||
now = time.time()
|
return response
|
||||||
if now - last_keepalive >= keepalive_interval:
|
|
||||||
yield format_sse({'type': 'keepalive'})
|
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
|
||||||
response.headers['Connection'] = 'keep-alive'
|
|
||||||
return response
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, send_file
|
from flask import Blueprint, jsonify, request, send_file
|
||||||
@@ -107,3 +108,59 @@ def download_recording(session_id: str):
|
|||||||
as_attachment=True,
|
as_attachment=True,
|
||||||
download_name=file_path.name,
|
download_name=file_path.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@recordings_bp.route('/<session_id>/events', methods=['GET'])
|
||||||
|
def get_recording_events(session_id: str):
|
||||||
|
"""Return parsed events from a recording for in-app replay."""
|
||||||
|
manager = get_recording_manager()
|
||||||
|
rec = manager.get_recording(session_id)
|
||||||
|
if not rec:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Recording not found'}), 404
|
||||||
|
|
||||||
|
file_path = Path(rec['file_path'])
|
||||||
|
try:
|
||||||
|
resolved_root = RECORDING_ROOT.resolve()
|
||||||
|
resolved_file = file_path.resolve()
|
||||||
|
if resolved_root not in resolved_file.parents:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
||||||
|
except Exception:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid recording path'}), 400
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Recording file missing'}), 404
|
||||||
|
|
||||||
|
limit = max(1, min(5000, request.args.get('limit', default=500, type=int)))
|
||||||
|
offset = max(0, request.args.get('offset', default=0, type=int))
|
||||||
|
|
||||||
|
events: list[dict] = []
|
||||||
|
seen = 0
|
||||||
|
with file_path.open('r', encoding='utf-8', errors='replace') as fh:
|
||||||
|
for idx, line in enumerate(fh):
|
||||||
|
if idx < offset:
|
||||||
|
continue
|
||||||
|
if seen >= limit:
|
||||||
|
break
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
events.append(json.loads(line))
|
||||||
|
seen += 1
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'recording': {
|
||||||
|
'id': rec['id'],
|
||||||
|
'mode': rec['mode'],
|
||||||
|
'started_at': rec['started_at'],
|
||||||
|
'stopped_at': rec['stopped_at'],
|
||||||
|
'event_count': rec['event_count'],
|
||||||
|
},
|
||||||
|
'offset': offset,
|
||||||
|
'limit': limit,
|
||||||
|
'returned': len(events),
|
||||||
|
'events': events,
|
||||||
|
})
|
||||||
|
|||||||
+13
-20
@@ -17,7 +17,7 @@ from utils.logging import sensor_logger as logger
|
|||||||
from utils.validation import (
|
from utils.validation import (
|
||||||
validate_frequency, validate_device_index, validate_gain, validate_ppm
|
validate_frequency, validate_device_index, validate_gain, validate_ppm
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
|
|
||||||
@@ -288,26 +288,19 @@ def stop_rtlamr() -> Response:
|
|||||||
|
|
||||||
@rtlamr_bp.route('/stream_rtlamr')
|
@rtlamr_bp.route('/stream_rtlamr')
|
||||||
def stream_rtlamr() -> Response:
|
def stream_rtlamr() -> Response:
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('rtlamr', msg, msg.get('type'))
|
||||||
keepalive_interval = 30.0
|
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = app_module.rtlamr_queue.get(timeout=1)
|
source_queue=app_module.rtlamr_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='rtlamr',
|
||||||
try:
|
timeout=1.0,
|
||||||
process_event('rtlamr', msg, msg.get('type'))
|
keepalive_interval=30.0,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
except queue.Empty:
|
)
|
||||||
now = time.time()
|
|
||||||
if now - last_keepalive >= keepalive_interval:
|
|
||||||
yield format_sse({'type': 'keepalive'})
|
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
+20
-27
@@ -18,7 +18,7 @@ from utils.validation import (
|
|||||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.process import safe_terminate, register_process, unregister_process
|
from utils.process import safe_terminate, register_process, unregister_process
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
@@ -272,32 +272,25 @@ def stop_sensor() -> Response:
|
|||||||
return jsonify({'status': 'not_running'})
|
return jsonify({'status': 'not_running'})
|
||||||
|
|
||||||
|
|
||||||
@sensor_bp.route('/stream_sensor')
|
@sensor_bp.route('/stream_sensor')
|
||||||
def stream_sensor() -> Response:
|
def stream_sensor() -> Response:
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('sensor', msg, msg.get('type'))
|
||||||
keepalive_interval = 30.0
|
|
||||||
|
response = Response(
|
||||||
while True:
|
sse_stream_fanout(
|
||||||
try:
|
source_queue=app_module.sensor_queue,
|
||||||
msg = app_module.sensor_queue.get(timeout=1)
|
channel_key='sensor',
|
||||||
last_keepalive = time.time()
|
timeout=1.0,
|
||||||
try:
|
keepalive_interval=30.0,
|
||||||
process_event('sensor', msg, msg.get('type'))
|
on_message=_on_msg,
|
||||||
except Exception:
|
),
|
||||||
pass
|
mimetype='text/event-stream',
|
||||||
yield format_sse(msg)
|
)
|
||||||
except queue.Empty:
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
now = time.time()
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
if now - last_keepalive >= keepalive_interval:
|
response.headers['Connection'] = 'keep-alive'
|
||||||
yield format_sse({'type': 'keepalive'})
|
return response
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
|
||||||
response.headers['Connection'] = 'keep-alive'
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@sensor_bp.route('/sensor/rssi_history')
|
@sensor_bp.route('/sensor/rssi_history')
|
||||||
|
|||||||
+52
-29
@@ -15,7 +15,7 @@ from flask import Blueprint, jsonify, request, Response, send_file
|
|||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.sstv import (
|
from utils.sstv import (
|
||||||
get_sstv_decoder,
|
get_sstv_decoder,
|
||||||
@@ -27,6 +27,12 @@ logger = get_logger('intercept.sstv')
|
|||||||
|
|
||||||
sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
|
sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
|
||||||
|
|
||||||
|
# ISS SSTV runs on a fixed downlink; allow a small entry tolerance so users
|
||||||
|
# can type nearby values and still land on the canonical center frequency.
|
||||||
|
ISS_SSTV_MODULATION = 'fm'
|
||||||
|
ISS_SSTV_FREQUENCIES = (ISS_SSTV_FREQ,)
|
||||||
|
ISS_SSTV_FREQ_TOLERANCE_MHZ = 0.05
|
||||||
|
|
||||||
# Queue for SSE progress streaming
|
# Queue for SSE progress streaming
|
||||||
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
|
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||||
|
|
||||||
@@ -46,6 +52,14 @@ def _progress_callback(data: dict) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_iss_frequency(frequency_mhz: float) -> float | None:
|
||||||
|
"""Snap near-match user input to a supported ISS SSTV center frequency."""
|
||||||
|
for supported in ISS_SSTV_FREQUENCIES:
|
||||||
|
if abs(frequency_mhz - supported) <= ISS_SSTV_FREQ_TOLERANCE_MHZ:
|
||||||
|
return supported
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@sstv_bp.route('/status')
|
@sstv_bp.route('/status')
|
||||||
def get_status():
|
def get_status():
|
||||||
"""
|
"""
|
||||||
@@ -62,6 +76,7 @@ def get_status():
|
|||||||
'decoder': decoder.decoder_available,
|
'decoder': decoder.decoder_available,
|
||||||
'running': decoder.is_running,
|
'running': decoder.is_running,
|
||||||
'iss_frequency': ISS_SSTV_FREQ,
|
'iss_frequency': ISS_SSTV_FREQ,
|
||||||
|
'modulation': ISS_SSTV_MODULATION,
|
||||||
'image_count': len(decoder.get_images()),
|
'image_count': len(decoder.get_images()),
|
||||||
'doppler_enabled': decoder.doppler_enabled,
|
'doppler_enabled': decoder.doppler_enabled,
|
||||||
}
|
}
|
||||||
@@ -82,6 +97,7 @@ def start_decoder():
|
|||||||
JSON body (optional):
|
JSON body (optional):
|
||||||
{
|
{
|
||||||
"frequency": 145.800, // Frequency in MHz (default: ISS 145.800)
|
"frequency": 145.800, // Frequency in MHz (default: ISS 145.800)
|
||||||
|
"modulation": "fm", // ISS mode is FM-only
|
||||||
"device": 0, // RTL-SDR device index
|
"device": 0, // RTL-SDR device index
|
||||||
"latitude": 40.7128, // Observer latitude for Doppler correction
|
"latitude": 40.7128, // Observer latitude for Doppler correction
|
||||||
"longitude": -74.0060 // Observer longitude for Doppler correction
|
"longitude": -74.0060 // Observer longitude for Doppler correction
|
||||||
@@ -106,6 +122,7 @@ def start_decoder():
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'already_running',
|
'status': 'already_running',
|
||||||
'frequency': ISS_SSTV_FREQ,
|
'frequency': ISS_SSTV_FREQ,
|
||||||
|
'modulation': ISS_SSTV_MODULATION,
|
||||||
'doppler_enabled': decoder.doppler_enabled
|
'doppler_enabled': decoder.doppler_enabled
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -119,18 +136,29 @@ def start_decoder():
|
|||||||
# Get parameters
|
# Get parameters
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
frequency = data.get('frequency', ISS_SSTV_FREQ)
|
frequency = data.get('frequency', ISS_SSTV_FREQ)
|
||||||
|
modulation = str(data.get('modulation', ISS_SSTV_MODULATION)).strip().lower()
|
||||||
device_index = data.get('device', 0)
|
device_index = data.get('device', 0)
|
||||||
latitude = data.get('latitude')
|
latitude = data.get('latitude')
|
||||||
longitude = data.get('longitude')
|
longitude = data.get('longitude')
|
||||||
|
|
||||||
|
# Validate modulation (ISS mode is FM-only)
|
||||||
|
if modulation != ISS_SSTV_MODULATION:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Modulation must be {ISS_SSTV_MODULATION} for ISS SSTV mode'
|
||||||
|
}), 400
|
||||||
|
|
||||||
# Validate frequency
|
# Validate frequency
|
||||||
try:
|
try:
|
||||||
frequency = float(frequency)
|
frequency = float(frequency)
|
||||||
if not (100 <= frequency <= 500): # VHF range
|
normalized_frequency = _normalize_iss_frequency(frequency)
|
||||||
|
if normalized_frequency is None:
|
||||||
|
supported = ', '.join(f'{freq:.3f}' for freq in ISS_SSTV_FREQUENCIES)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Frequency must be between 100-500 MHz'
|
'message': f'Supported ISS SSTV frequency: {supported} MHz FM'
|
||||||
}), 400
|
}), 400
|
||||||
|
frequency = normalized_frequency
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
@@ -178,7 +206,8 @@ def start_decoder():
|
|||||||
frequency=frequency,
|
frequency=frequency,
|
||||||
device_index=device_index,
|
device_index=device_index,
|
||||||
latitude=latitude,
|
latitude=latitude,
|
||||||
longitude=longitude
|
longitude=longitude,
|
||||||
|
modulation=ISS_SSTV_MODULATION,
|
||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
@@ -187,6 +216,7 @@ def start_decoder():
|
|||||||
result = {
|
result = {
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
'frequency': frequency,
|
'frequency': frequency,
|
||||||
|
'modulation': ISS_SSTV_MODULATION,
|
||||||
'device': device_index,
|
'device': device_index,
|
||||||
'doppler_enabled': decoder.doppler_enabled
|
'doppler_enabled': decoder.doppler_enabled
|
||||||
}
|
}
|
||||||
@@ -379,8 +409,8 @@ def delete_all_images():
|
|||||||
return jsonify({'status': 'ok', 'deleted': count})
|
return jsonify({'status': 'ok', 'deleted': count})
|
||||||
|
|
||||||
|
|
||||||
@sstv_bp.route('/stream')
|
@sstv_bp.route('/stream')
|
||||||
def stream_progress():
|
def stream_progress():
|
||||||
"""
|
"""
|
||||||
SSE stream of SSTV decode progress.
|
SSE stream of SSTV decode progress.
|
||||||
|
|
||||||
@@ -392,29 +422,22 @@ def stream_progress():
|
|||||||
Returns:
|
Returns:
|
||||||
SSE stream (text/event-stream)
|
SSE stream (text/event-stream)
|
||||||
"""
|
"""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('sstv', msg, msg.get('type'))
|
||||||
keepalive_interval = 30.0
|
|
||||||
|
response = Response(
|
||||||
while True:
|
sse_stream_fanout(
|
||||||
try:
|
source_queue=_sstv_queue,
|
||||||
progress = _sstv_queue.get(timeout=1)
|
channel_key='sstv',
|
||||||
last_keepalive = time.time()
|
timeout=1.0,
|
||||||
try:
|
keepalive_interval=30.0,
|
||||||
process_event('sstv', progress, progress.get('type'))
|
on_message=_on_msg,
|
||||||
except Exception:
|
),
|
||||||
pass
|
mimetype='text/event-stream',
|
||||||
yield format_sse(progress)
|
)
|
||||||
except queue.Empty:
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
now = time.time()
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
if now - last_keepalive >= keepalive_interval:
|
response.headers['Connection'] = 'keep-alive'
|
||||||
yield format_sse({'type': 'keepalive'})
|
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
|
||||||
response.headers['Connection'] = 'keep-alive'
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+37
-21
@@ -13,8 +13,9 @@ from pathlib import Path
|
|||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request, send_file
|
from flask import Blueprint, Response, jsonify, request, send_file
|
||||||
|
|
||||||
|
import app as app_module
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.sstv import (
|
from utils.sstv import (
|
||||||
get_general_sstv_decoder,
|
get_general_sstv_decoder,
|
||||||
@@ -27,6 +28,9 @@ sstv_general_bp = Blueprint('sstv_general', __name__, url_prefix='/sstv-general'
|
|||||||
# Queue for SSE progress streaming
|
# Queue for SSE progress streaming
|
||||||
_sstv_general_queue: queue.Queue = queue.Queue(maxsize=100)
|
_sstv_general_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||||
|
|
||||||
|
# Track which device is being used
|
||||||
|
_sstv_general_active_device: int | None = None
|
||||||
|
|
||||||
# Predefined SSTV frequencies
|
# Predefined SSTV frequencies
|
||||||
SSTV_FREQUENCIES = [
|
SSTV_FREQUENCIES = [
|
||||||
{'band': '80 m', 'frequency': 3.845, 'modulation': 'lsb', 'notes': 'Common US SSTV calling frequency', 'type': 'Terrestrial HF'},
|
{'band': '80 m', 'frequency': 3.845, 'modulation': 'lsb', 'notes': 'Common US SSTV calling frequency', 'type': 'Terrestrial HF'},
|
||||||
@@ -40,7 +44,7 @@ SSTV_FREQUENCIES = [
|
|||||||
{'band': '15 m', 'frequency': 21.340, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
|
{'band': '15 m', 'frequency': 21.340, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
|
||||||
{'band': '10 m', 'frequency': 28.680, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
|
{'band': '10 m', 'frequency': 28.680, 'modulation': 'usb', 'notes': 'International calling frequency', 'type': 'Terrestrial HF'},
|
||||||
{'band': '6 m', 'frequency': 50.950, 'modulation': 'usb', 'notes': 'SSTV calling (less common)', 'type': 'Terrestrial VHF'},
|
{'band': '6 m', 'frequency': 50.950, 'modulation': 'usb', 'notes': 'SSTV calling (less common)', 'type': 'Terrestrial VHF'},
|
||||||
{'band': '2 m', 'frequency': 145.625, 'modulation': 'fm', 'notes': 'Australia/common simplex (FM sometimes used)', 'type': 'Terrestrial VHF'},
|
{'band': '2 m', 'frequency': 145.500, 'modulation': 'fm', 'notes': 'Australia/common simplex (FM sometimes used)', 'type': 'Terrestrial VHF'},
|
||||||
{'band': '70 cm', 'frequency': 433.775, 'modulation': 'fm', 'notes': 'Australia/common simplex', 'type': 'Terrestrial UHF'},
|
{'band': '70 cm', 'frequency': 433.775, 'modulation': 'fm', 'notes': 'Australia/common simplex', 'type': 'Terrestrial UHF'},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -150,6 +154,17 @@ def start_decoder():
|
|||||||
'message': 'Modulation must be fm, usb, or lsb',
|
'message': 'Modulation must be fm, usb, or lsb',
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
|
# Claim SDR device
|
||||||
|
global _sstv_general_active_device
|
||||||
|
device_int = int(device_index)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'sstv_general')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error,
|
||||||
|
}), 409
|
||||||
|
|
||||||
# Set callback and start
|
# Set callback and start
|
||||||
decoder.set_callback(_progress_callback)
|
decoder.set_callback(_progress_callback)
|
||||||
success = decoder.start(
|
success = decoder.start(
|
||||||
@@ -159,6 +174,7 @@ def start_decoder():
|
|||||||
)
|
)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
|
_sstv_general_active_device = device_int
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
'frequency': frequency,
|
'frequency': frequency,
|
||||||
@@ -166,6 +182,7 @@ def start_decoder():
|
|||||||
'device': device_index,
|
'device': device_index,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
|
app_module.release_sdr_device(device_int)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Failed to start decoder',
|
'message': 'Failed to start decoder',
|
||||||
@@ -175,8 +192,14 @@ def start_decoder():
|
|||||||
@sstv_general_bp.route('/stop', methods=['POST'])
|
@sstv_general_bp.route('/stop', methods=['POST'])
|
||||||
def stop_decoder():
|
def stop_decoder():
|
||||||
"""Stop general SSTV decoder."""
|
"""Stop general SSTV decoder."""
|
||||||
|
global _sstv_general_active_device
|
||||||
decoder = get_general_sstv_decoder()
|
decoder = get_general_sstv_decoder()
|
||||||
decoder.stop()
|
decoder.stop()
|
||||||
|
|
||||||
|
if _sstv_general_active_device is not None:
|
||||||
|
app_module.release_sdr_device(_sstv_general_active_device)
|
||||||
|
_sstv_general_active_device = None
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@@ -266,26 +289,19 @@ def delete_all_images():
|
|||||||
@sstv_general_bp.route('/stream')
|
@sstv_general_bp.route('/stream')
|
||||||
def stream_progress():
|
def stream_progress():
|
||||||
"""SSE stream of SSTV decode progress."""
|
"""SSE stream of SSTV decode progress."""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('sstv_general', msg, msg.get('type'))
|
||||||
keepalive_interval = 30.0
|
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
progress = _sstv_general_queue.get(timeout=1)
|
source_queue=_sstv_general_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='sstv_general',
|
||||||
try:
|
timeout=1.0,
|
||||||
process_event('sstv_general', progress, progress.get('type'))
|
keepalive_interval=30.0,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(progress)
|
mimetype='text/event-stream',
|
||||||
except queue.Empty:
|
)
|
||||||
now = time.time()
|
|
||||||
if now - last_keepalive >= keepalive_interval:
|
|
||||||
yield format_sse({'type': 'keepalive'})
|
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
|||||||
+10
-16
@@ -61,6 +61,7 @@ from utils.tscm.device_identity import (
|
|||||||
ingest_wifi_dict,
|
ingest_wifi_dict,
|
||||||
)
|
)
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
|
|
||||||
# Import unified Bluetooth scanner helper for TSCM integration
|
# Import unified Bluetooth scanner helper for TSCM integration
|
||||||
try:
|
try:
|
||||||
@@ -629,24 +630,17 @@ def sweep_status():
|
|||||||
@tscm_bp.route('/sweep/stream')
|
@tscm_bp.route('/sweep/stream')
|
||||||
def sweep_stream():
|
def sweep_stream():
|
||||||
"""SSE stream for real-time sweep updates."""
|
"""SSE stream for real-time sweep updates."""
|
||||||
def generate():
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
while True:
|
process_event('tscm', msg, msg.get('type'))
|
||||||
try:
|
|
||||||
if tscm_queue:
|
|
||||||
msg = tscm_queue.get(timeout=1)
|
|
||||||
try:
|
|
||||||
process_event('tscm', msg, msg.get('type'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
yield f"data: {json.dumps(msg)}\n\n"
|
|
||||||
else:
|
|
||||||
time.sleep(1)
|
|
||||||
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
|
|
||||||
except queue.Empty:
|
|
||||||
yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
|
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
generate(),
|
sse_stream_fanout(
|
||||||
|
source_queue=tscm_queue,
|
||||||
|
channel_key='tscm',
|
||||||
|
timeout=1.0,
|
||||||
|
keepalive_interval=30.0,
|
||||||
|
on_message=_on_msg,
|
||||||
|
),
|
||||||
mimetype='text/event-stream',
|
mimetype='text/event-stream',
|
||||||
headers={
|
headers={
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache',
|
||||||
|
|||||||
+76
-57
@@ -1,13 +1,17 @@
|
|||||||
"""VDL2 aircraft datalink routes."""
|
"""VDL2 aircraft datalink routes."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import io
|
||||||
import queue
|
import json
|
||||||
import shutil
|
import os
|
||||||
import subprocess
|
import platform
|
||||||
import threading
|
import pty
|
||||||
import time
|
import queue
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
|
|
||||||
@@ -17,7 +21,7 @@ import app as app_module
|
|||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
@@ -51,17 +55,22 @@ def find_dumpvdl2():
|
|||||||
return shutil.which('dumpvdl2')
|
return shutil.which('dumpvdl2')
|
||||||
|
|
||||||
|
|
||||||
def stream_vdl2_output(process: subprocess.Popen) -> None:
|
def stream_vdl2_output(process: subprocess.Popen, is_text_mode: bool = False) -> None:
|
||||||
"""Stream dumpvdl2 JSON output to queue."""
|
"""Stream dumpvdl2 JSON output to queue."""
|
||||||
global vdl2_message_count, vdl2_last_message_time
|
global vdl2_message_count, vdl2_last_message_time
|
||||||
|
|
||||||
try:
|
try:
|
||||||
app_module.vdl2_queue.put({'type': 'status', 'status': 'started'})
|
app_module.vdl2_queue.put({'type': 'status', 'status': 'started'})
|
||||||
|
|
||||||
for line in iter(process.stdout.readline, b''):
|
# Use appropriate sentinel based on mode (text mode for pty on macOS)
|
||||||
line = line.decode('utf-8', errors='replace').strip()
|
sentinel = '' if is_text_mode else b''
|
||||||
if not line:
|
for line in iter(process.stdout.readline, sentinel):
|
||||||
continue
|
if is_text_mode:
|
||||||
|
line = line.strip()
|
||||||
|
else:
|
||||||
|
line = line.decode('utf-8', errors='replace').strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(line)
|
data = json.loads(line)
|
||||||
@@ -243,12 +252,28 @@ def start_vdl2() -> Response:
|
|||||||
logger.info(f"Starting VDL2 decoder: {' '.join(cmd)}")
|
logger.info(f"Starting VDL2 decoder: {' '.join(cmd)}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
process = subprocess.Popen(
|
is_text_mode = False
|
||||||
cmd,
|
|
||||||
stdout=subprocess.PIPE,
|
# On macOS, use pty to avoid stdout buffering issues
|
||||||
stderr=subprocess.PIPE,
|
if platform.system() == 'Darwin':
|
||||||
start_new_session=True
|
master_fd, slave_fd = pty.openpty()
|
||||||
)
|
process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=slave_fd,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
start_new_session=True
|
||||||
|
)
|
||||||
|
os.close(slave_fd)
|
||||||
|
# Wrap master_fd as a text file for line-buffered reading
|
||||||
|
process.stdout = io.open(master_fd, 'r', buffering=1)
|
||||||
|
is_text_mode = True
|
||||||
|
else:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
start_new_session=True
|
||||||
|
)
|
||||||
|
|
||||||
# Wait briefly to check if process started
|
# Wait briefly to check if process started
|
||||||
time.sleep(PROCESS_START_WAIT)
|
time.sleep(PROCESS_START_WAIT)
|
||||||
@@ -270,12 +295,12 @@ def start_vdl2() -> Response:
|
|||||||
app_module.vdl2_process = process
|
app_module.vdl2_process = process
|
||||||
register_process(process)
|
register_process(process)
|
||||||
|
|
||||||
# Start output streaming thread
|
# Start output streaming thread
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=stream_vdl2_output,
|
target=stream_vdl2_output,
|
||||||
args=(process,),
|
args=(process, is_text_mode),
|
||||||
daemon=True
|
daemon=True
|
||||||
)
|
)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -324,31 +349,25 @@ def stop_vdl2() -> Response:
|
|||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@vdl2_bp.route('/stream')
|
@vdl2_bp.route('/stream')
|
||||||
def stream_vdl2() -> Response:
|
def stream_vdl2() -> Response:
|
||||||
"""SSE stream for VDL2 messages."""
|
"""SSE stream for VDL2 messages."""
|
||||||
def generate() -> Generator[str, None, None]:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('vdl2', msg, msg.get('type'))
|
||||||
|
|
||||||
while True:
|
response = Response(
|
||||||
try:
|
sse_stream_fanout(
|
||||||
msg = app_module.vdl2_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
source_queue=app_module.vdl2_queue,
|
||||||
last_keepalive = time.time()
|
channel_key='vdl2',
|
||||||
try:
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
process_event('vdl2', msg, msg.get('type'))
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
except Exception:
|
on_message=_on_msg,
|
||||||
pass
|
),
|
||||||
yield format_sse(msg)
|
mimetype='text/event-stream',
|
||||||
except queue.Empty:
|
)
|
||||||
now = time.time()
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
yield format_sse({'type': 'keepalive'})
|
return response
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@vdl2_bp.route('/frequencies')
|
@vdl2_bp.route('/frequencies')
|
||||||
|
|||||||
+20
-13
@@ -563,19 +563,26 @@ def enable_schedule():
|
|||||||
'message': 'Invalid parameter value'
|
'message': 'Invalid parameter value'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
scheduler = get_weather_sat_scheduler()
|
scheduler = get_weather_sat_scheduler()
|
||||||
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
||||||
|
|
||||||
result = scheduler.enable(
|
try:
|
||||||
lat=lat,
|
result = scheduler.enable(
|
||||||
lon=lon,
|
lat=lat,
|
||||||
min_elevation=min_elev,
|
lon=lon,
|
||||||
device=device,
|
min_elevation=min_elev,
|
||||||
gain=gain_val,
|
device=device,
|
||||||
bias_t=bool(data.get('bias_t', False)),
|
gain=gain_val,
|
||||||
)
|
bias_t=bool(data.get('bias_t', False)),
|
||||||
|
)
|
||||||
return jsonify({'status': 'ok', **result})
|
except Exception as e:
|
||||||
|
logger.exception("Failed to enable weather sat scheduler")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Failed to enable scheduler'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
return jsonify({'status': 'ok', **result})
|
||||||
|
|
||||||
|
|
||||||
@weather_sat_bp.route('/schedule/disable', methods=['POST'])
|
@weather_sat_bp.route('/schedule/disable', methods=['POST'])
|
||||||
|
|||||||
+35
-50
@@ -20,7 +20,7 @@ from utils.dependencies import check_tool, get_tool_path
|
|||||||
from utils.logging import wifi_logger as logger
|
from utils.logging import wifi_logger as logger
|
||||||
from utils.process import is_valid_mac, is_valid_channel
|
from utils.process import is_valid_mac, is_valid_channel
|
||||||
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse, sse_stream_fanout
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from data.oui import get_manufacturer
|
from data.oui import get_manufacturer
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
@@ -1132,33 +1132,26 @@ def get_wifi_networks():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/stream')
|
@wifi_bp.route('/stream')
|
||||||
def stream_wifi():
|
def stream_wifi():
|
||||||
"""SSE stream for WiFi events."""
|
"""SSE stream for WiFi events."""
|
||||||
def generate():
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
last_keepalive = time.time()
|
process_event('wifi', msg, msg.get('type'))
|
||||||
keepalive_interval = 30.0
|
|
||||||
|
response = Response(
|
||||||
while True:
|
sse_stream_fanout(
|
||||||
try:
|
source_queue=app_module.wifi_queue,
|
||||||
msg = app_module.wifi_queue.get(timeout=1)
|
channel_key='wifi',
|
||||||
last_keepalive = time.time()
|
timeout=1.0,
|
||||||
try:
|
keepalive_interval=30.0,
|
||||||
process_event('wifi', msg, msg.get('type'))
|
on_message=_on_msg,
|
||||||
except Exception:
|
),
|
||||||
pass
|
mimetype='text/event-stream',
|
||||||
yield format_sse(msg)
|
)
|
||||||
except queue.Empty:
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
now = time.time()
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
if now - last_keepalive >= keepalive_interval:
|
response.headers['Connection'] = 'keep-alive'
|
||||||
yield format_sse({'type': 'keepalive'})
|
return response
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
|
||||||
response.headers['Connection'] = 'keep-alive'
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -1545,8 +1538,8 @@ def v2_deauth_status():
|
|||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@wifi_bp.route('/v2/deauth/stream')
|
@wifi_bp.route('/v2/deauth/stream')
|
||||||
def v2_deauth_stream():
|
def v2_deauth_stream():
|
||||||
"""
|
"""
|
||||||
SSE stream for real-time deauth alerts.
|
SSE stream for real-time deauth alerts.
|
||||||
|
|
||||||
@@ -1557,26 +1550,18 @@ def v2_deauth_stream():
|
|||||||
- deauth_error: An error occurred
|
- deauth_error: An error occurred
|
||||||
- keepalive: Periodic keepalive
|
- keepalive: Periodic keepalive
|
||||||
"""
|
"""
|
||||||
def generate():
|
response = Response(
|
||||||
last_keepalive = time.time()
|
sse_stream_fanout(
|
||||||
keepalive_interval = SSE_KEEPALIVE_INTERVAL
|
source_queue=app_module.deauth_detector_queue,
|
||||||
|
channel_key='wifi_deauth',
|
||||||
while True:
|
timeout=SSE_QUEUE_TIMEOUT,
|
||||||
try:
|
keepalive_interval=SSE_KEEPALIVE_INTERVAL,
|
||||||
# Try to get from the dedicated deauth queue
|
),
|
||||||
msg = app_module.deauth_detector_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
mimetype='text/event-stream',
|
||||||
last_keepalive = time.time()
|
)
|
||||||
yield format_sse(msg)
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
except queue.Empty:
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
now = time.time()
|
response.headers['Connection'] = 'keep-alive'
|
||||||
if now - last_keepalive >= keepalive_interval:
|
|
||||||
yield format_sse({'type': 'keepalive'})
|
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
response = Response(generate(), mimetype='text/event-stream')
|
|
||||||
response.headers['Cache-Control'] = 'no-cache'
|
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
|
||||||
response.headers['Connection'] = 'keep-alive'
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -325,9 +325,8 @@ install_python_deps() {
|
|||||||
# (C extension packages like scipy/numpy can fail on newer Python versions
|
# (C extension packages like scipy/numpy can fail on newer Python versions
|
||||||
# and cause pip to roll back pure-Python packages like flask)
|
# and cause pip to roll back pure-Python packages like flask)
|
||||||
info "Installing core packages..."
|
info "Installing core packages..."
|
||||||
$PIP install "flask>=3.0.0" "flask-limiter>=2.5.4" "requests>=2.28.0" \
|
$PIP install --quiet "flask>=3.0.0" "flask-limiter>=2.5.4" "requests>=2.28.0" \
|
||||||
"Werkzeug>=3.1.5" "pyserial>=3.5" "flask-sock" "websocket-client>=1.6.0" 2>&1 \
|
"Werkzeug>=3.1.5" "pyserial>=3.5" "flask-sock" "websocket-client>=1.6.0" 2>/dev/null || true
|
||||||
| tail -5 || true
|
|
||||||
|
|
||||||
# Verify critical packages
|
# Verify critical packages
|
||||||
$PY -c "import flask; import requests; from flask_limiter import Limiter" 2>/dev/null || {
|
$PY -c "import flask; import requests; from flask_limiter import Limiter" 2>/dev/null || {
|
||||||
@@ -761,11 +760,23 @@ install_aiscatcher_from_source_macos() {
|
|||||||
install_satdump_from_source_debian() {
|
install_satdump_from_source_debian() {
|
||||||
info "Building SatDump v1.2.2 from source (weather satellite decoder)..."
|
info "Building SatDump v1.2.2 from source (weather satellite decoder)..."
|
||||||
|
|
||||||
|
# Core deps — hard-fail if missing
|
||||||
apt_install build-essential git cmake pkg-config \
|
apt_install build-essential git cmake pkg-config \
|
||||||
libpng-dev libtiff-dev libjemalloc-dev libvolk-dev libnng-dev \
|
libpng-dev libtiff-dev libzstd-dev \
|
||||||
libzstd-dev libsoapysdr-dev libhackrf-dev liblimesuite-dev \
|
|
||||||
libsqlite3-dev libcurl4-openssl-dev zlib1g-dev libzmq3-dev libfftw3-dev
|
libsqlite3-dev libcurl4-openssl-dev zlib1g-dev libzmq3-dev libfftw3-dev
|
||||||
|
|
||||||
|
# libvolk: package name differs between distros
|
||||||
|
# Ubuntu / Debian Trixie+: libvolk-dev
|
||||||
|
# Raspberry Pi OS Bookworm / Debian Bookworm: libvolk2-dev
|
||||||
|
apt_try_install_any libvolk-dev libvolk2-dev \
|
||||||
|
|| warn "libvolk not found — SatDump will build without VOLK acceleration"
|
||||||
|
|
||||||
|
# Optional SDR hardware libs — soft-fail so missing hardware doesn't abort
|
||||||
|
for pkg in libjemalloc-dev libnng-dev libsoapysdr-dev libhackrf-dev liblimesuite-dev; do
|
||||||
|
$SUDO apt-get install -y --no-install-recommends "$pkg" >/dev/null 2>&1 \
|
||||||
|
|| warn "${pkg} not available — skipping (SatDump can build without it)"
|
||||||
|
done
|
||||||
|
|
||||||
# Run in subshell to isolate EXIT trap
|
# Run in subshell to isolate EXIT trap
|
||||||
(
|
(
|
||||||
tmp_dir="$(mktemp -d)"
|
tmp_dir="$(mktemp -d)"
|
||||||
@@ -787,6 +798,7 @@ install_satdump_from_source_debian() {
|
|||||||
echo '#pragma GCC diagnostic ignored "-Wdeprecated"'
|
echo '#pragma GCC diagnostic ignored "-Wdeprecated"'
|
||||||
echo '#pragma GCC diagnostic ignored "-Wdeprecated-declarations"'
|
echo '#pragma GCC diagnostic ignored "-Wdeprecated-declarations"'
|
||||||
cat "$lua_utils"
|
cat "$lua_utils"
|
||||||
|
echo # ensure the file ends with a newline before the closing pragma
|
||||||
echo '#pragma GCC diagnostic pop'
|
echo '#pragma GCC diagnostic pop'
|
||||||
} > "${lua_utils}.patched" && mv "${lua_utils}.patched" "$lua_utils"
|
} > "${lua_utils}.patched" && mv "${lua_utils}.patched" "$lua_utils"
|
||||||
fi
|
fi
|
||||||
@@ -1079,10 +1091,13 @@ install_dump1090_from_source_debian() {
|
|||||||
librtlsdr-dev libusb-1.0-0-dev \
|
librtlsdr-dev libusb-1.0-0-dev \
|
||||||
libncurses-dev tcl-dev python3-dev
|
libncurses-dev tcl-dev python3-dev
|
||||||
|
|
||||||
|
local JOBS
|
||||||
|
JOBS="$(nproc 2>/dev/null || echo 1)"
|
||||||
|
|
||||||
# Run in subshell to isolate EXIT trap
|
# Run in subshell to isolate EXIT trap
|
||||||
(
|
(
|
||||||
tmp_dir="$(mktemp -d)"
|
tmp_dir="$(mktemp -d)"
|
||||||
trap 'rm -rf "$tmp_dir"' EXIT
|
trap '{ [[ -n "${progress_pid:-}" ]] && kill "$progress_pid" 2>/dev/null && wait "$progress_pid" 2>/dev/null || true; }; rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
info "Cloning FlightAware dump1090..."
|
info "Cloning FlightAware dump1090..."
|
||||||
git clone --depth 1 https://github.com/flightaware/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|
git clone --depth 1 https://github.com/flightaware/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|
||||||
@@ -1091,22 +1106,44 @@ install_dump1090_from_source_debian() {
|
|||||||
cd "$tmp_dir/dump1090"
|
cd "$tmp_dir/dump1090"
|
||||||
# Remove -Werror to prevent build failures on newer GCC versions
|
# Remove -Werror to prevent build failures on newer GCC versions
|
||||||
sed -i 's/-Werror//g' Makefile 2>/dev/null || true
|
sed -i 's/-Werror//g' Makefile 2>/dev/null || true
|
||||||
info "Compiling FlightAware dump1090..."
|
info "Compiling FlightAware dump1090 (using ${JOBS} CPU cores)..."
|
||||||
if make BLADERF=no RTLSDR=yes >/dev/null 2>&1; then
|
build_log="$tmp_dir/dump1090-build.log"
|
||||||
|
|
||||||
|
(while true; do sleep 20; printf " [*] Still compiling dump1090...\n"; done) &
|
||||||
|
progress_pid=$!
|
||||||
|
|
||||||
|
if make -j "$JOBS" BLADERF=no RTLSDR=yes >"$build_log" 2>&1; then
|
||||||
|
kill "$progress_pid" 2>/dev/null; wait "$progress_pid" 2>/dev/null || true; progress_pid=
|
||||||
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
||||||
ok "dump1090 installed successfully (FlightAware)."
|
ok "dump1090 installed successfully (FlightAware)."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
kill "$progress_pid" 2>/dev/null; wait "$progress_pid" 2>/dev/null || true; progress_pid=
|
||||||
warn "FlightAware build failed. Falling back to wiedehopf/readsb..."
|
warn "FlightAware build failed. Falling back to wiedehopf/readsb..."
|
||||||
|
warn "Build log (last 20 lines):"
|
||||||
|
tail -20 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
||||||
|
|
||||||
rm -rf "$tmp_dir/dump1090"
|
rm -rf "$tmp_dir/dump1090"
|
||||||
git clone --depth 1 https://github.com/wiedehopf/readsb.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|
git clone --depth 1 https://github.com/wiedehopf/readsb.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|
||||||
|| { fail "Failed to clone wiedehopf/readsb"; exit 1; }
|
|| { fail "Failed to clone wiedehopf/readsb"; exit 1; }
|
||||||
|
|
||||||
cd "$tmp_dir/dump1090"
|
cd "$tmp_dir/dump1090"
|
||||||
info "Compiling readsb..."
|
info "Compiling readsb (using ${JOBS} CPU cores)..."
|
||||||
make RTLSDR=yes >/dev/null 2>&1 || { fail "Failed to build readsb from source (required)."; exit 1; }
|
build_log="$tmp_dir/readsb-build.log"
|
||||||
|
|
||||||
|
(while true; do sleep 20; printf " [*] Still compiling readsb...\n"; done) &
|
||||||
|
progress_pid=$!
|
||||||
|
|
||||||
|
if ! make -j "$JOBS" RTLSDR=yes >"$build_log" 2>&1; then
|
||||||
|
kill "$progress_pid" 2>/dev/null; wait "$progress_pid" 2>/dev/null || true; progress_pid=
|
||||||
|
warn "Build log (last 20 lines):"
|
||||||
|
tail -20 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
||||||
|
fail "Failed to build readsb from source (required)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
kill "$progress_pid" 2>/dev/null; wait "$progress_pid" 2>/dev/null || true; progress_pid=
|
||||||
$SUDO install -m 0755 readsb /usr/local/bin/dump1090
|
$SUDO install -m 0755 readsb /usr/local/bin/dump1090
|
||||||
ok "dump1090 installed successfully (via readsb)."
|
ok "dump1090 installed successfully (via readsb)."
|
||||||
)
|
)
|
||||||
@@ -1278,6 +1315,17 @@ install_rtlsdr_blog_drivers_debian() {
|
|||||||
$SUDO udevadm trigger || true
|
$SUDO udevadm trigger || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Make the Blog drivers' library take priority over the apt-installed
|
||||||
|
# librtlsdr. Removing apt packages is too destructive (dump1090-mutability
|
||||||
|
# and other tools depend on librtlsdr0 and get swept out). Instead,
|
||||||
|
# prepend /usr/local/lib to ldconfig's search path — files named 00-*
|
||||||
|
# sort before the distro's aarch64-linux-gnu.conf — so ldconfig lists
|
||||||
|
# /usr/local/lib/librtlsdr.so.0 first and the dynamic linker uses it.
|
||||||
|
if [[ -d /etc/ld.so.conf.d ]]; then
|
||||||
|
echo '/usr/local/lib' | $SUDO tee /etc/ld.so.conf.d/00-local-first.conf >/dev/null
|
||||||
|
fi
|
||||||
|
$SUDO ldconfig
|
||||||
|
|
||||||
ok "RTL-SDR Blog drivers installed successfully."
|
ok "RTL-SDR Blog drivers installed successfully."
|
||||||
info "These drivers provide improved support for RTL-SDR Blog V4 and other devices."
|
info "These drivers provide improved support for RTL-SDR Blog V4 and other devices."
|
||||||
warn "Unplug and replug your RTL-SDR devices for the new drivers to take effect."
|
warn "Unplug and replug your RTL-SDR devices for the new drivers to take effect."
|
||||||
@@ -1287,6 +1335,7 @@ install_rtlsdr_blog_drivers_debian() {
|
|||||||
warn "See: https://github.com/rtlsdrblog/rtl-sdr-blog"
|
warn "See: https://github.com/rtlsdrblog/rtl-sdr-blog"
|
||||||
fi
|
fi
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setup_udev_rules_debian() {
|
setup_udev_rules_debian() {
|
||||||
@@ -1311,24 +1360,35 @@ blacklist_kernel_drivers_debian() {
|
|||||||
|
|
||||||
if [[ -f "$blacklist_file" ]]; then
|
if [[ -f "$blacklist_file" ]]; then
|
||||||
ok "RTL-SDR kernel driver blacklist already present"
|
ok "RTL-SDR kernel driver blacklist already present"
|
||||||
return 0
|
else
|
||||||
fi
|
info "Blacklisting conflicting DVB kernel drivers..."
|
||||||
|
$SUDO tee "$blacklist_file" >/dev/null <<'EOF'
|
||||||
info "Blacklisting conflicting DVB kernel drivers..."
|
|
||||||
$SUDO tee "$blacklist_file" >/dev/null <<'EOF'
|
|
||||||
# Blacklist DVB-T drivers to allow rtl-sdr to access RTL2832U devices
|
# Blacklist DVB-T drivers to allow rtl-sdr to access RTL2832U devices
|
||||||
blacklist dvb_usb_rtl28xxu
|
blacklist dvb_usb_rtl28xxu
|
||||||
blacklist rtl2832
|
blacklist rtl2832
|
||||||
blacklist rtl2830
|
blacklist rtl2830
|
||||||
blacklist r820t
|
blacklist r820t
|
||||||
EOF
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
# Unload modules if currently loaded
|
# Always unload modules if currently loaded — this must happen even on
|
||||||
|
# re-runs where the blacklist file already exists, since the modules may
|
||||||
|
# still be live from the current boot (e.g. blacklist wasn't in initramfs).
|
||||||
|
local unloaded=false
|
||||||
for mod in dvb_usb_rtl28xxu rtl2832 rtl2830 r820t; do
|
for mod in dvb_usb_rtl28xxu rtl2832 rtl2830 r820t; do
|
||||||
if lsmod | grep -q "^$mod"; then
|
if lsmod | grep -q "^$mod"; then
|
||||||
$SUDO modprobe -r "$mod" 2>/dev/null || true
|
$SUDO modprobe -r "$mod" 2>/dev/null || true
|
||||||
|
unloaded=true
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
$unloaded && info "Unloaded conflicting DVB kernel modules from current session."
|
||||||
|
|
||||||
|
# Bake the blacklist into the initramfs so it survives reboots on
|
||||||
|
# Raspberry Pi OS / Debian (without this the modules can reload on boot).
|
||||||
|
if cmd_exists update-initramfs; then
|
||||||
|
info "Updating initramfs to persist driver blacklist across reboots..."
|
||||||
|
$SUDO update-initramfs -u >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
ok "Kernel drivers blacklisted. Unplug/replug your RTL-SDR if connected."
|
ok "Kernel drivers blacklisted. Unplug/replug your RTL-SDR if connected."
|
||||||
echo
|
echo
|
||||||
@@ -1391,12 +1451,18 @@ install_debian_packages() {
|
|||||||
|
|
||||||
apt_install_if_missing rtl-sdr
|
apt_install_if_missing rtl-sdr
|
||||||
|
|
||||||
progress "RTL-SDR Blog drivers"
|
progress "RTL-SDR Blog drivers (V4 support)"
|
||||||
if cmd_exists rtl_test; then
|
if $IS_DRAGONOS; then
|
||||||
ok "RTL-SDR drivers already installed"
|
info "DragonOS: skipping RTL-SDR Blog driver install (pre-configured)."
|
||||||
else
|
else
|
||||||
info "RTL-SDR drivers not found, installing RTL-SDR Blog drivers..."
|
echo
|
||||||
install_rtlsdr_blog_drivers_debian
|
info "RTL-SDR Blog drivers add V4 (R828D tuner) support and bias-tee improvements."
|
||||||
|
info "They are backward-compatible with all RTL-SDR devices."
|
||||||
|
if ask_yes_no "Install RTL-SDR Blog drivers? (recommended for V4 users, safe for all)" "y"; then
|
||||||
|
install_rtlsdr_blog_drivers_debian
|
||||||
|
else
|
||||||
|
warn "Skipping RTL-SDR Blog drivers. V4 devices may not work correctly."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
progress "Installing multimon-ng"
|
progress "Installing multimon-ng"
|
||||||
@@ -1486,6 +1552,15 @@ install_debian_packages() {
|
|||||||
$SUDO apt-get install -y python3-bleak >/dev/null 2>&1 || true
|
$SUDO apt-get install -y python3-bleak >/dev/null 2>&1 || true
|
||||||
|
|
||||||
progress "Installing dump1090"
|
progress "Installing dump1090"
|
||||||
|
# Remove any stale symlink left from a previous run where dump1090-mutability
|
||||||
|
# was later uninstalled — cmd_exists finds the broken symlink and skips the
|
||||||
|
# real install, leaving dump1090 seemingly present but non-functional.
|
||||||
|
local dump1090_path
|
||||||
|
dump1090_path="$(command -v dump1090 2>/dev/null || true)"
|
||||||
|
if [[ -n "$dump1090_path" ]] && [[ ! -x "$dump1090_path" ]]; then
|
||||||
|
info "Removing broken dump1090 symlink: $dump1090_path"
|
||||||
|
$SUDO rm -f "$dump1090_path"
|
||||||
|
fi
|
||||||
if ! cmd_exists dump1090 && ! cmd_exists dump1090-mutability; then
|
if ! cmd_exists dump1090 && ! cmd_exists dump1090-mutability; then
|
||||||
apt_try_install_any dump1090-fa dump1090-mutability dump1090 || true
|
apt_try_install_any dump1090-fa dump1090-mutability dump1090 || true
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -27,6 +27,27 @@
|
|||||||
--radar-bg: #101823;
|
--radar-bg: #101823;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg-dark: #f4f7fb;
|
||||||
|
--bg-panel: #e9eef5;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
--border-color: #d1d9e6;
|
||||||
|
--border-glow: #1f5fa8;
|
||||||
|
--text-primary: #122034;
|
||||||
|
--text-secondary: #3a4a5f;
|
||||||
|
--text-dim: #6b7c93;
|
||||||
|
--accent-green: #1f8a57;
|
||||||
|
--accent-cyan: #1f5fa8;
|
||||||
|
--accent-orange: #b5863a;
|
||||||
|
--accent-red: #c74444;
|
||||||
|
--accent-yellow: #b5863a;
|
||||||
|
--accent-amber: #b5863a;
|
||||||
|
--grid-line: rgba(31, 95, 168, 0.12);
|
||||||
|
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23000000'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||||
|
--radar-cyan: #1f5fa8;
|
||||||
|
--radar-bg: #e9eef5;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
|
|||||||
@@ -30,6 +30,27 @@
|
|||||||
--radar-bg: #101823;
|
--radar-bg: #101823;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg-dark: #f4f7fb;
|
||||||
|
--bg-panel: #e9eef5;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
--border-color: #d1d9e6;
|
||||||
|
--border-glow: #1f5fa8;
|
||||||
|
--text-primary: #122034;
|
||||||
|
--text-secondary: #3a4a5f;
|
||||||
|
--text-dim: #6b7c93;
|
||||||
|
--accent-green: #1f8a57;
|
||||||
|
--accent-cyan: #1f5fa8;
|
||||||
|
--accent-orange: #b5863a;
|
||||||
|
--accent-red: #c74444;
|
||||||
|
--accent-yellow: #b5863a;
|
||||||
|
--accent-amber: #b5863a;
|
||||||
|
--grid-line: rgba(31, 95, 168, 0.12);
|
||||||
|
--noise-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='40'%20height='40'%20viewBox='0%200%2040%2040'%3E%3Cg%20fill='%23000000'%20fill-opacity='0.05'%3E%3Ccircle%20cx='3'%20cy='5'%20r='1'/%3E%3Ccircle%20cx='11'%20cy='9'%20r='1'/%3E%3Ccircle%20cx='18'%20cy='3'%20r='1'/%3E%3Ccircle%20cx='26'%20cy='12'%20r='1'/%3E%3Ccircle%20cx='34'%20cy='6'%20r='1'/%3E%3Ccircle%20cx='7'%20cy='19'%20r='1'/%3E%3Ccircle%20cx='15'%20cy='24'%20r='1'/%3E%3Ccircle%20cx='28'%20cy='22'%20r='1'/%3E%3Ccircle%20cx='36'%20cy='18'%20r='1'/%3E%3Ccircle%20cx='5'%20cy='33'%20r='1'/%3E%3Ccircle%20cx='19'%20cy='32'%20r='1'/%3E%3Ccircle%20cx='31'%20cy='34'%20r='1'/%3E%3C/g%3E%3C/svg%3E");
|
||||||
|
--radar-cyan: #1f5fa8;
|
||||||
|
--radar-bg: #e9eef5;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
|
|||||||
@@ -0,0 +1,440 @@
|
|||||||
|
/* Shared UX platform components: run-state strip, command palette, setup assistant, and toasts */
|
||||||
|
|
||||||
|
.run-state-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
margin: 6px 12px 0;
|
||||||
|
border: 1px solid rgba(74, 163, 255, 0.32);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(180deg, rgba(19, 30, 44, 0.96), rgba(11, 18, 28, 0.97));
|
||||||
|
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#runStateChips {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-label {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-dim, #8697aa);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(74, 163, 255, 0.25);
|
||||||
|
background: linear-gradient(180deg, rgba(17, 26, 38, 0.82), rgba(12, 18, 28, 0.84));
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary, #b1c2d4);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-chip .dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #667788;
|
||||||
|
box-shadow: 0 0 0 0 rgba(102, 119, 136, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-chip.running .dot {
|
||||||
|
background: var(--accent-green, #28c27a);
|
||||||
|
box-shadow: 0 0 0 4px rgba(40, 194, 122, 0.16), 0 0 12px rgba(40, 194, 122, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-chip.active {
|
||||||
|
border-color: rgba(74, 163, 255, 0.65);
|
||||||
|
color: var(--text-primary, #e6edf5);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(74, 163, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-right {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-value {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #8697aa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-btn {
|
||||||
|
background: linear-gradient(180deg, rgba(17, 27, 41, 0.9), rgba(10, 16, 25, 0.92));
|
||||||
|
color: var(--accent-cyan, #4aa3ff);
|
||||||
|
border: 1px solid rgba(74, 163, 255, 0.45);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.16s ease, border-color 0.16s ease, transform 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-btn:hover {
|
||||||
|
background: rgba(74, 163, 255, 0.14);
|
||||||
|
border-color: rgba(74, 163, 255, 0.7);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10vh 18px 0;
|
||||||
|
z-index: 25000;
|
||||||
|
background: rgba(4, 8, 14, 0.65);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-overlay.open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette {
|
||||||
|
width: min(760px, 100%);
|
||||||
|
border: 1px solid rgba(74, 163, 255, 0.32);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(180deg, rgba(16, 26, 39, 0.98), rgba(10, 17, 27, 0.98));
|
||||||
|
box-shadow: 0 26px 60px rgba(0, 0, 0, 0.56), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-input {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary, #e6edf5);
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-hint {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #8697aa);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-list {
|
||||||
|
max-height: min(62vh, 520px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary, #b1c2d4);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item .meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item .title {
|
||||||
|
color: var(--text-primary, #e6edf5);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item .desc {
|
||||||
|
color: var(--text-dim, #8697aa);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item .kbd {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim, #8697aa);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item.active,
|
||||||
|
.command-palette-item:hover,
|
||||||
|
.command-palette-item:focus-visible {
|
||||||
|
background: rgba(74, 163, 255, 0.12);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-empty {
|
||||||
|
padding: 22px 16px;
|
||||||
|
color: var(--text-dim, #8697aa);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 26000;
|
||||||
|
background: rgba(4, 8, 14, 0.72);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-overlay.open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-modal {
|
||||||
|
width: min(760px, 100%);
|
||||||
|
max-height: 84vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #101926;
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-title {
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary, #e6edf5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-subtitle {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim, #8697aa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim, #8697aa);
|
||||||
|
font-size: 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-content {
|
||||||
|
padding: 14px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step {
|
||||||
|
border: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary, #e6edf5);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step-status {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #8697aa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step-status.done {
|
||||||
|
color: var(--accent-green, #28c27a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary, #b1c2d4);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-btn {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
background: var(--bg-tertiary, #121f2d);
|
||||||
|
color: var(--text-secondary, #b1c2d4);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-btn.primary {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent-cyan, #4aa3ff);
|
||||||
|
border-color: var(--accent-cyan, #4aa3ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-footer {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-top: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-footer-note {
|
||||||
|
color: var(--text-dim, #8697aa);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toast-stack {
|
||||||
|
position: fixed;
|
||||||
|
right: 14px;
|
||||||
|
bottom: 16px;
|
||||||
|
z-index: 25500;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: min(380px, calc(100vw - 24px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toast {
|
||||||
|
border: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
border-left: 3px solid var(--accent-cyan, #4aa3ff);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(15, 24, 35, 0.97);
|
||||||
|
color: var(--text-secondary, #b1c2d4);
|
||||||
|
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.35);
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toast.error {
|
||||||
|
border-left-color: var(--accent-red, #e25d5d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toast.warning {
|
||||||
|
border-left-color: var(--accent-orange, #d6a85e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toast-title {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-primary, #e6edf5);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toast-msg {
|
||||||
|
color: var(--text-secondary, #b1c2d4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toast-actions {
|
||||||
|
margin-top: 7px;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toast-actions button {
|
||||||
|
border: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-tertiary, #132133);
|
||||||
|
color: var(--text-secondary, #b1c2d4);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toast-actions button:hover {
|
||||||
|
border-color: rgba(74, 163, 255, 0.5);
|
||||||
|
color: var(--text-primary, #e6edf5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.run-state-strip {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-right {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.command-palette-overlay {
|
||||||
|
padding: 8vh 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item {
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-header,
|
||||||
|
.setup-content,
|
||||||
|
.setup-footer {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-toast-stack {
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,14 +28,16 @@ body {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
background-image:
|
background-image:
|
||||||
|
radial-gradient(1200px 620px at 8% -12%, var(--ambient-top-left), transparent 62%),
|
||||||
|
radial-gradient(980px 560px at 92% -16%, var(--ambient-top-right), transparent 64%),
|
||||||
|
radial-gradient(900px 520px at 50% 126%, var(--ambient-bottom), transparent 68%),
|
||||||
var(--noise-image),
|
var(--noise-image),
|
||||||
radial-gradient(circle at 15% 0%, var(--grid-dot), transparent 45%),
|
|
||||||
linear-gradient(180deg, var(--grid-dot), transparent 35%),
|
|
||||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||||
background-size: 40px 40px, auto, auto, 48px 48px, 48px 48px;
|
background-size: auto, auto, auto, 40px 40px, 48px 48px, 48px 48px;
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,11 +123,12 @@
|
|||||||
CARDS / PANELS
|
CARDS / PANELS
|
||||||
============================================ */
|
============================================ */
|
||||||
.card {
|
.card {
|
||||||
background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-secondary) 100%);
|
background: var(--surface-panel-gradient);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid rgba(74, 163, 255, 0.24);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
@@ -135,8 +136,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid rgba(74, 163, 255, 0.18);
|
||||||
background: var(--bg-secondary);
|
background: linear-gradient(180deg, rgba(25, 38, 55, 0.88) 0%, rgba(17, 27, 40, 0.9) 100%);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,11 +161,12 @@
|
|||||||
|
|
||||||
/* Panel variant (used in dashboards) */
|
/* Panel variant (used in dashboards) */
|
||||||
.panel {
|
.panel {
|
||||||
background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-secondary) 100%);
|
background: var(--surface-panel-gradient);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid rgba(74, 163, 255, 0.24);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@supports (clip-path: polygon(0 0)) {
|
@supports (clip-path: polygon(0 0)) {
|
||||||
@@ -190,8 +192,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid rgba(74, 163, 255, 0.18);
|
||||||
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-secondary) 100%);
|
background: linear-gradient(180deg, rgba(25, 38, 55, 0.88) 0%, rgba(17, 27, 40, 0.9) 100%);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
font-weight: var(--font-semibold);
|
font-weight: var(--font-semibold);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -720,10 +722,23 @@
|
|||||||
transform var(--transition-base);
|
transform var(--transition-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card:hover,
|
.card:hover,
|
||||||
.panel:hover {
|
.panel:hover {
|
||||||
border-color: var(--border-light);
|
border-color: var(--border-glow);
|
||||||
}
|
box-shadow: var(--shadow-md), var(--shadow-glow), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .card,
|
||||||
|
[data-theme="light"] .panel {
|
||||||
|
border-color: rgba(31, 95, 168, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .card-header,
|
||||||
|
[data-theme="light"] .panel-header {
|
||||||
|
border-bottom-color: rgba(31, 95, 168, 0.2);
|
||||||
|
background: linear-gradient(180deg, rgba(243, 247, 252, 0.96) 0%, rgba(233, 239, 247, 0.95) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
/* Stats strip value highlight on hover */
|
/* Stats strip value highlight on hover */
|
||||||
.strip-stat {
|
.strip-stat {
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
--bg-card: #121a25;
|
--bg-card: #121a25;
|
||||||
--bg-elevated: #1b2734;
|
--bg-elevated: #1b2734;
|
||||||
--bg-overlay: rgba(8, 13, 20, 0.75);
|
--bg-overlay: rgba(8, 13, 20, 0.75);
|
||||||
|
--surface-glass: rgba(16, 25, 37, 0.82);
|
||||||
|
--surface-panel-gradient: linear-gradient(160deg, rgba(20, 32, 47, 0.94) 0%, rgba(11, 18, 27, 0.96) 100%);
|
||||||
|
--ambient-top-left: rgba(74, 163, 255, 0.14);
|
||||||
|
--ambient-top-right: rgba(56, 193, 128, 0.09);
|
||||||
|
--ambient-bottom: rgba(214, 168, 94, 0.06);
|
||||||
|
|
||||||
/* Background aliases for components */
|
/* Background aliases for components */
|
||||||
--bg-dark: var(--bg-primary);
|
--bg-dark: var(--bg-primary);
|
||||||
@@ -158,6 +163,11 @@
|
|||||||
--bg-card: #ffffff;
|
--bg-card: #ffffff;
|
||||||
--bg-elevated: #f1f4f9;
|
--bg-elevated: #f1f4f9;
|
||||||
--bg-overlay: rgba(244, 247, 251, 0.92);
|
--bg-overlay: rgba(244, 247, 251, 0.92);
|
||||||
|
--surface-glass: rgba(255, 255, 255, 0.84);
|
||||||
|
--surface-panel-gradient: linear-gradient(160deg, rgba(255, 255, 255, 0.96) 0%, rgba(241, 245, 251, 0.97) 100%);
|
||||||
|
--ambient-top-left: rgba(31, 95, 168, 0.1);
|
||||||
|
--ambient-top-right: rgba(31, 138, 87, 0.06);
|
||||||
|
--ambient-bottom: rgba(181, 134, 58, 0.05);
|
||||||
|
|
||||||
/* Background aliases for components */
|
/* Background aliases for components */
|
||||||
--bg-dark: var(--bg-primary);
|
--bg-dark: var(--bg-primary);
|
||||||
|
|||||||
+732
-11
@@ -1556,7 +1556,7 @@ header h1 .tagline {
|
|||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow: visible;
|
overflow: hidden;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -3586,6 +3586,7 @@ header h1 .tagline {
|
|||||||
.wifi-networks-table-wrapper {
|
.wifi-networks-table-wrapper {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wifi-networks-table {
|
.wifi-networks-table {
|
||||||
@@ -3694,6 +3695,22 @@ header h1 .tagline {
|
|||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-collection-state-row td {
|
||||||
|
text-align: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-collection-state {
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 16px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-collection-state.is-loading {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
/* WiFi Radar Panel (CENTER) */
|
/* WiFi Radar Panel (CENTER) */
|
||||||
.wifi-radar-panel {
|
.wifi-radar-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -4060,8 +4077,8 @@ header h1 .tagline {
|
|||||||
/* Bluetooth Layout Container */
|
/* Bluetooth Layout Container */
|
||||||
.bt-layout-container {
|
.bt-layout-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 15px;
|
gap: 12px;
|
||||||
padding: 15px;
|
padding: 12px;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
margin: 0 15px 10px 15px;
|
margin: 0 15px 10px 15px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -4075,20 +4092,21 @@ header h1 .tagline {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overflow-y: auto; /* scroll rather than squash when detail panel + radar exceed available height */
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-main-area {
|
.bt-main-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 420px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-side-panels {
|
.bt-side-panels {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
width: 220px;
|
width: 300px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4096,6 +4114,21 @@ header h1 .tagline {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-tracker-panel h5 {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-tracker-list {
|
||||||
|
font-size: 11px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-radar-panel {
|
.bt-radar-panel {
|
||||||
@@ -4106,6 +4139,90 @@ header h1 .tagline {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#btRadarControls {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-radar-filter-btn,
|
||||||
|
#btRadarPauseBtn {
|
||||||
|
min-width: 84px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-radar-filter-btn:hover,
|
||||||
|
#btRadarPauseBtn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-radar-filter-btn.active,
|
||||||
|
#btRadarPauseBtn.active {
|
||||||
|
color: #0f172a;
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-zone-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-zone-card {
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 6px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-zone-card.immediate {
|
||||||
|
border-color: rgba(34, 197, 94, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-zone-card.near {
|
||||||
|
border-color: rgba(234, 179, 8, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-zone-card.far {
|
||||||
|
border-color: rgba(239, 68, 68, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-zone-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-zone-card.immediate .bt-zone-value {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-zone-card.near .bt-zone-value {
|
||||||
|
color: #eab308;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-zone-card.far .bt-zone-value {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-zone-label {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 10px;
|
||||||
|
margin-top: 3px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
|
||||||
.bt-radar-panel #btProximityRadar {
|
.bt-radar-panel #btProximityRadar {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -4247,6 +4364,70 @@ header h1 .tagline {
|
|||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bt-detail-badge.tracker-high {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-detail-badge.tracker-medium {
|
||||||
|
background: rgba(249, 115, 22, 0.2);
|
||||||
|
color: #f97316;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-detail-badge.tracker-low {
|
||||||
|
background: rgba(234, 179, 8, 0.2);
|
||||||
|
color: #eab308;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-detail-tracker-analysis {
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-analysis-header {
|
||||||
|
color: #fca5a5;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.35px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-analysis-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-analysis-label {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-analysis-section {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-evidence-list {
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
padding-left: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-analysis-warning {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #fca5a5;
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
.bt-detail-grid {
|
.bt-detail-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
@@ -4282,6 +4463,8 @@ header h1 .tagline {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-detail-services {
|
.bt-detail-services {
|
||||||
@@ -4437,8 +4620,8 @@ header h1 .tagline {
|
|||||||
border-left-color: var(--accent-purple) !important;
|
border-left-color: var(--accent-purple) !important;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 280px;
|
min-width: 330px;
|
||||||
max-width: 320px;
|
max-width: 420px;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -4450,6 +4633,9 @@ header h1 .tagline {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
padding: 8px 10px 12px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-device-list .wifi-device-list-header {
|
.bt-device-list .wifi-device-list-header {
|
||||||
@@ -4459,6 +4645,10 @@ header h1 .tagline {
|
|||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 4;
|
||||||
|
background: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-device-list .wifi-device-list-header h5 {
|
.bt-device-list .wifi-device-list-header h5 {
|
||||||
@@ -4468,6 +4658,101 @@ header h1 .tagline {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bt-list-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-summary-item {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 5px 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-summary-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-summary-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-top: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-list-signal-strip {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-list-signal-title {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.45px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-signal-dist-compact {
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-signal-dist-compact .signal-range {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-signal-dist-compact .signal-range span:first-child {
|
||||||
|
width: 50px;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-signal-dist-compact .signal-range span:last-child {
|
||||||
|
width: 22px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-signal-dist-compact .signal-bar-bg {
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-device-toolbar {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-device-search {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-device-search:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
/* Bluetooth Device Filters */
|
/* Bluetooth Device Filters */
|
||||||
.bt-device-filters {
|
.bt-device-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -4476,6 +4761,10 @@ header h1 .tagline {
|
|||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
position: sticky;
|
||||||
|
top: 44px;
|
||||||
|
z-index: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-filter-btn {
|
.bt-filter-btn {
|
||||||
@@ -4500,6 +4789,112 @@ header h1 .tagline {
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bt-tracker-item {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-tracker-item:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-tracker-item:focus-visible {
|
||||||
|
outline: 1px solid var(--accent-cyan);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-tracker-row-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-tracker-left,
|
||||||
|
.bt-tracker-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-tracker-confidence {
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-tracker-confidence-high .bt-tracker-confidence {
|
||||||
|
color: #ef4444;
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-tracker-confidence-medium .bt-tracker-confidence {
|
||||||
|
color: #f97316;
|
||||||
|
background: rgba(249, 115, 22, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-tracker-confidence-low .bt-tracker-confidence {
|
||||||
|
color: #eab308;
|
||||||
|
background: rgba(234, 179, 8, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-tracker-type {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-tracker-risk {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-risk-high {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-risk-medium {
|
||||||
|
color: #f97316;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-risk-low {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-tracker-rssi,
|
||||||
|
.bt-tracker-seen {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-tracker-row-bottom {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 3px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-tracker-address {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-tracker-evidence {
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
/* Bluetooth Signal Distribution */
|
/* Bluetooth Signal Distribution */
|
||||||
.bt-signal-dist {
|
.bt-signal-dist {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -4569,11 +4964,20 @@ header h1 .tagline {
|
|||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bt-device-row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.bt-device-row:hover {
|
.bt-device-row:hover {
|
||||||
background: rgba(0, 212, 255, 0.05);
|
background: rgba(0, 212, 255, 0.05);
|
||||||
border-color: var(--accent-cyan);
|
border-color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bt-device-row:focus-visible {
|
||||||
|
outline: 1px solid var(--accent-cyan);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.bt-row-main {
|
.bt-row-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -4711,6 +5115,50 @@ header h1 .tagline {
|
|||||||
padding: 4px 4px 0 42px;
|
padding: 4px 4px 0 42px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Locate action on Bluetooth device rows (must be in index.css so it styles in scanner mode) */
|
||||||
|
.bt-row-actions .bt-locate-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: var(--accent-green, #38c180);
|
||||||
|
background: linear-gradient(180deg, rgba(56, 193, 128, 0.2), rgba(56, 193, 128, 0.12));
|
||||||
|
border: 1px solid rgba(56, 193, 128, 0.42);
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-row-actions .bt-locate-btn:hover {
|
||||||
|
background: linear-gradient(180deg, rgba(56, 193, 128, 0.28), rgba(56, 193, 128, 0.18));
|
||||||
|
border-color: rgba(56, 193, 128, 0.72);
|
||||||
|
box-shadow: 0 0 0 1px rgba(56, 193, 128, 0.2), 0 6px 16px rgba(20, 80, 54, 0.35);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-row-actions .bt-locate-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-row-actions .bt-locate-btn svg {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
stroke: currentColor;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-device-filter-state {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Bluetooth Device Modal */
|
/* Bluetooth Device Modal */
|
||||||
.bt-modal-overlay {
|
.bt-modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -4921,14 +5369,31 @@ header h1 .tagline {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-layout-container .wifi-visuals {
|
.bt-layout-container .bt-visuals-column {
|
||||||
max-height: 50vh;
|
max-height: 50vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bt-main-area {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-side-panels {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-tracker-list {
|
||||||
|
max-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
.bt-device-list {
|
.bt-device-list {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: auto;
|
min-width: auto;
|
||||||
max-height: 300px;
|
max-width: none;
|
||||||
|
max-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-list-summary {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5208,8 +5673,11 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.disclaimer-modal .warning-icon {
|
.disclaimer-modal .warning-icon {
|
||||||
font-size: 48px;
|
display: block;
|
||||||
margin-bottom: 15px;
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin: 0 auto 15px;
|
||||||
|
color: var(--accent-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.disclaimer-modal p {
|
.disclaimer-modal p {
|
||||||
@@ -6656,3 +7124,256 @@ body::before {
|
|||||||
[data-animations="off"] .welcome-logo {
|
[data-animations="off"] .welcome-logo {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
VISUAL REFRESH OVERRIDES
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--visual-surface-soft: linear-gradient(180deg, rgba(18, 28, 40, 0.9) 0%, rgba(10, 16, 24, 0.95) 100%);
|
||||||
|
--visual-surface-panel: linear-gradient(160deg, rgba(20, 33, 48, 0.95) 0%, rgba(11, 18, 27, 0.96) 100%);
|
||||||
|
--visual-edge-cyan: rgba(74, 163, 255, 0.34);
|
||||||
|
--visual-edge-green: rgba(56, 193, 128, 0.28);
|
||||||
|
--visual-glow-soft: 0 14px 30px rgba(0, 0, 0, 0.32);
|
||||||
|
--visual-glow-cyan: 0 0 24px rgba(74, 163, 255, 0.16);
|
||||||
|
--mode-ambient-left: rgba(74, 163, 255, 0.12);
|
||||||
|
--mode-ambient-right: rgba(56, 193, 128, 0.08);
|
||||||
|
--mode-ambient-bottom: rgba(214, 168, 94, 0.05);
|
||||||
|
--top-rail-gutter: 12px;
|
||||||
|
--top-rail-gap: 6px;
|
||||||
|
--top-rail-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-image:
|
||||||
|
radial-gradient(1200px 560px at 8% -10%, var(--mode-ambient-left), transparent 60%),
|
||||||
|
radial-gradient(900px 520px at 92% -18%, var(--mode-ambient-right), transparent 60%),
|
||||||
|
radial-gradient(800px 440px at 50% 130%, var(--mode-ambient-bottom), transparent 65%),
|
||||||
|
var(--noise-image),
|
||||||
|
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||||
|
background-size: auto, auto, auto, 40px 40px, 48px 48px, 48px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-mode="wifi"],
|
||||||
|
body[data-mode="bluetooth"],
|
||||||
|
body[data-mode="bt_locate"] {
|
||||||
|
--mode-ambient-left: rgba(56, 193, 128, 0.14);
|
||||||
|
--mode-ambient-right: rgba(74, 163, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-mode="satellite"],
|
||||||
|
body[data-mode="weathersat"],
|
||||||
|
body[data-mode="sstv"],
|
||||||
|
body[data-mode="sstv_general"] {
|
||||||
|
--mode-ambient-left: rgba(74, 163, 255, 0.14);
|
||||||
|
--mode-ambient-right: rgba(143, 123, 214, 0.09);
|
||||||
|
--mode-ambient-bottom: rgba(56, 193, 128, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-mode="analytics"],
|
||||||
|
body[data-mode="spystations"],
|
||||||
|
body[data-mode="tscm"] {
|
||||||
|
--mode-ambient-left: rgba(214, 168, 94, 0.12);
|
||||||
|
--mode-ambient-right: rgba(74, 163, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] body {
|
||||||
|
--mode-ambient-left: rgba(31, 95, 168, 0.09);
|
||||||
|
--mode-ambient-right: rgba(31, 138, 87, 0.05);
|
||||||
|
--mode-ambient-bottom: rgba(181, 134, 58, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-nav {
|
||||||
|
background: linear-gradient(180deg, rgba(22, 33, 48, 0.96) 0%, rgba(14, 22, 33, 0.98) 100%);
|
||||||
|
border-bottom-color: rgba(74, 163, 255, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mainNav.mode-nav {
|
||||||
|
margin: var(--top-rail-gap) var(--top-rail-gutter) 0;
|
||||||
|
padding: 0 12px;
|
||||||
|
min-height: var(--top-rail-height);
|
||||||
|
height: var(--top-rail-height);
|
||||||
|
border: 1px solid rgba(74, 163, 255, 0.22);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--visual-glow-soft), inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-strip {
|
||||||
|
margin: 8px var(--top-rail-gutter) 0;
|
||||||
|
border-color: rgba(74, 163, 255, 0.3);
|
||||||
|
background: linear-gradient(180deg, rgba(20, 31, 44, 0.96) 0%, rgba(12, 19, 29, 0.97) 100%);
|
||||||
|
box-shadow: var(--visual-glow-soft), inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
min-height: var(--top-rail-height);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-strip .run-state-chip {
|
||||||
|
min-height: 22px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-strip .run-state-btn {
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-state-strip .run-state-right {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
margin: 0 12px;
|
||||||
|
border: 1px solid rgba(74, 163, 255, 0.22);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--visual-glow-soft), inset 0 0 0 1px rgba(255, 255, 255, 0.02);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: var(--visual-surface-soft);
|
||||||
|
border-right-color: rgba(74, 163, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
background: var(--visual-surface-panel);
|
||||||
|
border-color: rgba(74, 163, 255, 0.22);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||||
|
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section:hover {
|
||||||
|
border-color: var(--visual-edge-cyan);
|
||||||
|
box-shadow: var(--visual-glow-cyan), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h3 {
|
||||||
|
background: linear-gradient(180deg, rgba(28, 44, 63, 0.88) 0%, rgba(20, 31, 44, 0.9) 100%);
|
||||||
|
border-bottom-color: rgba(74, 163, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h3::before {
|
||||||
|
background: linear-gradient(180deg, var(--accent-cyan) 0%, var(--accent-green) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h3::after {
|
||||||
|
background: rgba(12, 18, 28, 0.9);
|
||||||
|
border: 1px solid rgba(74, 163, 255, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
background: rgba(8, 13, 20, 0.72);
|
||||||
|
border-color: rgba(74, 163, 255, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-btn,
|
||||||
|
.control-btn,
|
||||||
|
.clear-btn,
|
||||||
|
.run-btn,
|
||||||
|
.stop-btn {
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-btn,
|
||||||
|
.control-btn,
|
||||||
|
.clear-btn {
|
||||||
|
border-color: rgba(74, 163, 255, 0.24);
|
||||||
|
background: linear-gradient(180deg, rgba(16, 24, 35, 0.88) 0%, rgba(10, 15, 24, 0.9) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-panel {
|
||||||
|
background: linear-gradient(180deg, rgba(8, 13, 19, 0.98) 0%, rgba(7, 11, 18, 0.99) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-header {
|
||||||
|
background: linear-gradient(180deg, rgba(18, 28, 42, 0.95) 0%, rgba(13, 21, 31, 0.98) 100%);
|
||||||
|
border-bottom-color: rgba(74, 163, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-content {
|
||||||
|
background: linear-gradient(180deg, rgba(8, 13, 19, 0.6) 0%, rgba(8, 13, 19, 0.9) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats > div {
|
||||||
|
border-color: rgba(74, 163, 255, 0.2);
|
||||||
|
background: linear-gradient(180deg, rgba(19, 28, 40, 0.8) 0%, rgba(12, 18, 27, 0.82) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
border-color: rgba(74, 163, 255, 0.26);
|
||||||
|
border-left-width: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(180deg, rgba(21, 31, 44, 0.8) 0%, rgba(15, 23, 33, 0.82) 100%);
|
||||||
|
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 9;
|
||||||
|
border-top-color: rgba(74, 163, 255, 0.24);
|
||||||
|
background: linear-gradient(180deg, rgba(17, 26, 39, 0.96) 0%, rgba(10, 16, 24, 0.97) 100%);
|
||||||
|
backdrop-filter: blur(7px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator,
|
||||||
|
.control-group {
|
||||||
|
border-color: rgba(74, 163, 255, 0.2);
|
||||||
|
background: linear-gradient(180deg, rgba(15, 23, 34, 0.78) 0%, rgba(9, 14, 23, 0.8) 100%);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.running {
|
||||||
|
box-shadow: 0 0 0 4px rgba(56, 193, 128, 0.15), 0 0 14px rgba(56, 193, 128, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-content.active {
|
||||||
|
animation: modePanelEntrance 220ms ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modePanelEntrance {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(5px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .run-state-strip,
|
||||||
|
[data-theme="light"] .main-content,
|
||||||
|
[data-theme="light"] .section,
|
||||||
|
[data-theme="light"] #mainNav.mode-nav,
|
||||||
|
[data-theme="light"] .output-header,
|
||||||
|
[data-theme="light"] .status-bar,
|
||||||
|
[data-theme="light"] .status-indicator,
|
||||||
|
[data-theme="light"] .control-group {
|
||||||
|
box-shadow: 0 10px 24px rgba(18, 40, 66, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .section,
|
||||||
|
[data-theme="light"] .stats > div,
|
||||||
|
[data-theme="light"] .message,
|
||||||
|
[data-theme="light"] .preset-btn,
|
||||||
|
[data-theme="light"] .control-btn,
|
||||||
|
[data-theme="light"] .clear-btn {
|
||||||
|
border-color: rgba(31, 95, 168, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-animations="off"] .mode-content.active {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.run-state-strip {
|
||||||
|
margin-left: 8px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+252
-18
@@ -23,12 +23,112 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.analytics-grid {
|
.analytics-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
gap: var(--space-3, 12px);
|
gap: var(--space-3, 12px);
|
||||||
margin-bottom: var(--space-4, 16px);
|
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 {
|
.analytics-card {
|
||||||
background: var(--bg-card, #151f2b);
|
background: var(--bg-card, #151f2b);
|
||||||
@@ -180,11 +280,57 @@
|
|||||||
max-width: 60px;
|
max-width: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.analytics-correlation-pair .confidence-fill {
|
.analytics-correlation-pair .confidence-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--accent-green, #38c180);
|
background: var(--accent-green, #38c180);
|
||||||
border-radius: 2px;
|
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 list */
|
||||||
.geofence-zone-item {
|
.geofence-zone-item {
|
||||||
@@ -257,10 +403,98 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Empty state */
|
/* Empty state */
|
||||||
.analytics-empty {
|
.analytics-empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-dim, #5a6a7a);
|
color: var(--text-dim, #5a6a7a);
|
||||||
font-size: var(--text-xs, 10px);
|
font-size: var(--text-xs, 10px);
|
||||||
padding: var(--space-4, 16px);
|
padding: var(--space-4, 16px);
|
||||||
font-style: italic;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+154
-24
@@ -163,12 +163,29 @@
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btl-hud-controls {
|
.btl-hud-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btl-hud-export-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-hud-export-format {
|
||||||
|
min-width: 62px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.btl-hud-audio-toggle {
|
.btl-hud-audio-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -253,19 +270,108 @@
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btl-map-container {
|
.btl-map-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 250px;
|
min-height: 250px;
|
||||||
border-radius: 8px;
|
position: relative;
|
||||||
overflow: hidden;
|
border-radius: 8px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
overflow: hidden;
|
||||||
}
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
#btLocateMap {
|
#btLocateMap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #1a1a2e;
|
background: #1a1a2e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btl-map-overlay-controls {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 450;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 7px 8px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-overlay-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-overlay-toggle input[type="checkbox"] {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-overlay-toggle input[type="checkbox"]:disabled + span {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-heat-legend {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
z-index: 430;
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-heat-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.7px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-heat-bar {
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: linear-gradient(90deg, #2563eb 0%, #16a34a 40%, #f59e0b 70%, #ef4444 100%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-heat-scale {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-track-stats {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
z-index: 430;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
.btl-rssi-chart-container {
|
.btl-rssi-chart-container {
|
||||||
height: 100px;
|
height: 100px;
|
||||||
@@ -405,7 +511,7 @@
|
|||||||
RESPONSIVE — stack HUD vertically on narrow
|
RESPONSIVE — stack HUD vertically on narrow
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.btl-hud {
|
.btl-hud {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -422,9 +528,33 @@
|
|||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btl-hud-controls {
|
.btl-hud-controls {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btl-hud-export-row {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-overlay-controls {
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 6px 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-heat-legend {
|
||||||
|
left: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btl-map-track-stats {
|
||||||
|
right: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -509,10 +509,95 @@
|
|||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-ground-map {
|
.wxsat-ground-map {
|
||||||
height: 200px;
|
position: relative;
|
||||||
background: var(--bg-primary, #0d1117);
|
height: 200px;
|
||||||
}
|
overflow: hidden;
|
||||||
|
background: linear-gradient(180deg, #061329 0%, #050d1a 54%, #061325 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-ground-map .leaflet-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container.map-theme-cyber .leaflet-overlay-pane path.wxsat-pass-track {
|
||||||
|
filter: drop-shadow(0 0 5px rgba(91, 240, 255, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container.map-theme-cyber .leaflet-overlay-pane path.wxsat-pass-track.lrpt {
|
||||||
|
filter: drop-shadow(0 0 6px rgba(0, 255, 190, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-crosshair-icon {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-crosshair-marker {
|
||||||
|
position: relative;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-crosshair-h,
|
||||||
|
.wxsat-crosshair-v,
|
||||||
|
.wxsat-crosshair-ring,
|
||||||
|
.wxsat-crosshair-dot {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-crosshair-h {
|
||||||
|
top: 50%;
|
||||||
|
left: 2px;
|
||||||
|
right: 2px;
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255, 93, 93, 0.95);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-crosshair-v {
|
||||||
|
left: 50%;
|
||||||
|
top: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
width: 1px;
|
||||||
|
background: rgba(255, 93, 93, 0.95);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-crosshair-ring {
|
||||||
|
inset: 6px;
|
||||||
|
border: 1.5px solid rgba(255, 93, 93, 0.95);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 10px rgba(255, 93, 93, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-crosshair-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffa0a0;
|
||||||
|
box-shadow: 0 0 6px rgba(255, 100, 100, 0.65);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-map-tooltip {
|
||||||
|
background: rgba(5, 15, 32, 0.92);
|
||||||
|
border: 1px solid rgba(102, 229, 255, 0.65);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #8fe8ff;
|
||||||
|
box-shadow: 0 0 12px rgba(0, 210, 255, 0.24);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-map-tooltip.leaflet-tooltip-top:before {
|
||||||
|
border-top-color: rgba(102, 229, 255, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Image Gallery Panel ===== */
|
/* ===== Image Gallery Panel ===== */
|
||||||
.wxsat-gallery-panel {
|
.wxsat-gallery-panel {
|
||||||
|
|||||||
@@ -428,7 +428,7 @@
|
|||||||
|
|
||||||
/* Visual panels should be scrollable, not clipped */
|
/* Visual panels should be scrollable, not clipped */
|
||||||
.wifi-visuals,
|
.wifi-visuals,
|
||||||
.bt-visuals {
|
.bt-visuals-column {
|
||||||
max-height: none !important;
|
max-height: none !important;
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
@@ -444,7 +444,7 @@
|
|||||||
|
|
||||||
/* Visual panels should stack in single column on mobile when visible */
|
/* Visual panels should stack in single column on mobile when visible */
|
||||||
.wifi-visuals,
|
.wifi-visuals,
|
||||||
.bt-visuals {
|
.bt-visuals-column {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -465,6 +465,34 @@
|
|||||||
.wifi-visual-panel {
|
.wifi-visual-panel {
|
||||||
grid-column: auto !important;
|
grid-column: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bt-main-area {
|
||||||
|
flex-direction: column !important;
|
||||||
|
min-height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-side-panels {
|
||||||
|
width: 100% !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-detail-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-row-secondary {
|
||||||
|
padding-left: 0 !important;
|
||||||
|
white-space: normal !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-row-actions {
|
||||||
|
padding-left: 0 !important;
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-list-summary {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============== MOBILE MAP FIXES ============== */
|
/* ============== MOBILE MAP FIXES ============== */
|
||||||
|
|||||||
@@ -479,6 +479,54 @@
|
|||||||
filter: sepia(0.35) hue-rotate(185deg) saturate(1.75) brightness(1.06) contrast(1.05);
|
filter: sepia(0.35) hue-rotate(185deg) saturate(1.75) brightness(1.06) contrast(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Global Leaflet map theme: cyber overlay */
|
||||||
|
.leaflet-container.map-theme-cyber {
|
||||||
|
position: relative;
|
||||||
|
background: #020813;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container.map-theme-cyber .leaflet-tile-pane {
|
||||||
|
filter: sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hard global fallback: enforce cyber tint on all Leaflet tile images */
|
||||||
|
html.map-cyber-enabled .leaflet-container .leaflet-tile {
|
||||||
|
filter: sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hard global fallback: cyber glow + grid overlay */
|
||||||
|
html.map-cyber-enabled .leaflet-container {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.map-cyber-enabled .leaflet-container::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 620;
|
||||||
|
background:
|
||||||
|
radial-gradient(95% 78% at 50% 44%, rgba(18, 170, 255, 0.17), rgba(18, 170, 255, 0) 64%),
|
||||||
|
linear-gradient(180deg, rgba(24, 118, 255, 0.045), rgba(24, 118, 255, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.map-cyber-enabled .leaflet-container::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 621;
|
||||||
|
opacity: 0.42;
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(78, 188, 255, 0.14) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(78, 188, 255, 0.14) 1px, transparent 1px);
|
||||||
|
background-size: 52px 52px, 52px 52px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.settings-tabs {
|
.settings-tabs {
|
||||||
|
|||||||
@@ -485,7 +485,7 @@ async function syncLocalModeStates() {
|
|||||||
*/
|
*/
|
||||||
function showAgentModeWarnings(runningModes, modesDetail = {}) {
|
function showAgentModeWarnings(runningModes, modesDetail = {}) {
|
||||||
// SDR modes that can't run simultaneously on same device
|
// SDR modes that can't run simultaneously on same device
|
||||||
const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc'];
|
const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc'];
|
||||||
const runningSdrModes = runningModes.filter(m => sdrModes.includes(m));
|
const runningSdrModes = runningModes.filter(m => sdrModes.includes(m));
|
||||||
|
|
||||||
let warning = document.getElementById('agentModeWarning');
|
let warning = document.getElementById('agentModeWarning');
|
||||||
@@ -621,7 +621,7 @@ function checkAgentModeConflict(modeToStart, deviceToUse = null) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc'];
|
const sdrModes = ['sensor', 'pager', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'rtlamr', 'listening_post', 'tscm', 'dsc'];
|
||||||
|
|
||||||
// If we're trying to start an SDR mode
|
// If we're trying to start an SDR mode
|
||||||
if (sdrModes.includes(modeToStart)) {
|
if (sdrModes.includes(modeToStart)) {
|
||||||
|
|||||||
+247
-37
@@ -1,11 +1,12 @@
|
|||||||
const AlertCenter = (function() {
|
const AlertCenter = (function() {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const TRACKER_RULE_NAME = 'Tracker Detected';
|
||||||
|
|
||||||
let alerts = [];
|
let alerts = [];
|
||||||
let rules = [];
|
let rules = [];
|
||||||
let eventSource = null;
|
let eventSource = null;
|
||||||
|
let reconnectTimer = null;
|
||||||
const TRACKER_RULE_NAME = 'Tracker Detected';
|
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
loadRules();
|
loadRules();
|
||||||
@@ -17,6 +18,7 @@ const AlertCenter = (function() {
|
|||||||
if (eventSource) {
|
if (eventSource) {
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
eventSource = new EventSource('/alerts/stream');
|
eventSource = new EventSource('/alerts/stream');
|
||||||
eventSource.onmessage = function(e) {
|
eventSource.onmessage = function(e) {
|
||||||
try {
|
try {
|
||||||
@@ -27,21 +29,26 @@ const AlertCenter = (function() {
|
|||||||
console.error('[Alerts] SSE parse error', err);
|
console.error('[Alerts] SSE parse error', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
eventSource.onerror = function() {
|
eventSource.onerror = function() {
|
||||||
console.warn('[Alerts] SSE connection error');
|
console.warn('[Alerts] SSE connection error');
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = setTimeout(connect, 2500);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAlert(alert) {
|
function handleAlert(alert) {
|
||||||
alerts.unshift(alert);
|
alerts.unshift(alert);
|
||||||
alerts = alerts.slice(0, 50);
|
alerts = alerts.slice(0, 60);
|
||||||
updateFeedUI();
|
updateFeedUI();
|
||||||
|
|
||||||
if (typeof showNotification === 'function') {
|
const severity = String(alert.severity || '').toLowerCase();
|
||||||
const severity = (alert.severity || '').toLowerCase();
|
if (typeof showNotification === 'function' && ['high', 'critical'].includes(severity)) {
|
||||||
if (['high', 'critical'].includes(severity)) {
|
showNotification(alert.title || 'Alert', alert.message || 'Alert triggered');
|
||||||
showNotification(alert.title || 'Alert', alert.message || 'Alert triggered');
|
}
|
||||||
}
|
|
||||||
|
if (typeof showAppToast === 'function' && ['high', 'critical'].includes(severity)) {
|
||||||
|
showAppToast(alert.title || 'Alert', alert.message || 'Alert triggered', 'warning');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +63,7 @@ const AlertCenter = (function() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
list.innerHTML = alerts.map(alert => {
|
list.innerHTML = alerts.map((alert) => {
|
||||||
const title = escapeHtml(alert.title || 'Alert');
|
const title = escapeHtml(alert.title || 'Alert');
|
||||||
const message = escapeHtml(alert.message || '');
|
const message = escapeHtml(alert.message || '');
|
||||||
const severity = escapeHtml(alert.severity || 'medium');
|
const severity = escapeHtml(alert.severity || 'medium');
|
||||||
@@ -74,27 +81,218 @@ const AlertCenter = (function() {
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderRulesUI() {
|
||||||
|
const list = document.getElementById('alertsRulesList');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
if (!rules.length) {
|
||||||
|
list.innerHTML = '<div class="settings-feed-empty">No rules yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = rules.map((rule) => {
|
||||||
|
const enabled = Boolean(rule.enabled);
|
||||||
|
const mode = rule.mode || 'all';
|
||||||
|
const eventType = rule.event_type || 'any';
|
||||||
|
const severity = (rule.severity || 'medium').toUpperCase();
|
||||||
|
const match = formatMatch(rule.match);
|
||||||
|
const statusText = enabled ? 'ENABLED' : 'DISABLED';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="settings-feed-item" style="border-left: 2px solid ${enabled ? 'var(--accent-green)' : 'var(--text-dim)'};">
|
||||||
|
<div class="settings-feed-title" style="display:flex; gap:8px; align-items:center; justify-content:space-between;">
|
||||||
|
<span>${escapeHtml(rule.name || 'Rule')}</span>
|
||||||
|
<span style="color: var(--text-dim); font-size: 10px;">${statusText}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-feed-meta">Mode: ${escapeHtml(mode)} | Event: ${escapeHtml(eventType)} | Severity: ${escapeHtml(severity)}</div>
|
||||||
|
<div class="settings-feed-meta">Match: ${escapeHtml(match)}</div>
|
||||||
|
<div style="display:flex; gap:8px; margin-top: 8px;">
|
||||||
|
<button class="preset-btn" style="font-size: 10px; padding: 3px 8px;" onclick="AlertCenter.editRule(${Number(rule.id)})">Edit</button>
|
||||||
|
<button class="preset-btn" style="font-size: 10px; padding: 3px 8px;" onclick="AlertCenter.toggleRule(${Number(rule.id)}, ${enabled ? 'false' : 'true'})">${enabled ? 'Disable' : 'Enable'}</button>
|
||||||
|
<button class="preset-btn" style="font-size: 10px; padding: 3px 8px; border-color: var(--accent-red); color: var(--accent-red);" onclick="AlertCenter.deleteRule(${Number(rule.id)})">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMatch(match) {
|
||||||
|
if (!match || typeof match !== 'object' || !Object.keys(match).length) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
const [k, v] = Object.entries(match)[0];
|
||||||
|
return `${k}=${v}`;
|
||||||
|
}
|
||||||
|
|
||||||
function loadFeed() {
|
function loadFeed() {
|
||||||
fetch('/alerts/events?limit=20')
|
fetch('/alerts/events?limit=30')
|
||||||
.then(r => r.json())
|
.then((r) => r.json())
|
||||||
.then(data => {
|
.then((data) => {
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
alerts = data.events || [];
|
alerts = data.events || [];
|
||||||
updateFeedUI();
|
updateFeedUI();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => console.error('[Alerts] Load feed failed', err));
|
.catch((err) => console.error('[Alerts] Load feed failed', err));
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadRules() {
|
function loadRules() {
|
||||||
fetch('/alerts/rules?all=1')
|
return fetch('/alerts/rules?all=1')
|
||||||
.then(r => r.json())
|
.then((r) => r.json())
|
||||||
.then(data => {
|
.then((data) => {
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
rules = data.rules || [];
|
rules = data.rules || [];
|
||||||
|
renderRulesUI();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => console.error('[Alerts] Load rules failed', err));
|
.catch((err) => {
|
||||||
|
console.error('[Alerts] Load rules failed', err);
|
||||||
|
if (typeof reportActionableError === 'function') {
|
||||||
|
reportActionableError('Alert Rules', err, { onRetry: loadRules });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRule() {
|
||||||
|
const editingId = getEditingRuleId();
|
||||||
|
const payload = buildRulePayload();
|
||||||
|
|
||||||
|
if (!payload.name) {
|
||||||
|
payload.name = payload.mode ? `${payload.mode} alert` : 'Alert Rule';
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = editingId ? `/alerts/rules/${editingId}` : '/alerts/rules';
|
||||||
|
const method = editingId ? 'PATCH' : 'POST';
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.status !== 'success') {
|
||||||
|
throw new Error(data.message || 'Failed to save rule');
|
||||||
|
}
|
||||||
|
clearRuleForm();
|
||||||
|
return loadRules();
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
if (typeof showAppToast === 'function') {
|
||||||
|
showAppToast('Alerts', editingId ? 'Rule updated' : 'Rule created', 'info');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (typeof reportActionableError === 'function') {
|
||||||
|
reportActionableError('Save Alert Rule', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRulePayload() {
|
||||||
|
const nameEl = document.getElementById('alertsRuleName');
|
||||||
|
const modeEl = document.getElementById('alertsRuleMode');
|
||||||
|
const eventTypeEl = document.getElementById('alertsRuleEventType');
|
||||||
|
const keyEl = document.getElementById('alertsRuleMatchKey');
|
||||||
|
const valueEl = document.getElementById('alertsRuleMatchValue');
|
||||||
|
const severityEl = document.getElementById('alertsRuleSeverity');
|
||||||
|
|
||||||
|
const match = {};
|
||||||
|
const key = keyEl ? String(keyEl.value || '').trim() : '';
|
||||||
|
const value = valueEl ? String(valueEl.value || '').trim() : '';
|
||||||
|
if (key && value) {
|
||||||
|
match[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: nameEl ? String(nameEl.value || '').trim() : 'Alert Rule',
|
||||||
|
mode: modeEl ? String(modeEl.value || '').trim() || null : null,
|
||||||
|
event_type: eventTypeEl ? String(eventTypeEl.value || '').trim() || null : null,
|
||||||
|
match,
|
||||||
|
severity: severityEl ? String(severityEl.value || 'medium') : 'medium',
|
||||||
|
enabled: true,
|
||||||
|
notify: { webhook: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRuleForm() {
|
||||||
|
setField('alertsRuleName', '');
|
||||||
|
setField('alertsRuleMode', '');
|
||||||
|
setField('alertsRuleEventType', '');
|
||||||
|
setField('alertsRuleMatchKey', '');
|
||||||
|
setField('alertsRuleMatchValue', '');
|
||||||
|
setField('alertsRuleSeverity', 'medium');
|
||||||
|
setField('alertsRuleEditingId', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function editRule(ruleId) {
|
||||||
|
const rule = rules.find((r) => Number(r.id) === Number(ruleId));
|
||||||
|
if (!rule) return;
|
||||||
|
|
||||||
|
const matchEntries = Object.entries(rule.match || {});
|
||||||
|
const firstMatch = matchEntries.length ? matchEntries[0] : ['', ''];
|
||||||
|
|
||||||
|
setField('alertsRuleName', rule.name || '');
|
||||||
|
setField('alertsRuleMode', rule.mode || '');
|
||||||
|
setField('alertsRuleEventType', rule.event_type || '');
|
||||||
|
setField('alertsRuleMatchKey', firstMatch[0] || '');
|
||||||
|
setField('alertsRuleMatchValue', firstMatch[1] == null ? '' : String(firstMatch[1]));
|
||||||
|
setField('alertsRuleSeverity', rule.severity || 'medium');
|
||||||
|
setField('alertsRuleEditingId', String(rule.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRule(ruleId, enabled) {
|
||||||
|
fetch(`/alerts/rules/${ruleId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled: Boolean(enabled) }),
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.status !== 'success') {
|
||||||
|
throw new Error(data.message || 'Failed to update rule');
|
||||||
|
}
|
||||||
|
return loadRules();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (typeof reportActionableError === 'function') {
|
||||||
|
reportActionableError('Toggle Alert Rule', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRule(ruleId) {
|
||||||
|
if (!confirm('Delete this alert rule?')) return;
|
||||||
|
|
||||||
|
fetch(`/alerts/rules/${ruleId}`, { method: 'DELETE' })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.status !== 'success') {
|
||||||
|
throw new Error(data.message || 'Failed to delete rule');
|
||||||
|
}
|
||||||
|
if (Number(getEditingRuleId()) === Number(ruleId)) {
|
||||||
|
clearRuleForm();
|
||||||
|
}
|
||||||
|
return loadRules();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (typeof reportActionableError === 'function') {
|
||||||
|
reportActionableError('Delete Alert Rule', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEditingRuleId() {
|
||||||
|
const el = document.getElementById('alertsRuleEditingId');
|
||||||
|
if (!el || !el.value) return null;
|
||||||
|
const parsed = Number(el.value);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setField(id, value) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.value = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function enableTrackerAlerts() {
|
function enableTrackerAlerts() {
|
||||||
@@ -106,17 +304,18 @@ const AlertCenter = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureTrackerRule(enabled) {
|
function ensureTrackerRule(enabled) {
|
||||||
loadRules();
|
loadRules().then(() => {
|
||||||
setTimeout(() => {
|
const existing = rules.find((r) => r.name === TRACKER_RULE_NAME);
|
||||||
const existing = rules.find(r => r.name === TRACKER_RULE_NAME);
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
fetch(`/alerts/rules/${existing.id}`, {
|
return fetch(`/alerts/rules/${existing.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ enabled })
|
body: JSON.stringify({ enabled }),
|
||||||
}).then(() => loadRules());
|
}).then(() => loadRules());
|
||||||
} else if (enabled) {
|
}
|
||||||
fetch('/alerts/rules', {
|
|
||||||
|
if (enabled) {
|
||||||
|
return fetch('/alerts/rules', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -126,44 +325,49 @@ const AlertCenter = (function() {
|
|||||||
match: { is_tracker: true },
|
match: { is_tracker: true },
|
||||||
severity: 'high',
|
severity: 'high',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
notify: { webhook: true }
|
notify: { webhook: true },
|
||||||
})
|
}),
|
||||||
}).then(() => loadRules());
|
}).then(() => loadRules());
|
||||||
}
|
}
|
||||||
}, 150);
|
return null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function addBluetoothWatchlist(address, name) {
|
function addBluetoothWatchlist(address, name) {
|
||||||
if (!address) return;
|
if (!address) return;
|
||||||
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
|
const upper = String(address).toUpperCase();
|
||||||
if (existing) {
|
const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper);
|
||||||
return;
|
if (existing) return;
|
||||||
}
|
|
||||||
fetch('/alerts/rules', {
|
fetch('/alerts/rules', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: name ? `Watchlist ${name}` : `Watchlist ${address}`,
|
name: name ? `Watchlist ${name}` : `Watchlist ${upper}`,
|
||||||
mode: 'bluetooth',
|
mode: 'bluetooth',
|
||||||
event_type: 'device_update',
|
event_type: 'device_update',
|
||||||
match: { address: address },
|
match: { address: upper },
|
||||||
severity: 'medium',
|
severity: 'medium',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
notify: { webhook: true }
|
notify: { webhook: true },
|
||||||
})
|
}),
|
||||||
}).then(() => loadRules());
|
}).then(() => loadRules());
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeBluetoothWatchlist(address) {
|
function removeBluetoothWatchlist(address) {
|
||||||
if (!address) return;
|
if (!address) return;
|
||||||
const existing = rules.find(r => r.mode === 'bluetooth' && r.match && r.match.address === address);
|
const upper = String(address).toUpperCase();
|
||||||
|
const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper);
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
|
|
||||||
fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' })
|
fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' })
|
||||||
.then(() => loadRules());
|
.then(() => loadRules());
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWatchlisted(address) {
|
function isWatchlisted(address) {
|
||||||
return rules.some(r => r.mode === 'bluetooth' && r.match && r.match.address === address && r.enabled);
|
if (!address) return false;
|
||||||
|
const upper = String(address).toUpperCase();
|
||||||
|
return rules.some((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper && r.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
@@ -179,6 +383,12 @@ const AlertCenter = (function() {
|
|||||||
return {
|
return {
|
||||||
init,
|
init,
|
||||||
loadFeed,
|
loadFeed,
|
||||||
|
loadRules,
|
||||||
|
saveRule,
|
||||||
|
clearRuleForm,
|
||||||
|
editRule,
|
||||||
|
toggleRule,
|
||||||
|
deleteRule,
|
||||||
enableTrackerAlerts,
|
enableTrackerAlerts,
|
||||||
disableTrackerAlerts,
|
disableTrackerAlerts,
|
||||||
addBluetoothWatchlist,
|
addBluetoothWatchlist,
|
||||||
|
|||||||
@@ -36,12 +36,12 @@ let observerLocation = (function() {
|
|||||||
return ObserverLocation.getForModule('observerLocation');
|
return ObserverLocation.getForModule('observerLocation');
|
||||||
}
|
}
|
||||||
const saved = localStorage.getItem('observerLocation');
|
const saved = localStorage.getItem('observerLocation');
|
||||||
if (saved) {
|
if (saved) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(saved);
|
const parsed = JSON.parse(saved);
|
||||||
if (parsed.lat && parsed.lon) return parsed;
|
if (parsed.lat !== undefined && parsed.lat !== null && parsed.lon !== undefined && parsed.lon !== null) return parsed;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
return { lat: 51.5074, lon: -0.1278 };
|
return { lat: 51.5074, lon: -0.1278 };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
const CommandPalette = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let overlayEl = null;
|
||||||
|
let inputEl = null;
|
||||||
|
let listEl = null;
|
||||||
|
let isOpen = false;
|
||||||
|
let activeIndex = 0;
|
||||||
|
let filteredItems = [];
|
||||||
|
|
||||||
|
const fallbackModeCommands = [
|
||||||
|
{ mode: 'pager', label: 'Pager' },
|
||||||
|
{ mode: 'sensor', label: '433MHz Sensors' },
|
||||||
|
{ mode: 'rtlamr', label: 'Meters' },
|
||||||
|
{ mode: 'listening', label: 'Listening Post' },
|
||||||
|
{ mode: 'subghz', label: 'SubGHz' },
|
||||||
|
{ mode: 'aprs', label: 'APRS' },
|
||||||
|
{ mode: 'wifi', label: 'WiFi Scanner' },
|
||||||
|
{ mode: 'bluetooth', label: 'Bluetooth Scanner' },
|
||||||
|
{ mode: 'bt_locate', label: 'BT Locate' },
|
||||||
|
{ mode: 'satellite', label: 'Satellite' },
|
||||||
|
{ mode: 'sstv', label: 'ISS SSTV' },
|
||||||
|
{ mode: 'weathersat', label: 'Weather Sat' },
|
||||||
|
{ mode: 'sstv_general', label: 'HF SSTV' },
|
||||||
|
{ mode: 'gps', label: 'GPS' },
|
||||||
|
{ mode: 'meshtastic', label: 'Meshtastic' },
|
||||||
|
{ mode: 'dmr', label: 'Digital Voice' },
|
||||||
|
{ mode: 'websdr', label: 'WebSDR' },
|
||||||
|
{ mode: 'analytics', label: 'Analytics' },
|
||||||
|
{ mode: 'spaceweather', label: 'Space Weather' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getModeCommands() {
|
||||||
|
const commands = [];
|
||||||
|
const seenModes = new Set();
|
||||||
|
|
||||||
|
const catalog = window.interceptModeCatalog;
|
||||||
|
if (catalog && typeof catalog === 'object') {
|
||||||
|
for (const [mode, meta] of Object.entries(catalog)) {
|
||||||
|
if (!mode || seenModes.has(mode)) continue;
|
||||||
|
const label = String((meta && meta.label) || mode).trim();
|
||||||
|
commands.push({ mode, label });
|
||||||
|
seenModes.add(mode);
|
||||||
|
}
|
||||||
|
if (commands.length > 0) return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navNodes = document.querySelectorAll('.mode-nav-btn[data-mode], .mobile-nav-btn[data-mode]');
|
||||||
|
navNodes.forEach((node) => {
|
||||||
|
if (node.tagName === 'A') {
|
||||||
|
const href = String(node.getAttribute('href') || '');
|
||||||
|
if (href.includes('/dashboard')) return;
|
||||||
|
}
|
||||||
|
const mode = String(node.dataset.mode || '').trim();
|
||||||
|
if (!mode || seenModes.has(mode)) return;
|
||||||
|
const label = String(node.dataset.modeLabel || node.textContent || mode).trim();
|
||||||
|
commands.push({ mode, label });
|
||||||
|
seenModes.add(mode);
|
||||||
|
});
|
||||||
|
if (commands.length > 0) return commands;
|
||||||
|
|
||||||
|
return fallbackModeCommands.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
buildDOM();
|
||||||
|
registerHotkeys();
|
||||||
|
renderItems('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDOM() {
|
||||||
|
overlayEl = document.createElement('div');
|
||||||
|
overlayEl.className = 'command-palette-overlay';
|
||||||
|
overlayEl.id = 'commandPaletteOverlay';
|
||||||
|
overlayEl.addEventListener('click', (event) => {
|
||||||
|
if (event.target === overlayEl) close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const palette = document.createElement('div');
|
||||||
|
palette.className = 'command-palette';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'command-palette-header';
|
||||||
|
|
||||||
|
inputEl = document.createElement('input');
|
||||||
|
inputEl.className = 'command-palette-input';
|
||||||
|
inputEl.type = 'text';
|
||||||
|
inputEl.autocomplete = 'off';
|
||||||
|
inputEl.placeholder = 'Search commands and modes...';
|
||||||
|
inputEl.setAttribute('aria-label', 'Command Palette Search');
|
||||||
|
inputEl.addEventListener('input', () => {
|
||||||
|
renderItems(inputEl.value || '');
|
||||||
|
});
|
||||||
|
inputEl.addEventListener('keydown', onInputKeyDown);
|
||||||
|
|
||||||
|
const hint = document.createElement('span');
|
||||||
|
hint.className = 'command-palette-hint';
|
||||||
|
hint.textContent = 'Esc close';
|
||||||
|
|
||||||
|
header.appendChild(inputEl);
|
||||||
|
header.appendChild(hint);
|
||||||
|
|
||||||
|
listEl = document.createElement('div');
|
||||||
|
listEl.className = 'command-palette-list';
|
||||||
|
listEl.id = 'commandPaletteList';
|
||||||
|
|
||||||
|
palette.appendChild(header);
|
||||||
|
palette.appendChild(listEl);
|
||||||
|
overlayEl.appendChild(palette);
|
||||||
|
document.body.appendChild(overlayEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerHotkeys() {
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
const cmdK = (event.key.toLowerCase() === 'k') && (event.ctrlKey || event.metaKey);
|
||||||
|
if (cmdK) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (isOpen) {
|
||||||
|
close();
|
||||||
|
} else {
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen) return;
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInputKeyDown(event) {
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
activeIndex = Math.min(activeIndex + 1, Math.max(filteredItems.length - 1, 0));
|
||||||
|
renderSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
activeIndex = Math.max(activeIndex - 1, 0);
|
||||||
|
renderSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
const item = filteredItems[activeIndex];
|
||||||
|
if (item && typeof item.run === 'function') {
|
||||||
|
item.run();
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCommands() {
|
||||||
|
const commands = [
|
||||||
|
{
|
||||||
|
title: 'Open Settings',
|
||||||
|
description: 'Open global settings panel',
|
||||||
|
keyword: 'settings configure tools',
|
||||||
|
shortcut: 'S',
|
||||||
|
run: () => {
|
||||||
|
if (typeof showSettings === 'function') showSettings();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Settings: Alerts',
|
||||||
|
description: 'Open alert rules and feed',
|
||||||
|
keyword: 'settings alerts rule',
|
||||||
|
run: () => openSettingsTab('alerts')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Settings: Recording',
|
||||||
|
description: 'Open recording manager',
|
||||||
|
keyword: 'settings recording replay',
|
||||||
|
run: () => openSettingsTab('recording')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Settings: Location',
|
||||||
|
description: 'Configure observer location',
|
||||||
|
keyword: 'settings location gps lat lon',
|
||||||
|
run: () => openSettingsTab('location')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'View Aircraft Dashboard',
|
||||||
|
description: 'Open dedicated ADS-B dashboard page',
|
||||||
|
keyword: 'aircraft adsb dashboard',
|
||||||
|
run: () => { window.location.href = '/adsb/dashboard'; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'View Vessel Dashboard',
|
||||||
|
description: 'Open dedicated AIS dashboard page',
|
||||||
|
keyword: 'vessel ais dashboard',
|
||||||
|
run: () => { window.location.href = '/ais/dashboard'; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Kill All Running Processes',
|
||||||
|
description: 'Stop all decoders and scans',
|
||||||
|
keyword: 'kill stop processes emergency',
|
||||||
|
run: () => {
|
||||||
|
if (typeof killAll === 'function') {
|
||||||
|
killAll();
|
||||||
|
} else if (typeof fetch === 'function') {
|
||||||
|
fetch('/killall', { method: 'POST' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Toggle Sidebar Width',
|
||||||
|
description: 'Collapse or expand the left sidebar',
|
||||||
|
keyword: 'sidebar collapse layout',
|
||||||
|
run: () => {
|
||||||
|
if (typeof toggleMainSidebarCollapse === 'function') {
|
||||||
|
toggleMainSidebarCollapse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const modeEntry of getModeCommands()) {
|
||||||
|
commands.push({
|
||||||
|
title: `Switch Mode: ${modeEntry.label}`,
|
||||||
|
description: 'Navigate directly to mode',
|
||||||
|
keyword: `mode ${modeEntry.mode} ${modeEntry.label.toLowerCase()}`,
|
||||||
|
run: () => goToMode(modeEntry.mode),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItems(query) {
|
||||||
|
const q = String(query || '').trim().toLowerCase();
|
||||||
|
const allItems = getCommands();
|
||||||
|
|
||||||
|
filteredItems = allItems.filter((item) => {
|
||||||
|
if (!q) return true;
|
||||||
|
const haystack = `${item.title} ${item.description || ''} ${item.keyword || ''}`.toLowerCase();
|
||||||
|
return haystack.includes(q);
|
||||||
|
}).slice(0, 80);
|
||||||
|
|
||||||
|
activeIndex = 0;
|
||||||
|
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
if (filteredItems.length === 0) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'command-palette-empty';
|
||||||
|
empty.textContent = 'No matching commands';
|
||||||
|
listEl.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredItems.forEach((item, idx) => {
|
||||||
|
const row = document.createElement('button');
|
||||||
|
row.type = 'button';
|
||||||
|
row.className = 'command-palette-item';
|
||||||
|
row.dataset.index = String(idx);
|
||||||
|
row.addEventListener('click', () => {
|
||||||
|
item.run();
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const meta = document.createElement('span');
|
||||||
|
meta.className = 'meta';
|
||||||
|
|
||||||
|
const title = document.createElement('span');
|
||||||
|
title.className = 'title';
|
||||||
|
title.textContent = item.title;
|
||||||
|
meta.appendChild(title);
|
||||||
|
|
||||||
|
const desc = document.createElement('span');
|
||||||
|
desc.className = 'desc';
|
||||||
|
desc.textContent = item.description || '';
|
||||||
|
meta.appendChild(desc);
|
||||||
|
|
||||||
|
row.appendChild(meta);
|
||||||
|
|
||||||
|
if (item.shortcut) {
|
||||||
|
const kbd = document.createElement('span');
|
||||||
|
kbd.className = 'kbd';
|
||||||
|
kbd.textContent = item.shortcut;
|
||||||
|
row.appendChild(kbd);
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelection() {
|
||||||
|
const rows = listEl.querySelectorAll('.command-palette-item');
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const idx = Number(row.dataset.index || 0);
|
||||||
|
row.classList.toggle('active', idx === activeIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeRow = listEl.querySelector(`.command-palette-item[data-index="${activeIndex}"]`);
|
||||||
|
if (activeRow) {
|
||||||
|
activeRow.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToMode(mode) {
|
||||||
|
const welcome = document.getElementById('welcomePage');
|
||||||
|
if (welcome && getComputedStyle(welcome).display !== 'none') {
|
||||||
|
welcome.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof switchMode === 'function') {
|
||||||
|
switchMode(mode, { updateUrl: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSettingsTab(tab) {
|
||||||
|
if (typeof showSettings === 'function') {
|
||||||
|
showSettings();
|
||||||
|
}
|
||||||
|
if (typeof switchSettingsTab === 'function') {
|
||||||
|
switchSettingsTab(tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (!overlayEl) return;
|
||||||
|
isOpen = true;
|
||||||
|
overlayEl.classList.add('open');
|
||||||
|
renderItems('');
|
||||||
|
inputEl.value = '';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
inputEl.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!overlayEl) return;
|
||||||
|
isOpen = false;
|
||||||
|
overlayEl.classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
CommandPalette.init();
|
||||||
|
});
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
const FirstRunSetup = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const COMPLETE_KEY = 'intercept.setup.complete.v1';
|
||||||
|
const DEFAULT_MODE_KEY = 'intercept.default_mode';
|
||||||
|
|
||||||
|
let overlayEl = null;
|
||||||
|
let depsStatusEl = null;
|
||||||
|
let locationStatusEl = null;
|
||||||
|
let notifyStatusEl = null;
|
||||||
|
let modeStatusEl = null;
|
||||||
|
let modeSelectEl = null;
|
||||||
|
|
||||||
|
let dependencyReady = null;
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
buildDOM();
|
||||||
|
maybeShow();
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeShow() {
|
||||||
|
if (localStorage.getItem(COMPLETE_KEY) === 'true') return;
|
||||||
|
|
||||||
|
if (localStorage.getItem('disclaimerAccepted') === 'true') {
|
||||||
|
open();
|
||||||
|
refreshStatuses();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
const waitTimer = setInterval(() => {
|
||||||
|
attempts += 1;
|
||||||
|
if (localStorage.getItem(COMPLETE_KEY) === 'true') {
|
||||||
|
clearInterval(waitTimer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (localStorage.getItem('disclaimerAccepted') === 'true') {
|
||||||
|
clearInterval(waitTimer);
|
||||||
|
open();
|
||||||
|
refreshStatuses();
|
||||||
|
}
|
||||||
|
if (attempts > 30) {
|
||||||
|
clearInterval(waitTimer);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDOM() {
|
||||||
|
overlayEl = document.createElement('div');
|
||||||
|
overlayEl.id = 'firstRunSetupOverlay';
|
||||||
|
overlayEl.className = 'setup-overlay';
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'setup-modal';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'setup-header';
|
||||||
|
|
||||||
|
const headingWrap = document.createElement('div');
|
||||||
|
const title = document.createElement('h2');
|
||||||
|
title.className = 'setup-title';
|
||||||
|
title.textContent = 'Quick Setup';
|
||||||
|
headingWrap.appendChild(title);
|
||||||
|
|
||||||
|
const subtitle = document.createElement('p');
|
||||||
|
subtitle.className = 'setup-subtitle';
|
||||||
|
subtitle.textContent = 'Complete these checks once so all modes work reliably.';
|
||||||
|
headingWrap.appendChild(subtitle);
|
||||||
|
|
||||||
|
const closeBtn = document.createElement('button');
|
||||||
|
closeBtn.type = 'button';
|
||||||
|
closeBtn.className = 'setup-close';
|
||||||
|
closeBtn.textContent = '×';
|
||||||
|
closeBtn.setAttribute('aria-label', 'Close setup assistant');
|
||||||
|
closeBtn.addEventListener('click', close);
|
||||||
|
|
||||||
|
header.appendChild(headingWrap);
|
||||||
|
header.appendChild(closeBtn);
|
||||||
|
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'setup-content';
|
||||||
|
|
||||||
|
const depsStep = createStep(
|
||||||
|
'Dependencies',
|
||||||
|
'Verify required tools are installed for enabled modes.',
|
||||||
|
(statusEl, actionsEl) => {
|
||||||
|
depsStatusEl = statusEl;
|
||||||
|
|
||||||
|
const checkBtn = buildButton('Recheck', () => checkDependencies());
|
||||||
|
const openToolsBtn = buildButton('Open Tools', () => {
|
||||||
|
if (typeof showSettings === 'function') showSettings();
|
||||||
|
if (typeof switchSettingsTab === 'function') switchSettingsTab('tools');
|
||||||
|
});
|
||||||
|
actionsEl.appendChild(checkBtn);
|
||||||
|
actionsEl.appendChild(openToolsBtn);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const locationStep = createStep(
|
||||||
|
'Observer Location',
|
||||||
|
'Set latitude/longitude for pass prediction and mapping features.',
|
||||||
|
(statusEl, actionsEl) => {
|
||||||
|
locationStatusEl = statusEl;
|
||||||
|
actionsEl.appendChild(buildButton('Open Location', () => {
|
||||||
|
if (typeof showSettings === 'function') showSettings();
|
||||||
|
if (typeof switchSettingsTab === 'function') switchSettingsTab('location');
|
||||||
|
}));
|
||||||
|
actionsEl.appendChild(buildButton('Recheck', refreshStatuses));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const notifyStep = createStep(
|
||||||
|
'Desktop Alerts',
|
||||||
|
'Allow notifications so high-priority alerts are visible when the tab is hidden.',
|
||||||
|
(statusEl, actionsEl) => {
|
||||||
|
notifyStatusEl = statusEl;
|
||||||
|
actionsEl.appendChild(buildButton('Enable Notifications', requestNotifications));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const modeStep = createStep(
|
||||||
|
'Default Start Mode',
|
||||||
|
'Choose which mode should be selected by default.',
|
||||||
|
(statusEl, actionsEl) => {
|
||||||
|
modeStatusEl = statusEl;
|
||||||
|
|
||||||
|
modeSelectEl = document.createElement('select');
|
||||||
|
modeSelectEl.className = 'setup-btn';
|
||||||
|
const modes = [
|
||||||
|
['pager', 'Pager'],
|
||||||
|
['sensor', '433MHz'],
|
||||||
|
['rtlamr', 'Meters'],
|
||||||
|
['listening', 'Listening Post'],
|
||||||
|
['wifi', 'WiFi'],
|
||||||
|
['bluetooth', 'Bluetooth'],
|
||||||
|
['bt_locate', 'BT Locate'],
|
||||||
|
['aprs', 'APRS'],
|
||||||
|
['satellite', 'Satellite'],
|
||||||
|
['sstv', 'ISS SSTV'],
|
||||||
|
['weathersat', 'Weather Sat'],
|
||||||
|
['sstv_general', 'HF SSTV'],
|
||||||
|
['analytics', 'Analytics'],
|
||||||
|
];
|
||||||
|
for (const [value, label] of modes) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = value;
|
||||||
|
opt.textContent = label;
|
||||||
|
modeSelectEl.appendChild(opt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedDefaultMode = localStorage.getItem(DEFAULT_MODE_KEY);
|
||||||
|
if (savedDefaultMode) {
|
||||||
|
modeSelectEl.value = savedDefaultMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
actionsEl.appendChild(modeSelectEl);
|
||||||
|
actionsEl.appendChild(buildButton('Save', () => {
|
||||||
|
const selected = modeSelectEl.value || 'pager';
|
||||||
|
localStorage.setItem(DEFAULT_MODE_KEY, selected);
|
||||||
|
refreshStatuses();
|
||||||
|
if (typeof showAppToast === 'function') {
|
||||||
|
showAppToast('Default Mode Saved', `New sessions will default to ${selected}.`, 'info');
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
content.appendChild(depsStep);
|
||||||
|
content.appendChild(locationStep);
|
||||||
|
content.appendChild(notifyStep);
|
||||||
|
content.appendChild(modeStep);
|
||||||
|
|
||||||
|
const footer = document.createElement('div');
|
||||||
|
footer.className = 'setup-footer';
|
||||||
|
|
||||||
|
const note = document.createElement('span');
|
||||||
|
note.className = 'setup-footer-note';
|
||||||
|
note.textContent = 'You can reopen these options anytime in Settings.';
|
||||||
|
|
||||||
|
const footerActions = document.createElement('div');
|
||||||
|
footerActions.style.display = 'inline-flex';
|
||||||
|
footerActions.style.gap = '8px';
|
||||||
|
|
||||||
|
const laterBtn = buildButton('Remind Me Later', close);
|
||||||
|
const completeBtn = buildButton('Mark Setup Complete', completeSetup, true);
|
||||||
|
completeBtn.id = 'setupCompleteBtn';
|
||||||
|
|
||||||
|
footerActions.appendChild(laterBtn);
|
||||||
|
footerActions.appendChild(completeBtn);
|
||||||
|
|
||||||
|
footer.appendChild(note);
|
||||||
|
footer.appendChild(footerActions);
|
||||||
|
|
||||||
|
modal.appendChild(header);
|
||||||
|
modal.appendChild(content);
|
||||||
|
modal.appendChild(footer);
|
||||||
|
|
||||||
|
overlayEl.appendChild(modal);
|
||||||
|
document.body.appendChild(overlayEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStep(title, description, initActions) {
|
||||||
|
const root = document.createElement('div');
|
||||||
|
root.className = 'setup-step';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'setup-step-header';
|
||||||
|
|
||||||
|
const titleEl = document.createElement('span');
|
||||||
|
titleEl.className = 'setup-step-title';
|
||||||
|
titleEl.textContent = title;
|
||||||
|
|
||||||
|
const statusEl = document.createElement('span');
|
||||||
|
statusEl.className = 'setup-step-status';
|
||||||
|
statusEl.textContent = 'Pending';
|
||||||
|
|
||||||
|
header.appendChild(titleEl);
|
||||||
|
header.appendChild(statusEl);
|
||||||
|
|
||||||
|
const descEl = document.createElement('p');
|
||||||
|
descEl.className = 'setup-step-desc';
|
||||||
|
descEl.textContent = description;
|
||||||
|
|
||||||
|
const actionsEl = document.createElement('div');
|
||||||
|
actionsEl.className = 'setup-step-actions';
|
||||||
|
|
||||||
|
if (typeof initActions === 'function') {
|
||||||
|
initActions(statusEl, actionsEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
root.appendChild(header);
|
||||||
|
root.appendChild(descEl);
|
||||||
|
root.appendChild(actionsEl);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildButton(label, onClick, primary) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = `setup-btn${primary ? ' primary' : ''}`;
|
||||||
|
btn.textContent = label;
|
||||||
|
btn.addEventListener('click', onClick);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkDependencies() {
|
||||||
|
if (depsStatusEl) depsStatusEl.textContent = 'Checking...';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/dependencies');
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.status !== 'success') {
|
||||||
|
dependencyReady = false;
|
||||||
|
} else {
|
||||||
|
const modes = Object.values(data.modes || {});
|
||||||
|
dependencyReady = modes.every((modeInfo) => Boolean(modeInfo.ready));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
dependencyReady = false;
|
||||||
|
if (typeof reportActionableError === 'function') {
|
||||||
|
reportActionableError('Dependency Check', err, {
|
||||||
|
onRetry: checkDependencies,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshStatuses();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshStatuses() {
|
||||||
|
const hasLocation = hasValidLocation();
|
||||||
|
const notifications = notificationStatus();
|
||||||
|
const hasDefaultMode = Boolean(localStorage.getItem(DEFAULT_MODE_KEY));
|
||||||
|
|
||||||
|
setStatus(locationStatusEl, hasLocation, hasLocation ? 'Configured' : 'Not set');
|
||||||
|
setStatus(notifyStatusEl, notifications.ready, notifications.label);
|
||||||
|
setStatus(modeStatusEl, hasDefaultMode, hasDefaultMode ? localStorage.getItem(DEFAULT_MODE_KEY) : 'Not set');
|
||||||
|
|
||||||
|
if (dependencyReady === null) {
|
||||||
|
checkDependencies();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(depsStatusEl, dependencyReady, dependencyReady ? 'Ready' : 'Missing tools');
|
||||||
|
|
||||||
|
const doneCount = Number(dependencyReady) + Number(hasLocation) + Number(notifications.ready) + Number(hasDefaultMode);
|
||||||
|
const completeBtn = document.getElementById('setupCompleteBtn');
|
||||||
|
if (completeBtn) {
|
||||||
|
completeBtn.textContent = doneCount >= 3 ? 'Mark Setup Complete' : 'Complete Anyway';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(el, done, label) {
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.toggle('done', Boolean(done));
|
||||||
|
el.textContent = String(label || (done ? 'Done' : 'Pending'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasValidLocation() {
|
||||||
|
const rawLat = localStorage.getItem('observerLat');
|
||||||
|
const rawLon = localStorage.getItem('observerLon');
|
||||||
|
|
||||||
|
if (rawLat === null || rawLon === null || rawLat === '' || rawLon === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lat = Number(rawLat);
|
||||||
|
const lon = Number(rawLon);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
||||||
|
|
||||||
|
return lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationStatus() {
|
||||||
|
if (!('Notification' in window)) {
|
||||||
|
return { ready: true, label: 'Unsupported (optional)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
return { ready: true, label: 'Enabled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission === 'denied') {
|
||||||
|
return { ready: false, label: 'Blocked in browser' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ready: false, label: 'Permission needed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestNotifications() {
|
||||||
|
if (!('Notification' in window)) {
|
||||||
|
refreshStatuses();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Notification.requestPermission();
|
||||||
|
} catch (err) {
|
||||||
|
if (typeof reportActionableError === 'function') {
|
||||||
|
reportActionableError('Notifications', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshStatuses();
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeSetup() {
|
||||||
|
localStorage.setItem(COMPLETE_KEY, 'true');
|
||||||
|
close();
|
||||||
|
|
||||||
|
if (typeof showAppToast === 'function') {
|
||||||
|
showAppToast('Setup Complete', 'You can revisit these options in Settings.', 'info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (!overlayEl) return;
|
||||||
|
overlayEl.classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!overlayEl) return;
|
||||||
|
overlayEl.classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
refreshStatuses,
|
||||||
|
completeSetup,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
FirstRunSetup.init();
|
||||||
|
});
|
||||||
@@ -96,7 +96,10 @@ const RecordingUI = (function() {
|
|||||||
<div class="settings-feed-item">
|
<div class="settings-feed-item">
|
||||||
<div class="settings-feed-title">
|
<div class="settings-feed-title">
|
||||||
<span>${escapeHtml(rec.mode)}${rec.label ? ` • ${escapeHtml(rec.label)}` : ''}</span>
|
<span>${escapeHtml(rec.mode)}${rec.label ? ` • ${escapeHtml(rec.label)}` : ''}</span>
|
||||||
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.download('${rec.id}')">Download</button>
|
<div style="display:flex; gap:6px;">
|
||||||
|
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.openReplay('${rec.id}')">Replay</button>
|
||||||
|
<button class="preset-btn" style="font-size: 9px; padding: 2px 6px;" onclick="RecordingUI.download('${rec.id}')">Download</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-feed-meta">${new Date(rec.started_at).toLocaleString()}${rec.stopped_at ? ` → ${new Date(rec.stopped_at).toLocaleString()}` : ''}</div>
|
<div class="settings-feed-meta">${new Date(rec.started_at).toLocaleString()}${rec.stopped_at ? ` → ${new Date(rec.stopped_at).toLocaleString()}` : ''}</div>
|
||||||
<div class="settings-feed-meta">Events: ${rec.event_count || 0} • ${(rec.size_bytes || 0) / 1024.0 > 0 ? (rec.size_bytes / 1024).toFixed(1) + ' KB' : '0 KB'}</div>
|
<div class="settings-feed-meta">Events: ${rec.event_count || 0} • ${(rec.size_bytes || 0) / 1024.0 > 0 ? (rec.size_bytes / 1024).toFixed(1) + ' KB' : '0 KB'}</div>
|
||||||
@@ -109,6 +112,17 @@ const RecordingUI = (function() {
|
|||||||
window.open(`/recordings/${sessionId}/download`, '_blank');
|
window.open(`/recordings/${sessionId}/download`, '_blank');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openReplay(sessionId) {
|
||||||
|
if (!sessionId) return;
|
||||||
|
localStorage.setItem('analyticsReplaySession', sessionId);
|
||||||
|
if (typeof hideSettings === 'function') hideSettings();
|
||||||
|
if (typeof switchMode === 'function') {
|
||||||
|
switchMode('analytics', { updateUrl: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = '/?mode=analytics';
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
if (!str) return '';
|
if (!str) return '';
|
||||||
return String(str)
|
return String(str)
|
||||||
@@ -126,6 +140,7 @@ const RecordingUI = (function() {
|
|||||||
stop,
|
stop,
|
||||||
stopById,
|
stopById,
|
||||||
download,
|
download,
|
||||||
|
openReplay,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
const RunState = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const REFRESH_MS = 5000;
|
||||||
|
const CHIP_MODES = ['pager', 'sensor', 'wifi', 'bluetooth', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'dsc', 'dmr', 'subghz'];
|
||||||
|
const MODE_ALIASES = {
|
||||||
|
bt: 'bluetooth',
|
||||||
|
bt_locate: 'bluetooth',
|
||||||
|
btlocate: 'bluetooth',
|
||||||
|
aircraft: 'adsb',
|
||||||
|
};
|
||||||
|
|
||||||
|
const modeLabels = {
|
||||||
|
pager: 'Pager',
|
||||||
|
sensor: '433',
|
||||||
|
wifi: 'WiFi',
|
||||||
|
bluetooth: 'BT',
|
||||||
|
adsb: 'ADS-B',
|
||||||
|
ais: 'AIS',
|
||||||
|
acars: 'ACARS',
|
||||||
|
vdl2: 'VDL2',
|
||||||
|
aprs: 'APRS',
|
||||||
|
dsc: 'DSC',
|
||||||
|
dmr: 'DMR',
|
||||||
|
subghz: 'SubGHz',
|
||||||
|
};
|
||||||
|
|
||||||
|
let refreshTimer = null;
|
||||||
|
let activeMode = null;
|
||||||
|
let lastHealth = null;
|
||||||
|
let lastErrorToastAt = 0;
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const root = document.getElementById('runStateStrip');
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
wireActions();
|
||||||
|
wrapModeSwitch();
|
||||||
|
activeMode = inferCurrentMode();
|
||||||
|
renderHealth(null);
|
||||||
|
refresh();
|
||||||
|
|
||||||
|
if (!refreshTimer) {
|
||||||
|
refreshTimer = window.setInterval(refresh, REFRESH_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (!document.hidden) refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireActions() {
|
||||||
|
const refreshBtn = document.getElementById('runStateRefreshBtn');
|
||||||
|
if (refreshBtn) {
|
||||||
|
refreshBtn.addEventListener('click', () => refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsBtn = document.getElementById('runStateSettingsBtn');
|
||||||
|
if (settingsBtn) {
|
||||||
|
settingsBtn.addEventListener('click', () => {
|
||||||
|
if (typeof showSettings === 'function') {
|
||||||
|
showSettings();
|
||||||
|
if (typeof switchSettingsTab === 'function') {
|
||||||
|
switchSettingsTab('tools');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapModeSwitch() {
|
||||||
|
if (typeof window.switchMode !== 'function') return;
|
||||||
|
if (window.switchMode.__runStateWrapped) return;
|
||||||
|
|
||||||
|
const original = window.switchMode;
|
||||||
|
const wrapped = function(mode) {
|
||||||
|
if (mode) {
|
||||||
|
activeMode = normalizeMode(String(mode));
|
||||||
|
}
|
||||||
|
const result = original.apply(this, arguments);
|
||||||
|
markActiveChip();
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
wrapped.__runStateWrapped = true;
|
||||||
|
window.switchMode = wrapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/health');
|
||||||
|
const data = await response.json();
|
||||||
|
lastHealth = data;
|
||||||
|
renderHealth(data);
|
||||||
|
} catch (err) {
|
||||||
|
renderHealth(null, err);
|
||||||
|
const now = Date.now();
|
||||||
|
if (typeof reportActionableError === 'function' && (now - lastErrorToastAt) > 30000) {
|
||||||
|
lastErrorToastAt = now;
|
||||||
|
reportActionableError('Run State', err, { persistent: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHealth(data, err) {
|
||||||
|
const chipsContainer = document.getElementById('runStateChips');
|
||||||
|
const summaryEl = document.getElementById('runStateSummary');
|
||||||
|
if (!chipsContainer || !summaryEl) return;
|
||||||
|
|
||||||
|
chipsContainer.innerHTML = '';
|
||||||
|
|
||||||
|
if (!data || data.status !== 'healthy') {
|
||||||
|
const offline = buildChip('API', false);
|
||||||
|
offline.classList.add('active');
|
||||||
|
chipsContainer.appendChild(offline);
|
||||||
|
summaryEl.textContent = err ? `Health unavailable: ${extractMessage(err)}` : 'Health unavailable';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processes = normalizeProcesses(data.processes || {});
|
||||||
|
for (const mode of CHIP_MODES) {
|
||||||
|
const isRunning = Boolean(processes[mode]);
|
||||||
|
chipsContainer.appendChild(buildChip(modeLabels[mode] || mode.toUpperCase(), isRunning, mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = data.data || {};
|
||||||
|
summaryEl.textContent = `Aircraft ${counts.aircraft_count || 0} | Vessels ${counts.vessel_count || 0} | WiFi ${counts.wifi_networks_count || 0} | BT ${counts.bt_devices_count || 0}`;
|
||||||
|
markActiveChip();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChip(label, running, mode) {
|
||||||
|
const chip = document.createElement('span');
|
||||||
|
chip.className = `run-state-chip${running ? ' running' : ''}`;
|
||||||
|
if (mode) {
|
||||||
|
chip.dataset.mode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dot = document.createElement('span');
|
||||||
|
dot.className = 'dot';
|
||||||
|
chip.appendChild(dot);
|
||||||
|
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.textContent = label;
|
||||||
|
chip.appendChild(text);
|
||||||
|
|
||||||
|
return chip;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markActiveChip() {
|
||||||
|
if (!activeMode) {
|
||||||
|
activeMode = inferCurrentMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('#runStateChips .run-state-chip').forEach((chip) => {
|
||||||
|
chip.classList.remove('active');
|
||||||
|
if (chip.dataset.mode && chip.dataset.mode === normalizeMode(activeMode)) {
|
||||||
|
chip.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferCurrentMode() {
|
||||||
|
const modeParam = new URLSearchParams(window.location.search).get('mode');
|
||||||
|
if (modeParam) return normalizeMode(modeParam);
|
||||||
|
|
||||||
|
if (typeof window.currentMode === 'string' && window.currentMode) {
|
||||||
|
return normalizeMode(window.currentMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const indicator = document.getElementById('activeModeIndicator');
|
||||||
|
if (!indicator) return 'pager';
|
||||||
|
|
||||||
|
const text = indicator.textContent || '';
|
||||||
|
const normalized = text.toLowerCase();
|
||||||
|
if (normalized.includes('wifi')) return 'wifi';
|
||||||
|
if (normalized.includes('bluetooth')) return 'bluetooth';
|
||||||
|
if (normalized.includes('bt locate')) return 'bluetooth';
|
||||||
|
if (normalized.includes('ads-b')) return 'adsb';
|
||||||
|
if (normalized.includes('ais')) return 'ais';
|
||||||
|
if (normalized.includes('acars')) return 'acars';
|
||||||
|
if (normalized.includes('vdl2')) return 'vdl2';
|
||||||
|
if (normalized.includes('aprs')) return 'aprs';
|
||||||
|
if (normalized.includes('dsc')) return 'dsc';
|
||||||
|
if (normalized.includes('subghz')) return 'subghz';
|
||||||
|
if (normalized.includes('dmr')) return 'dmr';
|
||||||
|
if (normalized.includes('433')) return 'sensor';
|
||||||
|
return 'pager';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMode(mode) {
|
||||||
|
const value = String(mode || '').trim().toLowerCase();
|
||||||
|
if (!value) return 'pager';
|
||||||
|
return MODE_ALIASES[value] || value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProcesses(raw) {
|
||||||
|
const processes = Object.assign({}, raw || {});
|
||||||
|
processes.bluetooth = Boolean(
|
||||||
|
processes.bluetooth ||
|
||||||
|
processes.bt ||
|
||||||
|
processes.bt_scan ||
|
||||||
|
processes.btlocate ||
|
||||||
|
processes.bt_locate
|
||||||
|
);
|
||||||
|
processes.wifi = Boolean(
|
||||||
|
processes.wifi ||
|
||||||
|
processes.wifi_scan ||
|
||||||
|
processes.wlan
|
||||||
|
);
|
||||||
|
return processes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMessage(err) {
|
||||||
|
if (!err) return 'Unknown error';
|
||||||
|
if (typeof err === 'string') return err;
|
||||||
|
if (err.message) return err.message;
|
||||||
|
return String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLastHealth() {
|
||||||
|
return lastHealth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
if (refreshTimer) {
|
||||||
|
clearInterval(refreshTimer);
|
||||||
|
refreshTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
refresh,
|
||||||
|
destroy,
|
||||||
|
getLastHealth,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
RunState.init();
|
||||||
|
});
|
||||||
@@ -22,15 +22,16 @@ const Settings = {
|
|||||||
cartodb_dark: {
|
cartodb_dark: {
|
||||||
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png',
|
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png',
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||||
subdomains: 'abcd'
|
subdomains: 'abcd',
|
||||||
|
mapTheme: 'cyber',
|
||||||
|
options: {}
|
||||||
},
|
},
|
||||||
cartodb_dark_cyan: {
|
cartodb_dark_cyan: {
|
||||||
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png',
|
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png',
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||||
subdomains: 'abcd',
|
subdomains: 'abcd',
|
||||||
options: {
|
mapTheme: 'cyber',
|
||||||
className: 'tile-layer-cyan'
|
options: {}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
cartodb_light: {
|
cartodb_light: {
|
||||||
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png',
|
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png',
|
||||||
@@ -50,26 +51,153 @@ const Settings = {
|
|||||||
// Current settings cache
|
// Current settings cache
|
||||||
_cache: {},
|
_cache: {},
|
||||||
|
|
||||||
|
// Init guard to prevent concurrent fetch races across pages/modes
|
||||||
|
_initialized: false,
|
||||||
|
_initPromise: null,
|
||||||
|
_themeObserver: null,
|
||||||
|
_themeObserverStarted: false,
|
||||||
|
_themeObserverRaf: null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a tile provider key is valid.
|
||||||
|
* @param {string} provider
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
_isKnownTileProvider(provider) {
|
||||||
|
if (typeof provider !== 'string') return false;
|
||||||
|
const key = provider.trim();
|
||||||
|
return key === 'custom' || Object.prototype.hasOwnProperty.call(this.tileProviders, key);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize tile provider values from storage/UI.
|
||||||
|
* @param {string} provider
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
_normalizeTileProvider(provider) {
|
||||||
|
if (typeof provider !== 'string') return this.defaults['offline.tile_provider'];
|
||||||
|
const key = provider.trim();
|
||||||
|
if (this._isKnownTileProvider(key)) return key;
|
||||||
|
return this.defaults['offline.tile_provider'];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist and retrieve preferred map theme behavior for dark Carto tiles.
|
||||||
|
* Helps keep Cyber style enabled even if server-side tile provider drifts.
|
||||||
|
*/
|
||||||
|
_getMapThemePreference() {
|
||||||
|
if (typeof localStorage === 'undefined') return 'cyber';
|
||||||
|
const pref = localStorage.getItem('intercept_map_theme_pref');
|
||||||
|
if (pref === 'none' || pref === 'cyber') return pref;
|
||||||
|
return 'cyber';
|
||||||
|
},
|
||||||
|
|
||||||
|
_setMapThemePreference(pref) {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
if (pref !== 'none' && pref !== 'cyber') return;
|
||||||
|
localStorage.setItem('intercept_map_theme_pref', pref);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether Cyber map theme should be considered active globally.
|
||||||
|
* @param {Object} [config]
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
_isCyberThemeEnabled(config) {
|
||||||
|
const resolvedConfig = config || this.getTileConfig();
|
||||||
|
return this._getMapThemeClass(resolvedConfig) === 'map-theme-cyber';
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle root class used for hard global Leaflet theming.
|
||||||
|
* @param {Object} [config]
|
||||||
|
*/
|
||||||
|
_syncRootMapThemeClass(config) {
|
||||||
|
if (typeof document === 'undefined' || !document.documentElement) return;
|
||||||
|
const enabled = this._isCyberThemeEnabled(config);
|
||||||
|
document.documentElement.classList.toggle('map-cyber-enabled', enabled);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefer localStorage tile settings when available to avoid stale server values.
|
||||||
|
*/
|
||||||
|
_applyLocalTileOverrides() {
|
||||||
|
const stored = localStorage.getItem('intercept_settings');
|
||||||
|
if (!stored) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const local = JSON.parse(stored) || {};
|
||||||
|
const localProvider = this._normalizeTileProvider(local['offline.tile_provider']);
|
||||||
|
if (localProvider) {
|
||||||
|
this._cache['offline.tile_provider'] = localProvider;
|
||||||
|
}
|
||||||
|
if (typeof local['offline.tile_server_url'] === 'string') {
|
||||||
|
this._cache['offline.tile_server_url'] = local['offline.tile_server_url'];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore malformed local settings and keep current cache.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize settings - load from server/localStorage
|
* Initialize settings - load from server/localStorage
|
||||||
*/
|
*/
|
||||||
async init() {
|
async init(options = {}) {
|
||||||
try {
|
const force = Boolean(options && options.force);
|
||||||
const response = await fetch('/offline/settings');
|
|
||||||
if (response.ok) {
|
if (!force && this._initialized) {
|
||||||
const data = await response.json();
|
return this._cache;
|
||||||
this._cache = { ...this.defaults, ...data.settings };
|
|
||||||
} else {
|
|
||||||
// Fall back to localStorage
|
|
||||||
this._loadFromLocalStorage();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to load settings from server, using localStorage:', e);
|
|
||||||
this._loadFromLocalStorage();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this._updateUI();
|
if (!force && this._initPromise) {
|
||||||
return this._cache;
|
return this._initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._initPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/offline/settings');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
this._cache = { ...this.defaults, ...data.settings };
|
||||||
|
} else {
|
||||||
|
// Fall back to localStorage
|
||||||
|
this._loadFromLocalStorage();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to load settings from server, using localStorage:', e);
|
||||||
|
this._loadFromLocalStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._applyLocalTileOverrides();
|
||||||
|
this._cache['offline.tile_provider'] = this._normalizeTileProvider(this._cache['offline.tile_provider']);
|
||||||
|
|
||||||
|
// If dark Carto was restored by stale server settings but user prefers Cyber,
|
||||||
|
// keep the visible provider aligned with Cyber selection.
|
||||||
|
if (this._cache['offline.tile_provider'] === 'cartodb_dark' && this._getMapThemePreference() === 'cyber') {
|
||||||
|
this._cache['offline.tile_provider'] = 'cartodb_dark_cyan';
|
||||||
|
}
|
||||||
|
this._updateUI();
|
||||||
|
|
||||||
|
// Re-apply map theme to already-registered maps in case init happened after map creation.
|
||||||
|
const allMaps = this._collectMaps();
|
||||||
|
if (allMaps.length > 0) {
|
||||||
|
const config = this.getTileConfig();
|
||||||
|
allMaps.forEach((map) => this._applyMapTheme(map, config));
|
||||||
|
}
|
||||||
|
const activeConfig = this.getTileConfig();
|
||||||
|
this._syncRootMapThemeClass(activeConfig);
|
||||||
|
this._applyThemeToAllContainers(activeConfig);
|
||||||
|
this._ensureThemeObserver();
|
||||||
|
|
||||||
|
this._initialized = true;
|
||||||
|
return this._cache;
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this._initPromise;
|
||||||
|
} finally {
|
||||||
|
this._initPromise = null;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,11 +227,14 @@ const Settings = {
|
|||||||
|
|
||||||
// Save to server
|
// Save to server
|
||||||
try {
|
try {
|
||||||
await fetch('/offline/settings', {
|
const response = await fetch('/offline/settings', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ key, value })
|
body: JSON.stringify({ key, value })
|
||||||
});
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Save failed (${response.status})`);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to save setting to server:', e);
|
console.warn('Failed to save setting to server:', e);
|
||||||
}
|
}
|
||||||
@@ -152,6 +283,16 @@ const Settings = {
|
|||||||
* Set tile provider
|
* Set tile provider
|
||||||
*/
|
*/
|
||||||
async setTileProvider(provider) {
|
async setTileProvider(provider) {
|
||||||
|
provider = this._normalizeTileProvider(provider);
|
||||||
|
|
||||||
|
if (provider === 'cartodb_dark_cyan') {
|
||||||
|
this._setMapThemePreference('cyber');
|
||||||
|
} else if (provider === 'cartodb_dark') {
|
||||||
|
this._setMapThemePreference('none');
|
||||||
|
} else {
|
||||||
|
this._setMapThemePreference('none');
|
||||||
|
}
|
||||||
|
|
||||||
await this._save('offline.tile_provider', provider);
|
await this._save('offline.tile_provider', provider);
|
||||||
|
|
||||||
// Show/hide custom URL input
|
// Show/hide custom URL input
|
||||||
@@ -160,10 +301,11 @@ const Settings = {
|
|||||||
customRow.style.display = provider === 'custom' ? 'block' : 'none';
|
customRow.style.display = provider === 'custom' ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not custom and we have a map, update tiles immediately
|
// Update tiles immediately for all providers.
|
||||||
if (provider !== 'custom') {
|
this._updateMapTiles();
|
||||||
this._updateMapTiles();
|
const activeConfig = this.getTileConfig();
|
||||||
}
|
this._syncRootMapThemeClass(activeConfig);
|
||||||
|
this._applyThemeToAllContainers(activeConfig);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -178,7 +320,7 @@ const Settings = {
|
|||||||
* Get current tile configuration
|
* Get current tile configuration
|
||||||
*/
|
*/
|
||||||
getTileConfig() {
|
getTileConfig() {
|
||||||
const provider = this.get('offline.tile_provider');
|
const provider = this._normalizeTileProvider(this.get('offline.tile_provider'));
|
||||||
|
|
||||||
if (provider === 'custom') {
|
if (provider === 'custom') {
|
||||||
const customUrl = this.get('offline.tile_server_url');
|
const customUrl = this.get('offline.tile_server_url');
|
||||||
@@ -189,7 +331,170 @@ const Settings = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.tileProviders[provider] || this.tileProviders.cartodb_dark;
|
const config = this.tileProviders[provider] || this.tileProviders.cartodb_dark;
|
||||||
|
|
||||||
|
// Robust fallback: if dark Carto is active and Cyber is preferred,
|
||||||
|
// keep Cyber theme enabled even when provider temporarily reverts.
|
||||||
|
if (provider === 'cartodb_dark' && this._getMapThemePreference() === 'cyber') {
|
||||||
|
return { ...config, mapTheme: 'cyber' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve map theme class from tile config.
|
||||||
|
* @param {Object} config
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
_getMapThemeClass(config) {
|
||||||
|
if (!config || !config.mapTheme) return null;
|
||||||
|
if (config.mapTheme === 'cyber') return 'map-theme-cyber';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply or clear map theme styles for a Leaflet container.
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
* @param {Object} [config]
|
||||||
|
*/
|
||||||
|
_applyThemeToContainer(container, config) {
|
||||||
|
if (!container || !container.classList) return;
|
||||||
|
const tilePane = container.querySelector('.leaflet-tile-pane');
|
||||||
|
|
||||||
|
container.querySelectorAll('.intercept-map-theme-overlay').forEach((el) => el.remove());
|
||||||
|
|
||||||
|
if (tilePane && tilePane.style) {
|
||||||
|
tilePane.style.filter = '';
|
||||||
|
tilePane.style.opacity = '';
|
||||||
|
tilePane.style.willChange = '';
|
||||||
|
}
|
||||||
|
if (container.style) {
|
||||||
|
container.style.background = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
container.classList.remove('map-theme-cyber');
|
||||||
|
|
||||||
|
const resolvedConfig = config || this.getTileConfig();
|
||||||
|
const themeClass = this._getMapThemeClass(resolvedConfig);
|
||||||
|
if (!themeClass) return;
|
||||||
|
|
||||||
|
container.classList.add(themeClass);
|
||||||
|
|
||||||
|
if (container.style) {
|
||||||
|
container.style.background = '#020813';
|
||||||
|
}
|
||||||
|
if (tilePane && tilePane.style) {
|
||||||
|
tilePane.style.filter = 'sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08)';
|
||||||
|
tilePane.style.opacity = '1';
|
||||||
|
tilePane.style.willChange = 'filter';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid/glow overlays are rendered via CSS pseudo elements on
|
||||||
|
// `html.map-cyber-enabled .leaflet-container` for consistent stacking.
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply/remove map theme class on a Leaflet map container.
|
||||||
|
* @param {L.Map} map
|
||||||
|
* @param {Object} [config]
|
||||||
|
*/
|
||||||
|
_applyMapTheme(map, config) {
|
||||||
|
if (!map || typeof map.getContainer !== 'function') return;
|
||||||
|
const container = map.getContainer();
|
||||||
|
this._applyThemeToContainer(container, config);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply current map theme to all rendered Leaflet containers.
|
||||||
|
* Covers maps that were not explicitly registered with Settings.
|
||||||
|
* @param {Object} [config]
|
||||||
|
*/
|
||||||
|
_applyThemeToAllContainers(config) {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
const containers = document.querySelectorAll('.leaflet-container');
|
||||||
|
if (!containers.length) return;
|
||||||
|
|
||||||
|
const resolvedConfig = config || this.getTileConfig();
|
||||||
|
this._syncRootMapThemeClass(resolvedConfig);
|
||||||
|
containers.forEach((container) => this._applyThemeToContainer(container, resolvedConfig));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch the DOM for new Leaflet maps and apply current theme automatically.
|
||||||
|
*/
|
||||||
|
_ensureThemeObserver() {
|
||||||
|
if (this._themeObserverStarted || typeof MutationObserver === 'undefined') return;
|
||||||
|
if (typeof document === 'undefined' || !document.body) return;
|
||||||
|
|
||||||
|
const scheduleApply = () => {
|
||||||
|
if (this._themeObserverRaf && typeof cancelAnimationFrame === 'function') {
|
||||||
|
cancelAnimationFrame(this._themeObserverRaf);
|
||||||
|
}
|
||||||
|
if (typeof requestAnimationFrame === 'function') {
|
||||||
|
this._themeObserverRaf = requestAnimationFrame(() => {
|
||||||
|
this._themeObserverRaf = null;
|
||||||
|
this._applyThemeToAllContainers(this.getTileConfig());
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this._applyThemeToAllContainers(this.getTileConfig());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this._themeObserver = new MutationObserver((mutations) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
if (!mutation.addedNodes || mutation.addedNodes.length === 0) continue;
|
||||||
|
for (const node of mutation.addedNodes) {
|
||||||
|
if (!(node instanceof Element)) continue;
|
||||||
|
if (node.classList.contains('leaflet-container') || node.querySelector('.leaflet-container')) {
|
||||||
|
scheduleApply();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._themeObserver.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
|
|
||||||
|
this._themeObserverStarted = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all known map instances.
|
||||||
|
* @returns {L.Map[]}
|
||||||
|
*/
|
||||||
|
_collectMaps() {
|
||||||
|
const windowMaps = [
|
||||||
|
window.map,
|
||||||
|
window.leafletMap,
|
||||||
|
window.aprsMap,
|
||||||
|
window.radarMap,
|
||||||
|
window.vesselMap,
|
||||||
|
window.groundMap,
|
||||||
|
window.groundTrackMap,
|
||||||
|
window.meshMap,
|
||||||
|
window.issMap
|
||||||
|
].filter(m => m && typeof m.eachLayer === 'function');
|
||||||
|
|
||||||
|
return [...new Set([...this._registeredMaps, ...windowMaps])];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep map theme stable if map internals or layers are refreshed.
|
||||||
|
* @param {L.Map} map - Leaflet map instance
|
||||||
|
*/
|
||||||
|
_attachMapThemeHooks(map) {
|
||||||
|
if (!map || typeof map.on !== 'function' || map._interceptThemeHookBound) return;
|
||||||
|
|
||||||
|
const reapplyTheme = () => this._applyMapTheme(map);
|
||||||
|
const hookEvents = ['layeradd', 'layerremove', 'zoomend', 'resize', 'load'];
|
||||||
|
hookEvents.forEach((eventName) => map.on(eventName, reapplyTheme));
|
||||||
|
|
||||||
|
map._interceptThemeHookBound = true;
|
||||||
|
map._interceptThemeHookHandler = reapplyTheme;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -200,6 +505,18 @@ const Settings = {
|
|||||||
if (map && typeof map.eachLayer === 'function' && !this._registeredMaps.includes(map)) {
|
if (map && typeof map.eachLayer === 'function' && !this._registeredMaps.includes(map)) {
|
||||||
this._registeredMaps.push(map);
|
this._registeredMaps.push(map);
|
||||||
}
|
}
|
||||||
|
this._ensureThemeObserver();
|
||||||
|
this._attachMapThemeHooks(map);
|
||||||
|
this._applyMapTheme(map);
|
||||||
|
this._applyThemeToAllContainers(this.getTileConfig());
|
||||||
|
|
||||||
|
// Some maps create tile DOM asynchronously; re-apply after first paint.
|
||||||
|
if (typeof window !== 'undefined' && typeof window.setTimeout === 'function') {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
this._applyMapTheme(map);
|
||||||
|
this._applyThemeToAllContainers(this.getTileConfig());
|
||||||
|
}, 120);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -211,6 +528,15 @@ const Settings = {
|
|||||||
if (idx > -1) {
|
if (idx > -1) {
|
||||||
this._registeredMaps.splice(idx, 1);
|
this._registeredMaps.splice(idx, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (map && map._interceptThemeHookBound && typeof map.off === 'function') {
|
||||||
|
const handler = map._interceptThemeHookHandler;
|
||||||
|
['layeradd', 'layerremove', 'zoomend', 'resize', 'load'].forEach((eventName) => {
|
||||||
|
map.off(eventName, handler);
|
||||||
|
});
|
||||||
|
delete map._interceptThemeHookBound;
|
||||||
|
delete map._interceptThemeHookHandler;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -323,31 +649,29 @@ const Settings = {
|
|||||||
if (customRow) {
|
if (customRow) {
|
||||||
customRow.style.display = this.get('offline.tile_provider') === 'custom' ? 'block' : 'none';
|
customRow.style.display = this.get('offline.tile_provider') === 'custom' ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Theme select
|
||||||
|
const themeSelect = document.getElementById('themeSelect');
|
||||||
|
if (themeSelect) {
|
||||||
|
themeSelect.value = localStorage.getItem('intercept-theme') || 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animations toggle
|
||||||
|
const animationsEnabled = document.getElementById('animationsEnabled');
|
||||||
|
if (animationsEnabled) {
|
||||||
|
animationsEnabled.checked = localStorage.getItem('intercept-animations') !== 'off';
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update map tiles on all known maps
|
* Update map tiles on all known maps
|
||||||
*/
|
*/
|
||||||
_updateMapTiles() {
|
_updateMapTiles() {
|
||||||
// Combine registered maps with common window map variables
|
const allMaps = this._collectMaps();
|
||||||
const windowMaps = [
|
|
||||||
window.map,
|
|
||||||
window.leafletMap,
|
|
||||||
window.aprsMap,
|
|
||||||
window.radarMap,
|
|
||||||
window.vesselMap,
|
|
||||||
window.groundMap,
|
|
||||||
window.groundTrackMap,
|
|
||||||
window.meshMap,
|
|
||||||
window.issMap
|
|
||||||
].filter(m => m && typeof m.eachLayer === 'function');
|
|
||||||
|
|
||||||
// Combine with registered maps, removing duplicates
|
|
||||||
const allMaps = [...new Set([...this._registeredMaps, ...windowMaps])];
|
|
||||||
|
|
||||||
if (allMaps.length === 0) return;
|
if (allMaps.length === 0) return;
|
||||||
|
|
||||||
const config = this.getTileConfig();
|
const config = this.getTileConfig();
|
||||||
|
this._syncRootMapThemeClass(config);
|
||||||
|
|
||||||
allMaps.forEach(map => {
|
allMaps.forEach(map => {
|
||||||
// Remove existing tile layers
|
// Remove existing tile layers
|
||||||
@@ -368,7 +692,10 @@ const Settings = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
L.tileLayer(config.url, options).addTo(map);
|
L.tileLayer(config.url, options).addTo(map);
|
||||||
|
this._applyMapTheme(map, config);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this._applyThemeToAllContainers(config);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -423,10 +750,16 @@ const Settings = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Settings modal functions
|
// Settings modal functions
|
||||||
|
let lastSettingsFocusEl = null;
|
||||||
|
|
||||||
function showSettings() {
|
function showSettings() {
|
||||||
const modal = document.getElementById('settingsModal');
|
const modal = document.getElementById('settingsModal');
|
||||||
if (modal) {
|
if (modal) {
|
||||||
|
lastSettingsFocusEl = document.activeElement;
|
||||||
modal.classList.add('active');
|
modal.classList.add('active');
|
||||||
|
modal.setAttribute('aria-hidden', 'false');
|
||||||
|
const content = modal.querySelector('.settings-content');
|
||||||
|
if (content) content.focus();
|
||||||
Settings.init().then(() => {
|
Settings.init().then(() => {
|
||||||
Settings.checkAssets();
|
Settings.checkAssets();
|
||||||
});
|
});
|
||||||
@@ -437,18 +770,27 @@ function hideSettings() {
|
|||||||
const modal = document.getElementById('settingsModal');
|
const modal = document.getElementById('settingsModal');
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.classList.remove('active');
|
modal.classList.remove('active');
|
||||||
|
modal.setAttribute('aria-hidden', 'true');
|
||||||
|
if (lastSettingsFocusEl && typeof lastSettingsFocusEl.focus === 'function') {
|
||||||
|
lastSettingsFocusEl.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchSettingsTab(tabName) {
|
function switchSettingsTab(tabName) {
|
||||||
// Update tab buttons
|
// Update tab buttons
|
||||||
document.querySelectorAll('.settings-tab').forEach(tab => {
|
document.querySelectorAll('.settings-tab').forEach(tab => {
|
||||||
tab.classList.toggle('active', tab.dataset.tab === tabName);
|
const isActive = tab.dataset.tab === tabName;
|
||||||
|
tab.classList.toggle('active', isActive);
|
||||||
|
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update sections
|
// Update sections
|
||||||
document.querySelectorAll('.settings-section').forEach(section => {
|
document.querySelectorAll('.settings-section').forEach(section => {
|
||||||
section.classList.toggle('active', section.id === `settings-${tabName}`);
|
const isActive = section.id === `settings-${tabName}`;
|
||||||
|
section.classList.toggle('active', isActive);
|
||||||
|
section.hidden = !isActive;
|
||||||
|
section.setAttribute('role', 'tabpanel');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load tools/dependencies when that tab is selected
|
// Load tools/dependencies when that tab is selected
|
||||||
@@ -545,11 +887,6 @@ function loadSettingsTools() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize settings on page load
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
Settings.init();
|
|
||||||
});
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Location Settings Functions
|
// Location Settings Functions
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -582,7 +919,7 @@ function loadObserverLocation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sync dashboard-specific location keys for backward compatibility
|
// Sync dashboard-specific location keys for backward compatibility
|
||||||
if (lat && lon) {
|
if (lat !== undefined && lat !== null && lat !== '' && lon !== undefined && lon !== null && lon !== '') {
|
||||||
const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) });
|
const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) });
|
||||||
if (!localStorage.getItem('observerLocation')) {
|
if (!localStorage.getItem('observerLocation')) {
|
||||||
localStorage.setItem('observerLocation', locationObj);
|
localStorage.setItem('observerLocation', locationObj);
|
||||||
@@ -907,12 +1244,17 @@ const _originalSwitchSettingsTab = typeof switchSettingsTab !== 'undefined' ? sw
|
|||||||
function switchSettingsTab(tabName) {
|
function switchSettingsTab(tabName) {
|
||||||
// Update tab buttons
|
// Update tab buttons
|
||||||
document.querySelectorAll('.settings-tab').forEach(tab => {
|
document.querySelectorAll('.settings-tab').forEach(tab => {
|
||||||
tab.classList.toggle('active', tab.dataset.tab === tabName);
|
const isActive = tab.dataset.tab === tabName;
|
||||||
|
tab.classList.toggle('active', isActive);
|
||||||
|
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update sections
|
// Update sections
|
||||||
document.querySelectorAll('.settings-section').forEach(section => {
|
document.querySelectorAll('.settings-section').forEach(section => {
|
||||||
section.classList.toggle('active', section.id === `settings-${tabName}`);
|
const isActive = section.id === `settings-${tabName}`;
|
||||||
|
section.classList.toggle('active', isActive);
|
||||||
|
section.hidden = !isActive;
|
||||||
|
section.setAttribute('role', 'tabpanel');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load content based on tab
|
// Load content based on tab
|
||||||
@@ -983,3 +1325,45 @@ function toggleApiKeyVisibility() {
|
|||||||
if (!input) return;
|
if (!input) return;
|
||||||
input.type = input.type === 'password' ? 'text' : 'password';
|
input.type = input.type === 'password' ? 'text' : 'password';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set theme preference from the Display settings tab
|
||||||
|
*/
|
||||||
|
function setThemePreference(value) {
|
||||||
|
document.documentElement.setAttribute('data-theme', value);
|
||||||
|
localStorage.setItem('intercept-theme', value);
|
||||||
|
|
||||||
|
const btn = document.getElementById('themeToggle');
|
||||||
|
if (btn) {
|
||||||
|
btn.textContent = value === 'light' ? '🌙' : '☀️';
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ theme: value })
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set animations preference from the Display settings tab
|
||||||
|
*/
|
||||||
|
function setAnimationsEnabled(enabled) {
|
||||||
|
if (enabled) {
|
||||||
|
document.documentElement.removeAttribute('data-animations');
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-animations', 'off');
|
||||||
|
}
|
||||||
|
localStorage.setItem('intercept-animations', enabled ? 'on' : 'off');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window._settingsEscapeHandlerBound) {
|
||||||
|
window._settingsEscapeHandlerBound = true;
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key !== 'Escape') return;
|
||||||
|
const modal = document.getElementById('settingsModal');
|
||||||
|
if (modal && modal.classList.contains('active')) {
|
||||||
|
hideSettings();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
const AppFeedback = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let stackEl = null;
|
||||||
|
let nextToastId = 1;
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
ensureStack();
|
||||||
|
installGlobalHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureStack() {
|
||||||
|
if (stackEl && document.body.contains(stackEl)) return stackEl;
|
||||||
|
|
||||||
|
stackEl = document.getElementById('appToastStack');
|
||||||
|
if (!stackEl) {
|
||||||
|
stackEl = document.createElement('div');
|
||||||
|
stackEl.id = 'appToastStack';
|
||||||
|
stackEl.className = 'app-toast-stack';
|
||||||
|
document.body.appendChild(stackEl);
|
||||||
|
}
|
||||||
|
return stackEl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(options) {
|
||||||
|
const opts = options || {};
|
||||||
|
const type = normalizeType(opts.type);
|
||||||
|
const id = nextToastId++;
|
||||||
|
const durationMs = Number.isFinite(opts.durationMs) ? opts.durationMs : 6500;
|
||||||
|
|
||||||
|
const root = document.createElement('div');
|
||||||
|
root.className = `app-toast ${type}`;
|
||||||
|
root.dataset.toastId = String(id);
|
||||||
|
|
||||||
|
const titleEl = document.createElement('div');
|
||||||
|
titleEl.className = 'app-toast-title';
|
||||||
|
titleEl.textContent = String(opts.title || defaultTitle(type));
|
||||||
|
root.appendChild(titleEl);
|
||||||
|
|
||||||
|
const msgEl = document.createElement('div');
|
||||||
|
msgEl.className = 'app-toast-msg';
|
||||||
|
msgEl.textContent = String(opts.message || '');
|
||||||
|
root.appendChild(msgEl);
|
||||||
|
|
||||||
|
const actions = Array.isArray(opts.actions) ? opts.actions.filter(Boolean).slice(0, 3) : [];
|
||||||
|
if (actions.length > 0) {
|
||||||
|
const actionsEl = document.createElement('div');
|
||||||
|
actionsEl.className = 'app-toast-actions';
|
||||||
|
for (const action of actions) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.textContent = String(action.label || 'Action');
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
try {
|
||||||
|
if (typeof action.onClick === 'function') {
|
||||||
|
action.onClick();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
removeToast(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
actionsEl.appendChild(btn);
|
||||||
|
}
|
||||||
|
root.appendChild(actionsEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureStack().appendChild(root);
|
||||||
|
|
||||||
|
if (durationMs > 0) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
removeToast(id);
|
||||||
|
}, durationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeToast(id) {
|
||||||
|
if (!stackEl) return;
|
||||||
|
const toastEl = stackEl.querySelector(`[data-toast-id="${id}"]`);
|
||||||
|
if (!toastEl) return;
|
||||||
|
toastEl.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportError(context, error, options) {
|
||||||
|
const opts = options || {};
|
||||||
|
const message = extractMessage(error);
|
||||||
|
const actions = [];
|
||||||
|
|
||||||
|
if (isSettingsError(message)) {
|
||||||
|
actions.push({
|
||||||
|
label: 'Open Settings',
|
||||||
|
onClick: () => {
|
||||||
|
if (typeof showSettings === 'function') {
|
||||||
|
showSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNetworkError(message)) {
|
||||||
|
actions.push({
|
||||||
|
label: 'Retry',
|
||||||
|
onClick: () => {
|
||||||
|
if (typeof opts.onRetry === 'function') {
|
||||||
|
opts.onRetry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof opts.extraAction === 'function' && opts.extraActionLabel) {
|
||||||
|
actions.push({
|
||||||
|
label: String(opts.extraActionLabel),
|
||||||
|
onClick: opts.extraAction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return toast({
|
||||||
|
type: 'error',
|
||||||
|
title: context || 'Action Failed',
|
||||||
|
message,
|
||||||
|
actions,
|
||||||
|
durationMs: opts.persistent ? 0 : 8500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function installGlobalHandlers() {
|
||||||
|
window.addEventListener('error', (event) => {
|
||||||
|
const target = event && event.target;
|
||||||
|
if (target && (target.tagName === 'IMG' || target.tagName === 'SCRIPT')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = extractMessage(event && event.error) || String(event.message || 'Unknown error');
|
||||||
|
if (shouldIgnore(message)) return;
|
||||||
|
toast({
|
||||||
|
type: 'warning',
|
||||||
|
title: 'Unhandled Error',
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
const message = extractMessage(event && event.reason);
|
||||||
|
if (shouldIgnore(message)) return;
|
||||||
|
toast({
|
||||||
|
type: 'warning',
|
||||||
|
title: 'Promise Rejection',
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeType(type) {
|
||||||
|
const t = String(type || 'info').toLowerCase();
|
||||||
|
if (t === 'error' || t === 'warning') return t;
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultTitle(type) {
|
||||||
|
if (type === 'error') return 'Error';
|
||||||
|
if (type === 'warning') return 'Warning';
|
||||||
|
return 'Notice';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMessage(error) {
|
||||||
|
if (!error) return 'Unknown error';
|
||||||
|
if (typeof error === 'string') return error;
|
||||||
|
if (error instanceof Error) return error.message || error.name;
|
||||||
|
if (typeof error.message === 'string') return error.message;
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldIgnore(message) {
|
||||||
|
const text = String(message || '').toLowerCase();
|
||||||
|
return text.includes('script error') || text.includes('resizeobserver loop limit exceeded');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCollectionState(container, options) {
|
||||||
|
if (!container) return null;
|
||||||
|
const opts = options || {};
|
||||||
|
const type = String(opts.type || 'empty').toLowerCase();
|
||||||
|
const message = String(opts.message || (type === 'loading' ? 'Loading...' : 'No data available'));
|
||||||
|
const className = opts.className || `app-collection-state is-${type}`;
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (container.tagName === 'TBODY') {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.className = 'app-collection-state-row';
|
||||||
|
const cell = document.createElement('td');
|
||||||
|
const columns = Number.isFinite(opts.columns) ? opts.columns : 1;
|
||||||
|
cell.colSpan = Math.max(1, columns);
|
||||||
|
const state = document.createElement('div');
|
||||||
|
state.className = className;
|
||||||
|
state.textContent = message;
|
||||||
|
cell.appendChild(state);
|
||||||
|
row.appendChild(cell);
|
||||||
|
container.appendChild(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = document.createElement('div');
|
||||||
|
state.className = className;
|
||||||
|
state.textContent = message;
|
||||||
|
container.appendChild(state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNetworkError(message) {
|
||||||
|
const text = String(message || '').toLowerCase();
|
||||||
|
return text.includes('networkerror') || text.includes('failed to fetch') || text.includes('timeout');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSettingsError(message) {
|
||||||
|
const text = String(message || '').toLowerCase();
|
||||||
|
return text.includes('permission') || text.includes('denied') || text.includes('dependency') || text.includes('tool');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
toast,
|
||||||
|
reportError,
|
||||||
|
removeToast,
|
||||||
|
renderCollectionState,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.showAppToast = function(title, message, type) {
|
||||||
|
return AppFeedback.toast({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.reportActionableError = function(context, error, options) {
|
||||||
|
return AppFeedback.reportError(context, error, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.renderCollectionState = function(container, options) {
|
||||||
|
return AppFeedback.renderCollectionState(container, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
AppFeedback.init();
|
||||||
|
});
|
||||||
+103
-75
@@ -78,13 +78,14 @@ const Updater = {
|
|||||||
* Show update toast notification
|
* Show update toast notification
|
||||||
* @param {Object} data - Update data from server
|
* @param {Object} data - Update data from server
|
||||||
*/
|
*/
|
||||||
showUpdateToast(data) {
|
showUpdateToast(data) {
|
||||||
// Remove existing toast if present
|
// Remove existing toast if present
|
||||||
this.hideToast();
|
this.hideToast();
|
||||||
|
const latestVersion = this._escape(data.latest_version || '');
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = 'update-toast';
|
const toast = document.createElement('div');
|
||||||
toast.innerHTML = `
|
toast.className = 'update-toast';
|
||||||
|
toast.innerHTML = `
|
||||||
<div class="update-toast-indicator"></div>
|
<div class="update-toast-indicator"></div>
|
||||||
<div class="update-toast-content">
|
<div class="update-toast-content">
|
||||||
<div class="update-toast-header">
|
<div class="update-toast-header">
|
||||||
@@ -97,11 +98,11 @@ const Updater = {
|
|||||||
</span>
|
</span>
|
||||||
<span class="update-toast-title">Update Available</span>
|
<span class="update-toast-title">Update Available</span>
|
||||||
<button class="update-toast-close" onclick="Updater.dismissUpdate()">×</button>
|
<button class="update-toast-close" onclick="Updater.dismissUpdate()">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="update-toast-body">
|
<div class="update-toast-body">
|
||||||
Version <strong>${data.latest_version}</strong> is ready
|
Version <strong>${latestVersion}</strong> is ready
|
||||||
</div>
|
</div>
|
||||||
<div class="update-toast-actions">
|
<div class="update-toast-actions">
|
||||||
<button class="update-toast-btn update-toast-btn-primary" onclick="Updater.showUpdateModal()">
|
<button class="update-toast-btn update-toast-btn-primary" onclick="Updater.showUpdateModal()">
|
||||||
View Details
|
View Details
|
||||||
</button>
|
</button>
|
||||||
@@ -172,14 +173,17 @@ const Updater = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove existing modal if present
|
// Remove existing modal if present
|
||||||
this.hideModal();
|
this.hideModal();
|
||||||
|
|
||||||
const data = this._updateData;
|
const data = this._updateData;
|
||||||
const releaseNotes = this._formatReleaseNotes(data.release_notes || 'No release notes available.');
|
const releaseNotes = this._formatReleaseNotes(data.release_notes || 'No release notes available.');
|
||||||
|
const safeCurrentVersion = this._escape(data.current_version || '');
|
||||||
const modal = document.createElement('div');
|
const safeLatestVersion = this._escape(data.latest_version || '');
|
||||||
modal.className = 'update-modal-overlay';
|
const safeReleaseUrl = this._safeUrl(data.release_url || '');
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'update-modal-overlay';
|
||||||
modal.onclick = (e) => {
|
modal.onclick = (e) => {
|
||||||
if (e.target === modal) this.hideModal();
|
if (e.target === modal) this.hideModal();
|
||||||
};
|
};
|
||||||
@@ -201,21 +205,21 @@ const Updater = {
|
|||||||
</div>
|
</div>
|
||||||
<div class="update-modal-body">
|
<div class="update-modal-body">
|
||||||
<div class="update-version-info">
|
<div class="update-version-info">
|
||||||
<div class="update-version-current">
|
<div class="update-version-current">
|
||||||
<span class="update-version-label">Current</span>
|
<span class="update-version-label">Current</span>
|
||||||
<span class="update-version-value">v${data.current_version}</span>
|
<span class="update-version-value">v${safeCurrentVersion}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="update-version-arrow">
|
<div class="update-version-arrow">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
<polyline points="12 5 19 12 12 19"/>
|
<polyline points="12 5 19 12 12 19"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="update-version-latest">
|
<div class="update-version-latest">
|
||||||
<span class="update-version-label">Latest</span>
|
<span class="update-version-label">Latest</span>
|
||||||
<span class="update-version-value update-version-new">v${data.latest_version}</span>
|
<span class="update-version-value update-version-new">v${safeLatestVersion}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="update-section">
|
<div class="update-section">
|
||||||
<div class="update-section-title">Release Notes</div>
|
<div class="update-section-title">Release Notes</div>
|
||||||
@@ -249,11 +253,11 @@ const Updater = {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="update-result" id="updateResult" style="display: none;"></div>
|
<div class="update-result" id="updateResult" style="display: none;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="update-modal-footer">
|
<div class="update-modal-footer">
|
||||||
<a href="${data.release_url || '#'}" target="_blank" class="update-modal-link" ${!data.release_url ? 'style="display:none"' : ''}>
|
<a href="${safeReleaseUrl || '#'}" target="_blank" class="update-modal-link" ${!safeReleaseUrl ? 'style="display:none"' : ''}>
|
||||||
View on GitHub
|
View on GitHub
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
|
||||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||||
<polyline points="15 3 21 3 21 9"/>
|
<polyline points="15 3 21 3 21 9"/>
|
||||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||||
@@ -357,14 +361,16 @@ const Updater = {
|
|||||||
/**
|
/**
|
||||||
* Show update result
|
* Show update result
|
||||||
*/
|
*/
|
||||||
_showResult(resultEl, success, data, isManual = false) {
|
_showResult(resultEl, success, data, isManual = false) {
|
||||||
if (!resultEl) return;
|
if (!resultEl) return;
|
||||||
|
|
||||||
resultEl.style.display = 'block';
|
resultEl.style.display = 'block';
|
||||||
|
const safeMessage = this._escape(data.message || data.error || 'An error occurred during the update.');
|
||||||
if (success) {
|
const safeDetails = data.details ? this._escape(String(data.details).substring(0, 200)) : '';
|
||||||
if (data.updated) {
|
|
||||||
let message = '<strong>Update successful!</strong><br>Please restart the application to complete the update.';
|
if (success) {
|
||||||
|
if (data.updated) {
|
||||||
|
let message = '<strong>Update successful!</strong><br>Please restart the application to complete the update.';
|
||||||
|
|
||||||
if (data.requirements_changed) {
|
if (data.requirements_changed) {
|
||||||
message += '<br><br><strong>Dependencies changed!</strong> Run:<br><code>pip install -r requirements.txt</code>';
|
message += '<br><br><strong>Dependencies changed!</strong> Run:<br><code>pip install -r requirements.txt</code>';
|
||||||
@@ -380,22 +386,22 @@ const Updater = {
|
|||||||
</div>
|
</div>
|
||||||
<div class="update-result-text">${message}</div>
|
<div class="update-result-text">${message}</div>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
resultEl.className = 'update-result update-result-info';
|
resultEl.className = 'update-result update-result-info';
|
||||||
resultEl.innerHTML = `
|
resultEl.innerHTML = `
|
||||||
<div class="update-result-icon">
|
<div class="update-result-icon">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<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="10"/>
|
||||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="update-result-text">${data.message || 'Already up to date.'}</div>
|
<div class="update-result-text">${this._escape(data.message || 'Already up to date.')}</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (isManual) {
|
if (isManual) {
|
||||||
resultEl.className = 'update-result update-result-warning';
|
resultEl.className = 'update-result update-result-warning';
|
||||||
resultEl.innerHTML = `
|
resultEl.innerHTML = `
|
||||||
<div class="update-result-icon">
|
<div class="update-result-icon">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@@ -403,14 +409,14 @@ const Updater = {
|
|||||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="update-result-text">
|
<div class="update-result-text">
|
||||||
<strong>Manual update required</strong><br>
|
<strong>Manual update required</strong><br>
|
||||||
${data.message || 'Please download the latest release from GitHub.'}
|
${safeMessage || 'Please download the latest release from GitHub.'}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
resultEl.className = 'update-result update-result-error';
|
resultEl.className = 'update-result update-result-error';
|
||||||
resultEl.innerHTML = `
|
resultEl.innerHTML = `
|
||||||
<div class="update-result-icon">
|
<div class="update-result-icon">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@@ -418,16 +424,16 @@ const Updater = {
|
|||||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="update-result-text">
|
<div class="update-result-text">
|
||||||
<strong>Update failed</strong><br>
|
<strong>Update failed</strong><br>
|
||||||
${data.message || data.error || 'An error occurred during the update.'}
|
${safeMessage}
|
||||||
${data.details ? '<br><code style="font-size: 10px; margin-top: 8px; display: block;">' + data.details.substring(0, 200) + '</code>' : ''}
|
${safeDetails ? '<br><code style="font-size: 10px; margin-top: 8px; display: block;">' + safeDetails + '</code>' : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format release notes (basic markdown to HTML)
|
* Format release notes (basic markdown to HTML)
|
||||||
@@ -461,11 +467,33 @@ const Updater = {
|
|||||||
// Line breaks
|
// Line breaks
|
||||||
.replace(/\n/g, '<br>');
|
.replace(/\n/g, '<br>');
|
||||||
|
|
||||||
// Wrap list items
|
// Wrap list items
|
||||||
html = html.replace(/(<li>.*<\/li>)+/g, '<ul>$&</ul>');
|
html = html.replace(/(<li>.*<\/li>)+/g, '<ul>$&</ul>');
|
||||||
|
|
||||||
return '<p>' + html + '</p>';
|
return '<p>' + html + '</p>';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_escape(value) {
|
||||||
|
return String(value == null ? '' : value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
},
|
||||||
|
|
||||||
|
_safeUrl(url) {
|
||||||
|
if (!url) return '';
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url, window.location.origin);
|
||||||
|
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||||
|
return parsed.href;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manual trigger for settings panel
|
* Manual trigger for settings panel
|
||||||
|
|||||||
+549
-215
@@ -1,215 +1,549 @@
|
|||||||
/**
|
/**
|
||||||
* Analytics Dashboard Module
|
* Analytics Dashboard Module
|
||||||
* Cross-mode summary, sparklines, alerts, correlations, geofence management, export.
|
* Cross-mode summary, sparklines, alerts, correlations, target view, and replay.
|
||||||
*/
|
*/
|
||||||
const Analytics = (function () {
|
const Analytics = (function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
let refreshTimer = null;
|
let refreshTimer = null;
|
||||||
|
let replayTimer = null;
|
||||||
function init() {
|
let replaySessions = [];
|
||||||
refresh();
|
let replayEvents = [];
|
||||||
if (!refreshTimer) {
|
let replayIndex = 0;
|
||||||
refreshTimer = setInterval(refresh, 5000);
|
|
||||||
}
|
function init() {
|
||||||
}
|
refresh();
|
||||||
|
loadReplaySessions();
|
||||||
function destroy() {
|
if (!refreshTimer) {
|
||||||
if (refreshTimer) {
|
refreshTimer = setInterval(refresh, 5000);
|
||||||
clearInterval(refreshTimer);
|
}
|
||||||
refreshTimer = null;
|
}
|
||||||
}
|
|
||||||
}
|
function destroy() {
|
||||||
|
if (refreshTimer) {
|
||||||
function refresh() {
|
clearInterval(refreshTimer);
|
||||||
Promise.all([
|
refreshTimer = null;
|
||||||
fetch('/analytics/summary').then(r => r.json()).catch(() => null),
|
}
|
||||||
fetch('/analytics/activity').then(r => r.json()).catch(() => null),
|
pauseReplay();
|
||||||
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),
|
function refresh() {
|
||||||
]).then(([summary, activity, alerts, correlations, geofences]) => {
|
Promise.all([
|
||||||
if (summary) renderSummary(summary);
|
fetch('/analytics/summary').then(r => r.json()).catch(() => null),
|
||||||
if (activity) renderSparklines(activity.sparklines || {});
|
fetch('/analytics/activity').then(r => r.json()).catch(() => null),
|
||||||
if (alerts) renderAlerts(alerts.events || []);
|
fetch('/analytics/insights').then(r => r.json()).catch(() => null),
|
||||||
if (correlations) renderCorrelations(correlations);
|
fetch('/analytics/patterns').then(r => r.json()).catch(() => null),
|
||||||
if (geofences) renderGeofences(geofences.zones || []);
|
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]) => {
|
||||||
function renderSummary(data) {
|
if (summary) renderSummary(summary);
|
||||||
const counts = data.counts || {};
|
if (activity) renderSparklines(activity.sparklines || {});
|
||||||
_setText('analyticsCountAdsb', counts.adsb || 0);
|
if (insights) renderInsights(insights);
|
||||||
_setText('analyticsCountAis', counts.ais || 0);
|
if (patterns) renderPatterns(patterns.patterns || []);
|
||||||
_setText('analyticsCountWifi', counts.wifi || 0);
|
if (alerts) renderAlerts(alerts.events || []);
|
||||||
_setText('analyticsCountBt', counts.bluetooth || 0);
|
if (correlations) renderCorrelations(correlations);
|
||||||
_setText('analyticsCountDsc', counts.dsc || 0);
|
if (geofences) renderGeofences(geofences.zones || []);
|
||||||
_setText('analyticsCountAcars', counts.acars || 0);
|
});
|
||||||
_setText('analyticsCountVdl2', counts.vdl2 || 0);
|
}
|
||||||
_setText('analyticsCountAprs', counts.aprs || 0);
|
|
||||||
_setText('analyticsCountMesh', counts.meshtastic || 0);
|
function renderSummary(data) {
|
||||||
|
const counts = data.counts || {};
|
||||||
// Health
|
_setText('analyticsCountAdsb', counts.adsb || 0);
|
||||||
const health = data.health || {};
|
_setText('analyticsCountAis', counts.ais || 0);
|
||||||
const container = document.getElementById('analyticsHealth');
|
_setText('analyticsCountWifi', counts.wifi || 0);
|
||||||
if (container) {
|
_setText('analyticsCountBt', counts.bluetooth || 0);
|
||||||
let html = '';
|
_setText('analyticsCountDsc', counts.dsc || 0);
|
||||||
const modeLabels = {
|
_setText('analyticsCountAcars', counts.acars || 0);
|
||||||
pager: 'Pager', sensor: '433MHz', adsb: 'ADS-B', ais: 'AIS',
|
_setText('analyticsCountVdl2', counts.vdl2 || 0);
|
||||||
acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', wifi: 'WiFi',
|
_setText('analyticsCountAprs', counts.aprs || 0);
|
||||||
bluetooth: 'BT', dsc: 'DSC'
|
_setText('analyticsCountMesh', counts.meshtastic || 0);
|
||||||
};
|
|
||||||
for (const [mode, info] of Object.entries(health)) {
|
const health = data.health || {};
|
||||||
if (mode === 'sdr_devices') continue;
|
const container = document.getElementById('analyticsHealth');
|
||||||
const running = info && info.running;
|
if (container) {
|
||||||
const label = modeLabels[mode] || mode;
|
let html = '';
|
||||||
html += '<div class="health-item"><span class="health-dot' + (running ? ' running' : '') + '"></span>' + _esc(label) + '</div>';
|
const modeLabels = {
|
||||||
}
|
pager: 'Pager', sensor: '433MHz', adsb: 'ADS-B', ais: 'AIS',
|
||||||
container.innerHTML = html;
|
acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', wifi: 'WiFi',
|
||||||
}
|
bluetooth: 'BT', dsc: 'DSC', meshtastic: 'Mesh'
|
||||||
|
};
|
||||||
// Squawks
|
for (const [mode, info] of Object.entries(health)) {
|
||||||
const squawks = data.squawks || [];
|
if (mode === 'sdr_devices') continue;
|
||||||
const sqSection = document.getElementById('analyticsSquawkSection');
|
const running = info && info.running;
|
||||||
const sqList = document.getElementById('analyticsSquawkList');
|
const label = modeLabels[mode] || mode;
|
||||||
if (sqSection && sqList) {
|
html += '<div class="health-item"><span class="health-dot' + (running ? ' running' : '') + '"></span>' + _esc(label) + '</div>';
|
||||||
if (squawks.length > 0) {
|
}
|
||||||
sqSection.style.display = '';
|
container.innerHTML = html;
|
||||||
sqList.innerHTML = squawks.map(s =>
|
}
|
||||||
'<div class="squawk-item"><strong>' + _esc(s.squawk) + '</strong> ' +
|
|
||||||
_esc(s.meaning) + ' — ' + _esc(s.callsign || s.icao) + '</div>'
|
const squawks = data.squawks || [];
|
||||||
).join('');
|
const sqSection = document.getElementById('analyticsSquawkSection');
|
||||||
} else {
|
const sqList = document.getElementById('analyticsSquawkList');
|
||||||
sqSection.style.display = 'none';
|
if (sqSection && sqList) {
|
||||||
}
|
if (squawks.length > 0) {
|
||||||
}
|
sqSection.style.display = '';
|
||||||
}
|
sqList.innerHTML = squawks.map(s =>
|
||||||
|
'<div class="squawk-item"><strong>' + _esc(s.squawk) + '</strong> ' +
|
||||||
function renderSparklines(sparklines) {
|
_esc(s.meaning) + ' - ' + _esc(s.callsign || s.icao) + '</div>'
|
||||||
const map = {
|
).join('');
|
||||||
adsb: 'analyticsSparkAdsb',
|
} else {
|
||||||
ais: 'analyticsSparkAis',
|
sqSection.style.display = 'none';
|
||||||
wifi: 'analyticsSparkWifi',
|
}
|
||||||
bluetooth: 'analyticsSparkBt',
|
}
|
||||||
dsc: 'analyticsSparkDsc',
|
}
|
||||||
};
|
|
||||||
|
function renderSparklines(sparklines) {
|
||||||
for (const [mode, elId] of Object.entries(map)) {
|
const map = {
|
||||||
const el = document.getElementById(elId);
|
adsb: 'analyticsSparkAdsb',
|
||||||
if (!el) continue;
|
ais: 'analyticsSparkAis',
|
||||||
const data = sparklines[mode] || [];
|
wifi: 'analyticsSparkWifi',
|
||||||
if (data.length < 2) {
|
bluetooth: 'analyticsSparkBt',
|
||||||
el.innerHTML = '';
|
dsc: 'analyticsSparkDsc',
|
||||||
continue;
|
acars: 'analyticsSparkAcars',
|
||||||
}
|
vdl2: 'analyticsSparkVdl2',
|
||||||
const max = Math.max(...data, 1);
|
aprs: 'analyticsSparkAprs',
|
||||||
const w = 100;
|
meshtastic: 'analyticsSparkMesh',
|
||||||
const h = 24;
|
};
|
||||||
const step = w / (data.length - 1);
|
|
||||||
const points = data.map((v, i) =>
|
for (const [mode, elId] of Object.entries(map)) {
|
||||||
(i * step).toFixed(1) + ',' + (h - (v / max) * (h - 2)).toFixed(1)
|
const el = document.getElementById(elId);
|
||||||
).join(' ');
|
if (!el) continue;
|
||||||
el.innerHTML = '<svg viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none"><polyline points="' + points + '"/></svg>';
|
const data = sparklines[mode] || [];
|
||||||
}
|
if (data.length < 2) {
|
||||||
}
|
el.innerHTML = '';
|
||||||
|
continue;
|
||||||
function renderAlerts(events) {
|
}
|
||||||
const container = document.getElementById('analyticsAlertFeed');
|
const max = Math.max(...data, 1);
|
||||||
if (!container) return;
|
const w = 100;
|
||||||
if (!events || events.length === 0) {
|
const h = 24;
|
||||||
container.innerHTML = '<div class="analytics-empty">No recent alerts</div>';
|
const step = w / (data.length - 1);
|
||||||
return;
|
const points = data.map((v, i) =>
|
||||||
}
|
(i * step).toFixed(1) + ',' + (h - (v / max) * (h - 2)).toFixed(1)
|
||||||
container.innerHTML = events.slice(0, 20).map(e => {
|
).join(' ');
|
||||||
const sev = e.severity || 'medium';
|
el.innerHTML = '<svg viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none"><polyline points="' + points + '"/></svg>';
|
||||||
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>' +
|
function renderInsights(data) {
|
||||||
'<span>' + _esc(title) + '</span>' +
|
const cards = data.cards || [];
|
||||||
'<span style="margin-left:auto;color:var(--text-dim)">' + _esc(time) + '</span>' +
|
const topChanges = data.top_changes || [];
|
||||||
'</div>';
|
const cardsEl = document.getElementById('analyticsInsights');
|
||||||
}).join('');
|
const changesEl = document.getElementById('analyticsTopChanges');
|
||||||
}
|
|
||||||
|
if (cardsEl) {
|
||||||
function renderCorrelations(data) {
|
if (!cards.length) {
|
||||||
const container = document.getElementById('analyticsCorrelations');
|
cardsEl.innerHTML = '<div class="analytics-empty">No insight data available</div>';
|
||||||
if (!container) return;
|
} else {
|
||||||
const pairs = (data && data.correlations) || [];
|
cardsEl.innerHTML = cards.map(c => {
|
||||||
if (pairs.length === 0) {
|
const sev = _esc(c.severity || 'low');
|
||||||
container.innerHTML = '<div class="analytics-empty">No correlations detected</div>';
|
const title = _esc(c.title || 'Insight');
|
||||||
return;
|
const value = _esc(c.value || '--');
|
||||||
}
|
const label = _esc(c.label || '');
|
||||||
container.innerHTML = pairs.slice(0, 20).map(p => {
|
const detail = _esc(c.detail || '');
|
||||||
const conf = Math.round((p.confidence || 0) * 100);
|
return '<div class="analytics-insight-card ' + sev + '">' +
|
||||||
return '<div class="analytics-correlation-pair">' +
|
'<div class="insight-title">' + title + '</div>' +
|
||||||
'<span>' + _esc(p.wifi_mac || '') + '</span>' +
|
'<div class="insight-value">' + value + '</div>' +
|
||||||
'<span style="color:var(--text-dim)">↔</span>' +
|
'<div class="insight-label">' + label + '</div>' +
|
||||||
'<span>' + _esc(p.bt_mac || '') + '</span>' +
|
'<div class="insight-detail">' + detail + '</div>' +
|
||||||
'<div class="confidence-bar"><div class="confidence-fill" style="width:' + conf + '%"></div></div>' +
|
'</div>';
|
||||||
'<span style="color:var(--text-dim)">' + conf + '%</span>' +
|
}).join('');
|
||||||
'</div>';
|
}
|
||||||
}).join('');
|
}
|
||||||
}
|
|
||||||
|
if (changesEl) {
|
||||||
function renderGeofences(zones) {
|
if (!topChanges.length) {
|
||||||
const container = document.getElementById('analyticsGeofenceList');
|
changesEl.innerHTML = '<div class="analytics-empty">No change signals yet</div>';
|
||||||
if (!container) return;
|
} else {
|
||||||
if (!zones || zones.length === 0) {
|
changesEl.innerHTML = topChanges.map(item => {
|
||||||
container.innerHTML = '<div class="analytics-empty">No geofence zones defined</div>';
|
const mode = _esc(item.mode_label || item.mode || '');
|
||||||
return;
|
const deltaRaw = Number(item.delta || 0);
|
||||||
}
|
const trendClass = deltaRaw > 0 ? 'up' : (deltaRaw < 0 ? 'down' : 'flat');
|
||||||
container.innerHTML = zones.map(z =>
|
const delta = _esc(item.signed_delta || String(deltaRaw));
|
||||||
'<div class="geofence-zone-item">' +
|
const recentAvg = _esc(item.recent_avg);
|
||||||
'<span class="zone-name">' + _esc(z.name) + '</span>' +
|
const prevAvg = _esc(item.previous_avg);
|
||||||
'<span class="zone-radius">' + z.radius_m + 'm</span>' +
|
return '<div class="analytics-change-row">' +
|
||||||
'<button class="zone-delete" onclick="Analytics.deleteGeofence(' + z.id + ')">DEL</button>' +
|
'<span class="mode">' + mode + '</span>' +
|
||||||
'</div>'
|
'<span class="delta ' + trendClass + '">' + delta + '</span>' +
|
||||||
).join('');
|
'<span class="avg">avg ' + recentAvg + ' vs ' + prevAvg + '</span>' +
|
||||||
}
|
'</div>';
|
||||||
|
}).join('');
|
||||||
function addGeofence() {
|
}
|
||||||
const name = prompt('Zone name:');
|
}
|
||||||
if (!name) return;
|
}
|
||||||
const lat = parseFloat(prompt('Latitude:', '0'));
|
|
||||||
const lon = parseFloat(prompt('Longitude:', '0'));
|
function renderPatterns(patterns) {
|
||||||
const radius = parseFloat(prompt('Radius (meters):', '1000'));
|
const container = document.getElementById('analyticsPatternList');
|
||||||
if (isNaN(lat) || isNaN(lon) || isNaN(radius)) {
|
if (!container) return;
|
||||||
alert('Invalid input');
|
if (!patterns || patterns.length === 0) {
|
||||||
return;
|
container.innerHTML = '<div class="analytics-empty">No recurring patterns detected</div>';
|
||||||
}
|
return;
|
||||||
fetch('/analytics/geofences', {
|
}
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const modeLabels = {
|
||||||
body: JSON.stringify({ name, lat, lon, radius_m: radius }),
|
adsb: 'ADS-B', ais: 'AIS', wifi: 'WiFi', bluetooth: 'Bluetooth',
|
||||||
})
|
dsc: 'DSC', acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', meshtastic: 'Meshtastic',
|
||||||
.then(r => r.json())
|
};
|
||||||
.then(() => refresh());
|
|
||||||
}
|
const sorted = patterns
|
||||||
|
.slice()
|
||||||
function deleteGeofence(id) {
|
.sort((a, b) => (b.confidence || 0) - (a.confidence || 0))
|
||||||
if (!confirm('Delete this geofence zone?')) return;
|
.slice(0, 20);
|
||||||
fetch('/analytics/geofences/' + id, { method: 'DELETE' })
|
|
||||||
.then(r => r.json())
|
container.innerHTML = sorted.map(p => {
|
||||||
.then(() => refresh());
|
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));
|
||||||
function exportData(mode) {
|
const occurrences = Number(p.occurrences || 0);
|
||||||
const m = mode || (document.getElementById('exportMode') || {}).value || 'adsb';
|
const deviceId = _shortId(p.device_id || '--');
|
||||||
const f = (document.getElementById('exportFormat') || {}).value || 'json';
|
return '<div class="analytics-pattern-item">' +
|
||||||
window.open('/analytics/export/' + encodeURIComponent(m) + '?format=' + encodeURIComponent(f), '_blank');
|
'<div class="pattern-main">' +
|
||||||
}
|
'<span class="pattern-mode">' + _esc(mode) + '</span>' +
|
||||||
|
'<span class="pattern-device">' + _esc(deviceId) + '</span>' +
|
||||||
// Helpers
|
'</div>' +
|
||||||
function _setText(id, val) {
|
'<div class="pattern-meta">' +
|
||||||
const el = document.getElementById(id);
|
'<span>Period: ' + _esc(period) + '</span>' +
|
||||||
if (el) el.textContent = val;
|
'<span>Hits: ' + _esc(occurrences) + '</span>' +
|
||||||
}
|
'<span class="pattern-confidence">' + _esc(confidencePct) + '%</span>' +
|
||||||
|
'</div>' +
|
||||||
function _esc(s) {
|
'</div>';
|
||||||
if (typeof s !== 'string') s = String(s == null ? '' : s);
|
}).join('');
|
||||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
}
|
||||||
}
|
|
||||||
|
function renderAlerts(events) {
|
||||||
return { init, destroy, refresh, addGeofence, deleteGeofence, exportData };
|
const container = document.getElementById('analyticsAlertFeed');
|
||||||
})();
|
if (!container) return;
|
||||||
|
if (!events || events.length === 0) {
|
||||||
|
container.innerHTML = '<div class="analytics-empty">No recent alerts</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = events.slice(0, 20).map(e => {
|
||||||
|
const sev = e.severity || 'medium';
|
||||||
|
const title = e.title || e.event_type || 'Alert';
|
||||||
|
const time = e.created_at ? new Date(e.created_at).toLocaleTimeString() : '';
|
||||||
|
return '<div class="analytics-alert-item">' +
|
||||||
|
'<span class="alert-severity ' + _esc(sev) + '">' + _esc(sev) + '</span>' +
|
||||||
|
'<span>' + _esc(title) + '</span>' +
|
||||||
|
'<span style="margin-left:auto;color:var(--text-dim)">' + _esc(time) + '</span>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCorrelations(data) {
|
||||||
|
const container = document.getElementById('analyticsCorrelations');
|
||||||
|
if (!container) return;
|
||||||
|
const pairs = (data && data.correlations) || [];
|
||||||
|
if (pairs.length === 0) {
|
||||||
|
container.innerHTML = '<div class="analytics-empty">No correlations detected</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = pairs.slice(0, 20).map(p => {
|
||||||
|
const conf = Math.round((p.confidence || 0) * 100);
|
||||||
|
return '<div class="analytics-correlation-pair">' +
|
||||||
|
'<span>' + _esc(p.wifi_mac || '') + '</span>' +
|
||||||
|
'<span style="color:var(--text-dim)">↔</span>' +
|
||||||
|
'<span>' + _esc(p.bt_mac || '') + '</span>' +
|
||||||
|
'<div class="confidence-bar"><div class="confidence-fill" style="width:' + conf + '%"></div></div>' +
|
||||||
|
'<span style="color:var(--text-dim)">' + conf + '%</span>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGeofences(zones) {
|
||||||
|
const container = document.getElementById('analyticsGeofenceList');
|
||||||
|
if (!container) return;
|
||||||
|
if (!zones || zones.length === 0) {
|
||||||
|
container.innerHTML = '<div class="analytics-empty">No geofence zones defined</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = zones.map(z =>
|
||||||
|
'<div class="geofence-zone-item">' +
|
||||||
|
'<span class="zone-name">' + _esc(z.name) + '</span>' +
|
||||||
|
'<span class="zone-radius">' + z.radius_m + 'm</span>' +
|
||||||
|
'<button class="zone-delete" onclick="Analytics.deleteGeofence(' + z.id + ')">DEL</button>' +
|
||||||
|
'</div>'
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGeofence() {
|
||||||
|
const name = prompt('Zone name:');
|
||||||
|
if (!name) return;
|
||||||
|
const lat = parseFloat(prompt('Latitude:', '0'));
|
||||||
|
const lon = parseFloat(prompt('Longitude:', '0'));
|
||||||
|
const radius = parseFloat(prompt('Radius (meters):', '1000'));
|
||||||
|
if (isNaN(lat) || isNaN(lon) || isNaN(radius)) {
|
||||||
|
alert('Invalid input');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch('/analytics/geofences', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, lat, lon, radius_m: radius }),
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteGeofence(id) {
|
||||||
|
if (!confirm('Delete this geofence zone?')) return;
|
||||||
|
fetch('/analytics/geofences/' + id, { method: 'DELETE' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportData(mode) {
|
||||||
|
const m = mode || (document.getElementById('exportMode') || {}).value || 'adsb';
|
||||||
|
const f = (document.getElementById('exportFormat') || {}).value || 'json';
|
||||||
|
window.open('/analytics/export/' + encodeURIComponent(m) + '?format=' + encodeURIComponent(f), '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchTarget() {
|
||||||
|
const input = document.getElementById('analyticsTargetQuery');
|
||||||
|
const summaryEl = document.getElementById('analyticsTargetSummary');
|
||||||
|
const q = (input && input.value || '').trim();
|
||||||
|
if (!q) {
|
||||||
|
if (summaryEl) summaryEl.textContent = 'Enter a search value to correlate entities';
|
||||||
|
renderTargetResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/analytics/target?q=' + encodeURIComponent(q) + '&limit=120')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
const results = data.results || [];
|
||||||
|
if (summaryEl) {
|
||||||
|
const modeCounts = data.mode_counts || {};
|
||||||
|
const bits = Object.entries(modeCounts).map(([mode, count]) => `${mode}: ${count}`).join(' | ');
|
||||||
|
summaryEl.textContent = `${results.length} results${bits ? ' | ' + bits : ''}`;
|
||||||
|
}
|
||||||
|
renderTargetResults(results);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (summaryEl) summaryEl.textContent = 'Search failed';
|
||||||
|
if (typeof reportActionableError === 'function') {
|
||||||
|
reportActionableError('Target View Search', err, { onRetry: searchTarget });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTargetResults(results) {
|
||||||
|
const container = document.getElementById('analyticsTargetResults');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (!results || !results.length) {
|
||||||
|
container.innerHTML = '<div class="analytics-empty">No matching entities</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = results.map((item) => {
|
||||||
|
const title = _esc(item.title || item.id || 'Entity');
|
||||||
|
const subtitle = _esc(item.subtitle || '');
|
||||||
|
const mode = _esc(item.mode || 'unknown');
|
||||||
|
const confidence = item.confidence != null ? `Confidence ${_esc(Math.round(Number(item.confidence) * 100))}%` : '';
|
||||||
|
const lastSeen = _esc(item.last_seen || '');
|
||||||
|
return '<div class="analytics-target-item">' +
|
||||||
|
'<div class="title"><span class="mode">' + mode + '</span><span>' + title + '</span></div>' +
|
||||||
|
'<div class="meta"><span>' + subtitle + '</span>' +
|
||||||
|
(lastSeen ? '<span>Last seen ' + lastSeen + '</span>' : '') +
|
||||||
|
(confidence ? '<span>' + confidence + '</span>' : '') +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadReplaySessions() {
|
||||||
|
const select = document.getElementById('analyticsReplaySelect');
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
fetch('/recordings?limit=60')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
replaySessions = (data.recordings || []).filter((rec) => Number(rec.event_count || 0) > 0);
|
||||||
|
|
||||||
|
if (!replaySessions.length) {
|
||||||
|
select.innerHTML = '<option value="">No recordings</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
select.innerHTML = replaySessions.map((rec) => {
|
||||||
|
const label = `${rec.mode} | ${(rec.label || 'session')} | ${new Date(rec.started_at).toLocaleString()}`;
|
||||||
|
return `<option value="${_esc(rec.id)}">${_esc(label)}</option>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const pendingReplay = localStorage.getItem('analyticsReplaySession');
|
||||||
|
if (pendingReplay && replaySessions.some((rec) => rec.id === pendingReplay)) {
|
||||||
|
select.value = pendingReplay;
|
||||||
|
localStorage.removeItem('analyticsReplaySession');
|
||||||
|
loadReplay();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (typeof reportActionableError === 'function') {
|
||||||
|
reportActionableError('Load Replay Sessions', err, { onRetry: loadReplaySessions });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadReplay() {
|
||||||
|
pauseReplay();
|
||||||
|
replayEvents = [];
|
||||||
|
replayIndex = 0;
|
||||||
|
|
||||||
|
const select = document.getElementById('analyticsReplaySelect');
|
||||||
|
const meta = document.getElementById('analyticsReplayMeta');
|
||||||
|
const timeline = document.getElementById('analyticsReplayTimeline');
|
||||||
|
if (!select || !meta || !timeline) return;
|
||||||
|
|
||||||
|
const id = select.value;
|
||||||
|
if (!id) {
|
||||||
|
meta.textContent = 'Select a recording';
|
||||||
|
timeline.innerHTML = '<div class="analytics-empty">No recording selected</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
meta.textContent = 'Loading replay events...';
|
||||||
|
|
||||||
|
fetch('/recordings/' + encodeURIComponent(id) + '/events?limit=600')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
replayEvents = data.events || [];
|
||||||
|
replayIndex = 0;
|
||||||
|
if (!replayEvents.length) {
|
||||||
|
meta.textContent = 'No events found in selected recording';
|
||||||
|
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rec = replaySessions.find((s) => s.id === id);
|
||||||
|
const mode = rec ? rec.mode : (data.recording && data.recording.mode) || 'unknown';
|
||||||
|
meta.textContent = `${replayEvents.length} events loaded | mode ${mode}`;
|
||||||
|
renderReplayWindow();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
meta.textContent = 'Replay load failed';
|
||||||
|
if (typeof reportActionableError === 'function') {
|
||||||
|
reportActionableError('Load Replay', err, { onRetry: loadReplay });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function playReplay() {
|
||||||
|
if (!replayEvents.length) {
|
||||||
|
loadReplay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replayTimer) return;
|
||||||
|
|
||||||
|
replayTimer = setInterval(() => {
|
||||||
|
if (replayIndex >= replayEvents.length - 1) {
|
||||||
|
pauseReplay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
replayIndex += 1;
|
||||||
|
renderReplayWindow();
|
||||||
|
}, 260);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pauseReplay() {
|
||||||
|
if (replayTimer) {
|
||||||
|
clearInterval(replayTimer);
|
||||||
|
replayTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepReplay() {
|
||||||
|
if (!replayEvents.length) {
|
||||||
|
loadReplay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pauseReplay();
|
||||||
|
replayIndex = Math.min(replayIndex + 1, replayEvents.length - 1);
|
||||||
|
renderReplayWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReplayWindow() {
|
||||||
|
const timeline = document.getElementById('analyticsReplayTimeline');
|
||||||
|
const meta = document.getElementById('analyticsReplayMeta');
|
||||||
|
if (!timeline || !meta) return;
|
||||||
|
|
||||||
|
const total = replayEvents.length;
|
||||||
|
if (!total) {
|
||||||
|
timeline.innerHTML = '<div class="analytics-empty">No events to replay</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Math.max(0, replayIndex - 15);
|
||||||
|
const end = Math.min(total, replayIndex + 20);
|
||||||
|
const windowed = replayEvents.slice(start, end);
|
||||||
|
|
||||||
|
timeline.innerHTML = windowed.map((row, i) => {
|
||||||
|
const absolute = start + i;
|
||||||
|
const active = absolute === replayIndex;
|
||||||
|
const eventType = _esc(row.event_type || 'event');
|
||||||
|
const mode = _esc(row.mode || '--');
|
||||||
|
const ts = _esc(row.timestamp ? new Date(row.timestamp).toLocaleTimeString() : '--');
|
||||||
|
const detail = summarizeReplayEvent(row.event || {});
|
||||||
|
return '<div class="analytics-replay-item" style="opacity:' + (active ? '1' : '0.65') + ';">' +
|
||||||
|
'<div class="title"><span class="mode">' + mode + '</span><span>' + eventType + '</span></div>' +
|
||||||
|
'<div class="meta"><span>' + ts + '</span><span>' + _esc(detail) + '</span></div>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
meta.textContent = `Event ${replayIndex + 1}/${total}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeReplayEvent(event) {
|
||||||
|
if (!event || typeof event !== 'object') return 'No details';
|
||||||
|
if (event.callsign) return `Callsign ${event.callsign}`;
|
||||||
|
if (event.icao) return `ICAO ${event.icao}`;
|
||||||
|
if (event.ssid) return `SSID ${event.ssid}`;
|
||||||
|
if (event.bssid) return `BSSID ${event.bssid}`;
|
||||||
|
if (event.address) return `Address ${event.address}`;
|
||||||
|
if (event.name) return `Name ${event.name}`;
|
||||||
|
const keys = Object.keys(event);
|
||||||
|
if (!keys.length) return 'No fields';
|
||||||
|
return `${keys[0]}=${String(event[keys[0]]).slice(0, 40)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setText(id, val) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _esc(s) {
|
||||||
|
if (typeof s !== 'string') s = String(s == null ? '' : s);
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _shortId(value) {
|
||||||
|
const text = String(value || '');
|
||||||
|
if (text.length <= 18) return text;
|
||||||
|
return text.slice(0, 8) + '...' + text.slice(-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _humanPeriod(seconds) {
|
||||||
|
if (!isFinite(seconds) || seconds <= 0) return '--';
|
||||||
|
if (seconds < 60) return Math.round(seconds) + 's';
|
||||||
|
const mins = seconds / 60;
|
||||||
|
if (mins < 60) return mins.toFixed(mins < 10 ? 1 : 0) + 'm';
|
||||||
|
const hours = mins / 60;
|
||||||
|
return hours.toFixed(hours < 10 ? 1 : 0) + 'h';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
destroy,
|
||||||
|
refresh,
|
||||||
|
addGeofence,
|
||||||
|
deleteGeofence,
|
||||||
|
exportData,
|
||||||
|
searchTarget,
|
||||||
|
loadReplay,
|
||||||
|
playReplay,
|
||||||
|
pauseReplay,
|
||||||
|
stepReplay,
|
||||||
|
loadReplaySessions,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|||||||
+422
-289
@@ -27,15 +27,22 @@ const BluetoothMode = (function() {
|
|||||||
trackers: []
|
trackers: []
|
||||||
};
|
};
|
||||||
|
|
||||||
// Zone counts for proximity display
|
// Zone counts for proximity display
|
||||||
let zoneCounts = { veryClose: 0, close: 0, nearby: 0, far: 0 };
|
let zoneCounts = { immediate: 0, near: 0, far: 0 };
|
||||||
|
|
||||||
// New visualization components
|
// New visualization components
|
||||||
let radarInitialized = false;
|
let radarInitialized = false;
|
||||||
let radarPaused = false;
|
let radarPaused = false;
|
||||||
|
|
||||||
// Device list filter
|
// Device list filter
|
||||||
let currentDeviceFilter = 'all';
|
let currentDeviceFilter = 'all';
|
||||||
|
let currentSearchTerm = '';
|
||||||
|
let visibleDeviceCount = 0;
|
||||||
|
let pendingDeviceFlush = false;
|
||||||
|
let selectedDeviceNeedsRefresh = false;
|
||||||
|
let filterListenersBound = false;
|
||||||
|
let listListenersBound = false;
|
||||||
|
const pendingDeviceIds = new Set();
|
||||||
|
|
||||||
// Agent support
|
// Agent support
|
||||||
let showAllAgentsMode = false;
|
let showAllAgentsMode = false;
|
||||||
@@ -109,8 +116,9 @@ const BluetoothMode = (function() {
|
|||||||
// Initialize legacy heatmap (zone counts)
|
// Initialize legacy heatmap (zone counts)
|
||||||
initHeatmap();
|
initHeatmap();
|
||||||
|
|
||||||
// Initialize device list filters
|
// Initialize device list filters
|
||||||
initDeviceFilters();
|
initDeviceFilters();
|
||||||
|
initListInteractions();
|
||||||
|
|
||||||
// Set initial panel states
|
// Set initial panel states
|
||||||
updateVisualizationPanels();
|
updateVisualizationPanels();
|
||||||
@@ -119,80 +127,133 @@ const BluetoothMode = (function() {
|
|||||||
/**
|
/**
|
||||||
* Initialize device list filter buttons
|
* Initialize device list filter buttons
|
||||||
*/
|
*/
|
||||||
function initDeviceFilters() {
|
function initDeviceFilters() {
|
||||||
const filterContainer = document.getElementById('btDeviceFilters');
|
if (filterListenersBound) return;
|
||||||
if (!filterContainer) return;
|
const filterContainer = document.getElementById('btDeviceFilters');
|
||||||
|
if (filterContainer) {
|
||||||
filterContainer.addEventListener('click', (e) => {
|
filterContainer.addEventListener('click', (e) => {
|
||||||
const btn = e.target.closest('.bt-filter-btn');
|
const btn = e.target.closest('.bt-filter-btn');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
|
|
||||||
const filter = btn.dataset.filter;
|
const filter = btn.dataset.filter;
|
||||||
if (!filter) return;
|
if (!filter) return;
|
||||||
|
|
||||||
// Update active state
|
// Update active state
|
||||||
filterContainer.querySelectorAll('.bt-filter-btn').forEach(b => b.classList.remove('active'));
|
filterContainer.querySelectorAll('.bt-filter-btn').forEach(b => b.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
|
|
||||||
// Apply filter
|
// Apply filter
|
||||||
currentDeviceFilter = filter;
|
currentDeviceFilter = filter;
|
||||||
applyDeviceFilter();
|
applyDeviceFilter();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('btDeviceSearch');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('input', () => {
|
||||||
|
currentSearchTerm = searchInput.value.trim().toLowerCase();
|
||||||
|
applyDeviceFilter();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
filterListenersBound = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initListInteractions() {
|
||||||
|
if (listListenersBound) return;
|
||||||
|
if (deviceContainer) {
|
||||||
|
deviceContainer.addEventListener('click', (event) => {
|
||||||
|
const locateBtn = event.target.closest('.bt-locate-btn[data-locate-id]');
|
||||||
|
if (locateBtn) {
|
||||||
|
event.preventDefault();
|
||||||
|
locateById(locateBtn.dataset.locateId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = event.target.closest('.bt-device-row[data-bt-device-id]');
|
||||||
|
if (!row) return;
|
||||||
|
selectDevice(row.dataset.btDeviceId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackerList = document.getElementById('btTrackerList');
|
||||||
|
if (trackerList) {
|
||||||
|
trackerList.addEventListener('click', (event) => {
|
||||||
|
const row = event.target.closest('.bt-tracker-item[data-device-id]');
|
||||||
|
if (!row) return;
|
||||||
|
selectDevice(row.dataset.deviceId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
listListenersBound = true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply current filter to device list
|
* Apply current filter to device list
|
||||||
*/
|
*/
|
||||||
function applyDeviceFilter() {
|
function applyDeviceFilter() {
|
||||||
if (!deviceContainer) return;
|
if (!deviceContainer) return;
|
||||||
|
|
||||||
const cards = deviceContainer.querySelectorAll('[data-bt-device-id]');
|
const cards = deviceContainer.querySelectorAll('[data-bt-device-id]');
|
||||||
cards.forEach(card => {
|
let visibleCount = 0;
|
||||||
const isNew = card.dataset.isNew === 'true';
|
cards.forEach(card => {
|
||||||
const hasName = card.dataset.hasName === 'true';
|
const isNew = card.dataset.isNew === 'true';
|
||||||
const rssi = parseInt(card.dataset.rssi) || -100;
|
const hasName = card.dataset.hasName === 'true';
|
||||||
const isTracker = card.dataset.isTracker === 'true';
|
const rssi = parseInt(card.dataset.rssi) || -100;
|
||||||
|
const isTracker = card.dataset.isTracker === 'true';
|
||||||
let visible = true;
|
const searchHaystack = (card.dataset.search || '').toLowerCase();
|
||||||
switch (currentDeviceFilter) {
|
|
||||||
case 'new':
|
let matchesFilter = true;
|
||||||
visible = isNew;
|
switch (currentDeviceFilter) {
|
||||||
break;
|
case 'new':
|
||||||
case 'named':
|
matchesFilter = isNew;
|
||||||
visible = hasName;
|
break;
|
||||||
break;
|
case 'named':
|
||||||
case 'strong':
|
matchesFilter = hasName;
|
||||||
visible = rssi >= -70;
|
break;
|
||||||
break;
|
case 'strong':
|
||||||
case 'trackers':
|
matchesFilter = rssi >= -70;
|
||||||
visible = isTracker;
|
break;
|
||||||
break;
|
case 'trackers':
|
||||||
case 'all':
|
matchesFilter = isTracker;
|
||||||
default:
|
break;
|
||||||
visible = true;
|
case 'all':
|
||||||
}
|
default:
|
||||||
|
matchesFilter = true;
|
||||||
card.style.display = visible ? '' : 'none';
|
}
|
||||||
});
|
|
||||||
|
const matchesSearch = !currentSearchTerm || searchHaystack.includes(currentSearchTerm);
|
||||||
// Update visible count
|
const visible = matchesFilter && matchesSearch;
|
||||||
updateFilteredCount();
|
card.style.display = visible ? '' : 'none';
|
||||||
}
|
if (visible) visibleCount++;
|
||||||
|
});
|
||||||
|
|
||||||
|
visibleDeviceCount = visibleCount;
|
||||||
|
|
||||||
|
let stateEl = deviceContainer.querySelector('.bt-device-filter-state');
|
||||||
|
if (visibleCount === 0 && devices.size > 0) {
|
||||||
|
if (!stateEl) {
|
||||||
|
stateEl = document.createElement('div');
|
||||||
|
stateEl.className = 'bt-device-filter-state app-collection-state is-empty';
|
||||||
|
deviceContainer.appendChild(stateEl);
|
||||||
|
}
|
||||||
|
stateEl.textContent = 'No devices match current filters';
|
||||||
|
} else if (stateEl) {
|
||||||
|
stateEl.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update visible count
|
||||||
|
updateFilteredCount();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the device count display based on visible devices
|
* Update the device count display based on visible devices
|
||||||
*/
|
*/
|
||||||
function updateFilteredCount() {
|
function updateFilteredCount() {
|
||||||
const countEl = document.getElementById('btDeviceListCount');
|
const countEl = document.getElementById('btDeviceListCount');
|
||||||
if (!countEl || !deviceContainer) return;
|
if (!countEl || !deviceContainer) return;
|
||||||
|
|
||||||
if (currentDeviceFilter === 'all') {
|
const hasFilter = currentDeviceFilter !== 'all' || currentSearchTerm.length > 0;
|
||||||
countEl.textContent = devices.size;
|
countEl.textContent = hasFilter ? `${visibleDeviceCount}/${devices.size}` : devices.size;
|
||||||
} else {
|
}
|
||||||
const visible = deviceContainer.querySelectorAll('[data-bt-device-id]:not([style*="display: none"])').length;
|
|
||||||
countEl.textContent = visible + '/' + devices.size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the new proximity radar component
|
* Initialize the new proximity radar component
|
||||||
@@ -308,30 +369,20 @@ const BluetoothMode = (function() {
|
|||||||
/**
|
/**
|
||||||
* Update proximity zone counts (simple HTML, no canvas)
|
* Update proximity zone counts (simple HTML, no canvas)
|
||||||
*/
|
*/
|
||||||
function updateProximityZones() {
|
function updateProximityZones() {
|
||||||
zoneCounts = { veryClose: 0, close: 0, nearby: 0, far: 0 };
|
zoneCounts = { immediate: 0, near: 0, far: 0 };
|
||||||
|
|
||||||
devices.forEach(device => {
|
devices.forEach(device => {
|
||||||
const rssi = device.rssi_current;
|
const rssi = device.rssi_current;
|
||||||
if (rssi == null) return;
|
if (rssi == null) return;
|
||||||
|
|
||||||
if (rssi >= -40) zoneCounts.veryClose++;
|
if (rssi >= -50) zoneCounts.immediate++;
|
||||||
else if (rssi >= -55) zoneCounts.close++;
|
else if (rssi >= -70) zoneCounts.near++;
|
||||||
else if (rssi >= -70) zoneCounts.nearby++;
|
else zoneCounts.far++;
|
||||||
else zoneCounts.far++;
|
});
|
||||||
});
|
|
||||||
|
updateProximityZoneCounts(zoneCounts);
|
||||||
// Update DOM elements
|
}
|
||||||
const veryCloseEl = document.getElementById('btZoneVeryClose');
|
|
||||||
const closeEl = document.getElementById('btZoneClose');
|
|
||||||
const nearbyEl = document.getElementById('btZoneNearby');
|
|
||||||
const farEl = document.getElementById('btZoneFar');
|
|
||||||
|
|
||||||
if (veryCloseEl) veryCloseEl.textContent = zoneCounts.veryClose;
|
|
||||||
if (closeEl) closeEl.textContent = zoneCounts.close;
|
|
||||||
if (nearbyEl) nearbyEl.textContent = zoneCounts.nearby;
|
|
||||||
if (farEl) farEl.textContent = zoneCounts.far;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Currently selected device
|
// Currently selected device
|
||||||
let selectedDeviceId = null;
|
let selectedDeviceId = null;
|
||||||
@@ -912,14 +963,25 @@ const BluetoothMode = (function() {
|
|||||||
function setScanning(scanning) {
|
function setScanning(scanning) {
|
||||||
isScanning = scanning;
|
isScanning = scanning;
|
||||||
|
|
||||||
if (startBtn) startBtn.style.display = scanning ? 'none' : 'block';
|
if (startBtn) startBtn.style.display = scanning ? 'none' : 'block';
|
||||||
if (stopBtn) stopBtn.style.display = scanning ? 'block' : 'none';
|
if (stopBtn) stopBtn.style.display = scanning ? 'block' : 'none';
|
||||||
|
|
||||||
if (scanning && deviceContainer) {
|
if (scanning && deviceContainer) {
|
||||||
deviceContainer.innerHTML = '';
|
pendingDeviceIds.clear();
|
||||||
devices.clear();
|
selectedDeviceNeedsRefresh = false;
|
||||||
resetStats();
|
pendingDeviceFlush = false;
|
||||||
}
|
if (typeof renderCollectionState === 'function') {
|
||||||
|
renderCollectionState(deviceContainer, { type: 'loading', message: 'Scanning for Bluetooth devices...' });
|
||||||
|
} else {
|
||||||
|
deviceContainer.innerHTML = '';
|
||||||
|
}
|
||||||
|
devices.clear();
|
||||||
|
resetStats();
|
||||||
|
} else if (!scanning && deviceContainer && devices.size === 0) {
|
||||||
|
if (typeof renderCollectionState === 'function') {
|
||||||
|
renderCollectionState(deviceContainer, { type: 'empty', message: 'Start scanning to discover Bluetooth devices' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const statusDot = document.getElementById('statusDot');
|
const statusDot = document.getElementById('statusDot');
|
||||||
const statusText = document.getElementById('statusText');
|
const statusText = document.getElementById('statusText');
|
||||||
@@ -927,20 +989,22 @@ const BluetoothMode = (function() {
|
|||||||
if (statusText) statusText.textContent = scanning ? 'Scanning...' : 'Idle';
|
if (statusText) statusText.textContent = scanning ? 'Scanning...' : 'Idle';
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetStats() {
|
function resetStats() {
|
||||||
deviceStats = {
|
deviceStats = {
|
||||||
strong: 0,
|
strong: 0,
|
||||||
medium: 0,
|
medium: 0,
|
||||||
weak: 0,
|
weak: 0,
|
||||||
trackers: []
|
trackers: []
|
||||||
};
|
};
|
||||||
updateVisualizationPanels();
|
visibleDeviceCount = 0;
|
||||||
updateProximityZones();
|
updateVisualizationPanels();
|
||||||
|
updateProximityZones();
|
||||||
// Clear radar
|
updateFilteredCount();
|
||||||
if (radarInitialized && typeof ProximityRadar !== 'undefined') {
|
|
||||||
ProximityRadar.clear();
|
// Clear radar
|
||||||
}
|
if (radarInitialized && typeof ProximityRadar !== 'undefined') {
|
||||||
|
ProximityRadar.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEventStream() {
|
function startEventStream() {
|
||||||
@@ -1082,17 +1146,43 @@ const BluetoothMode = (function() {
|
|||||||
}, pollInterval);
|
}, pollInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDeviceUpdate(device) {
|
function handleDeviceUpdate(device) {
|
||||||
devices.set(device.device_id, device);
|
devices.set(device.device_id, device);
|
||||||
renderDevice(device);
|
pendingDeviceIds.add(device.device_id);
|
||||||
updateDeviceCount();
|
if (selectedDeviceId === device.device_id) {
|
||||||
updateStatsFromDevices();
|
selectedDeviceNeedsRefresh = true;
|
||||||
updateVisualizationPanels();
|
}
|
||||||
updateProximityZones();
|
scheduleDeviceFlush();
|
||||||
|
}
|
||||||
// Update new proximity radar
|
|
||||||
updateRadar();
|
function scheduleDeviceFlush() {
|
||||||
}
|
if (pendingDeviceFlush) return;
|
||||||
|
pendingDeviceFlush = true;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
pendingDeviceFlush = false;
|
||||||
|
|
||||||
|
pendingDeviceIds.forEach((deviceId) => {
|
||||||
|
const device = devices.get(deviceId);
|
||||||
|
if (device) {
|
||||||
|
renderDevice(device, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
pendingDeviceIds.clear();
|
||||||
|
|
||||||
|
applyDeviceFilter();
|
||||||
|
updateDeviceCount();
|
||||||
|
updateStatsFromDevices();
|
||||||
|
updateVisualizationPanels();
|
||||||
|
updateProximityZones();
|
||||||
|
updateRadar();
|
||||||
|
|
||||||
|
if (selectedDeviceNeedsRefresh && selectedDeviceId && devices.has(selectedDeviceId)) {
|
||||||
|
showDeviceDetail(selectedDeviceId);
|
||||||
|
}
|
||||||
|
selectedDeviceNeedsRefresh = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update stats from all devices
|
* Update stats from all devices
|
||||||
@@ -1127,9 +1217,9 @@ const BluetoothMode = (function() {
|
|||||||
/**
|
/**
|
||||||
* Update visualization panels
|
* Update visualization panels
|
||||||
*/
|
*/
|
||||||
function updateVisualizationPanels() {
|
function updateVisualizationPanels() {
|
||||||
// Signal Distribution
|
// Signal Distribution
|
||||||
const total = devices.size || 1;
|
const total = devices.size || 1;
|
||||||
const strongBar = document.getElementById('btSignalStrong');
|
const strongBar = document.getElementById('btSignalStrong');
|
||||||
const mediumBar = document.getElementById('btSignalMedium');
|
const mediumBar = document.getElementById('btSignalMedium');
|
||||||
const weakBar = document.getElementById('btSignalWeak');
|
const weakBar = document.getElementById('btSignalWeak');
|
||||||
@@ -1140,101 +1230,120 @@ const BluetoothMode = (function() {
|
|||||||
if (strongBar) strongBar.style.width = (deviceStats.strong / total * 100) + '%';
|
if (strongBar) strongBar.style.width = (deviceStats.strong / total * 100) + '%';
|
||||||
if (mediumBar) mediumBar.style.width = (deviceStats.medium / total * 100) + '%';
|
if (mediumBar) mediumBar.style.width = (deviceStats.medium / total * 100) + '%';
|
||||||
if (weakBar) weakBar.style.width = (deviceStats.weak / total * 100) + '%';
|
if (weakBar) weakBar.style.width = (deviceStats.weak / total * 100) + '%';
|
||||||
if (strongCount) strongCount.textContent = deviceStats.strong;
|
if (strongCount) strongCount.textContent = deviceStats.strong;
|
||||||
if (mediumCount) mediumCount.textContent = deviceStats.medium;
|
if (mediumCount) mediumCount.textContent = deviceStats.medium;
|
||||||
if (weakCount) weakCount.textContent = deviceStats.weak;
|
if (weakCount) weakCount.textContent = deviceStats.weak;
|
||||||
|
|
||||||
// Tracker Detection - Enhanced display with confidence and evidence
|
// Device summary strip
|
||||||
const trackerList = document.getElementById('btTrackerList');
|
const totalEl = document.getElementById('btSummaryTotal');
|
||||||
if (trackerList) {
|
const newEl = document.getElementById('btSummaryNew');
|
||||||
if (devices.size === 0) {
|
const trackersEl = document.getElementById('btSummaryTrackers');
|
||||||
trackerList.innerHTML = '<div style="color:#666;padding:10px;text-align:center;font-size:11px;">Start scanning to detect trackers</div>';
|
const strongestEl = document.getElementById('btSummaryStrongest');
|
||||||
} else if (deviceStats.trackers.length === 0) {
|
if (totalEl || newEl || trackersEl || strongestEl) {
|
||||||
trackerList.innerHTML = '<div style="color:#22c55e;padding:10px;text-align:center;font-size:11px;">No trackers detected</div>';
|
let newCount = 0;
|
||||||
} else {
|
let strongest = null;
|
||||||
// Sort by risk score (highest first), then confidence
|
devices.forEach(d => {
|
||||||
const sortedTrackers = [...deviceStats.trackers].sort((a, b) => {
|
if (!d.in_baseline) newCount++;
|
||||||
const riskA = a.risk_score || 0;
|
if (d.rssi_current != null) {
|
||||||
const riskB = b.risk_score || 0;
|
strongest = strongest == null ? d.rssi_current : Math.max(strongest, d.rssi_current);
|
||||||
if (riskB !== riskA) return riskB - riskA;
|
}
|
||||||
const confA = a.tracker_confidence_score || 0;
|
});
|
||||||
const confB = b.tracker_confidence_score || 0;
|
if (totalEl) totalEl.textContent = devices.size;
|
||||||
return confB - confA;
|
if (newEl) newEl.textContent = newCount;
|
||||||
});
|
if (trackersEl) trackersEl.textContent = deviceStats.trackers.length;
|
||||||
|
if (strongestEl) strongestEl.textContent = strongest == null ? '--' : `${strongest} dBm`;
|
||||||
trackerList.innerHTML = sortedTrackers.map(t => {
|
}
|
||||||
// Get tracker type badge color based on confidence
|
|
||||||
const confidence = t.tracker_confidence || 'low';
|
// Tracker Detection - Enhanced display with confidence and evidence
|
||||||
const confColor = confidence === 'high' ? '#ef4444' :
|
const trackerList = document.getElementById('btTrackerList');
|
||||||
confidence === 'medium' ? '#f97316' : '#eab308';
|
if (trackerList) {
|
||||||
const confBg = confidence === 'high' ? 'rgba(239,68,68,0.2)' :
|
if (devices.size === 0) {
|
||||||
confidence === 'medium' ? 'rgba(249,115,22,0.2)' : 'rgba(234,179,8,0.2)';
|
if (typeof renderCollectionState === 'function') {
|
||||||
|
renderCollectionState(trackerList, { type: 'empty', message: 'Start scanning to detect trackers' });
|
||||||
// Risk score indicator
|
} else {
|
||||||
const riskScore = t.risk_score || 0;
|
trackerList.innerHTML = '<div class="app-collection-state is-empty">Start scanning to detect trackers</div>';
|
||||||
const riskColor = riskScore >= 0.5 ? '#ef4444' : riskScore >= 0.3 ? '#f97316' : '#666';
|
}
|
||||||
|
} else if (deviceStats.trackers.length === 0) {
|
||||||
// Tracker type label
|
if (typeof renderCollectionState === 'function') {
|
||||||
const trackerType = t.tracker_name || t.tracker_type || 'Unknown Tracker';
|
renderCollectionState(trackerList, { type: 'empty', message: 'No trackers detected' });
|
||||||
|
} else {
|
||||||
// Build evidence tooltip (first 2 items)
|
trackerList.innerHTML = '<div class="app-collection-state is-empty">No trackers detected</div>';
|
||||||
const evidence = (t.tracker_evidence || []).slice(0, 2);
|
}
|
||||||
const evidenceHtml = evidence.length > 0
|
} else {
|
||||||
? '<div style="font-size:9px;color:#888;margin-top:3px;font-style:italic;">' +
|
// Sort by risk score (highest first), then confidence
|
||||||
evidence.map(e => '• ' + escapeHtml(e)).join('<br>') +
|
const sortedTrackers = [...deviceStats.trackers].sort((a, b) => {
|
||||||
'</div>'
|
const riskA = a.risk_score || 0;
|
||||||
: '';
|
const riskB = b.risk_score || 0;
|
||||||
|
if (riskB !== riskA) return riskB - riskA;
|
||||||
const deviceIdEscaped = escapeHtml(t.device_id).replace(/'/g, "\\'");
|
const confA = a.tracker_confidence_score || 0;
|
||||||
|
const confB = b.tracker_confidence_score || 0;
|
||||||
return '<div class="bt-tracker-item" style="padding:8px;border-bottom:1px solid rgba(255,255,255,0.05);cursor:pointer;" onclick="BluetoothMode.selectDevice(\'' + deviceIdEscaped + '\')">' +
|
return confB - confA;
|
||||||
'<div style="display:flex;justify-content:space-between;align-items:center;">' +
|
});
|
||||||
'<div style="display:flex;align-items:center;gap:6px;">' +
|
|
||||||
'<span style="background:' + confBg + ';color:' + confColor + ';font-size:9px;padding:2px 5px;border-radius:3px;font-weight:600;">' + confidence.toUpperCase() + '</span>' +
|
trackerList.innerHTML = sortedTrackers.map((t) => {
|
||||||
'<span style="color:#fff;font-size:11px;">' + escapeHtml(trackerType) + '</span>' +
|
const confidence = t.tracker_confidence || 'low';
|
||||||
'</div>' +
|
const riskScore = t.risk_score || 0;
|
||||||
'<div style="display:flex;align-items:center;gap:8px;">' +
|
const trackerType = t.tracker_name || t.tracker_type || 'Unknown Tracker';
|
||||||
(riskScore >= 0.3 ? '<span style="color:' + riskColor + ';font-size:9px;font-weight:600;">RISK ' + Math.round(riskScore * 100) + '%</span>' : '') +
|
const evidence = (t.tracker_evidence || []).slice(0, 2);
|
||||||
'<span style="color:#666;font-size:10px;">' + (t.rssi_current || '--') + ' dBm</span>' +
|
const evidenceHtml = evidence.length > 0
|
||||||
'</div>' +
|
? `<div class="bt-tracker-evidence">${evidence.map((e) => `• ${escapeHtml(e)}`).join('<br>')}</div>`
|
||||||
'</div>' +
|
: '';
|
||||||
'<div style="display:flex;justify-content:space-between;margin-top:3px;">' +
|
const riskClass = riskScore >= 0.5 ? 'high' : riskScore >= 0.3 ? 'medium' : 'low';
|
||||||
'<span style="font-size:9px;color:#888;font-family:monospace;">' + (t.address_type === 'uuid' ? formatAddress(t) : t.address) + '</span>' +
|
const riskHtml = riskScore >= 0.3
|
||||||
'<span style="font-size:9px;color:#666;">Seen ' + (t.seen_count || 0) + 'x</span>' +
|
? `<span class="bt-tracker-risk bt-risk-${riskClass}">RISK ${Math.round(riskScore * 100)}%</span>`
|
||||||
'</div>' +
|
: '';
|
||||||
evidenceHtml +
|
|
||||||
'</div>';
|
return `
|
||||||
}).join('');
|
<div class="bt-tracker-item bt-tracker-confidence-${escapeHtml(confidence)}" data-device-id="${escapeAttr(t.device_id)}" role="button" tabindex="0" data-keyboard-activate="true">
|
||||||
}
|
<div class="bt-tracker-row-top">
|
||||||
}
|
<div class="bt-tracker-left">
|
||||||
|
<span class="bt-tracker-confidence">${escapeHtml(confidence.toUpperCase())}</span>
|
||||||
}
|
<span class="bt-tracker-type">${escapeHtml(trackerType)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bt-tracker-right">
|
||||||
|
${riskHtml}
|
||||||
|
<span class="bt-tracker-rssi">${t.rssi_current != null ? t.rssi_current : '--'} dBm</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bt-tracker-row-bottom">
|
||||||
|
<span class="bt-tracker-address">${escapeHtml(t.address_type === 'uuid' ? formatAddress(t) : (t.address || '--'))}</span>
|
||||||
|
<span class="bt-tracker-seen">Seen ${t.seen_count || 0}x</span>
|
||||||
|
</div>
|
||||||
|
${evidenceHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
function updateDeviceCount() {
|
function updateDeviceCount() {
|
||||||
updateFilteredCount();
|
updateFilteredCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDevice(device) {
|
function renderDevice(device, reapplyFilter = true) {
|
||||||
if (!deviceContainer) {
|
if (!deviceContainer) {
|
||||||
deviceContainer = document.getElementById('btDeviceListContent');
|
deviceContainer = document.getElementById('btDeviceListContent');
|
||||||
if (!deviceContainer) return;
|
if (!deviceContainer) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deviceContainer.querySelectorAll('.app-collection-state, .bt-device-filter-state').forEach((el) => el.remove());
|
||||||
|
|
||||||
|
const escapedId = CSS.escape(device.device_id);
|
||||||
|
const existingCard = deviceContainer.querySelector('[data-bt-device-id="' + escapedId + '"]');
|
||||||
|
const cardHtml = createSimpleDeviceCard(device);
|
||||||
|
|
||||||
const escapedId = CSS.escape(device.device_id);
|
if (existingCard) {
|
||||||
const existingCard = deviceContainer.querySelector('[data-bt-device-id="' + escapedId + '"]');
|
existingCard.outerHTML = cardHtml;
|
||||||
const cardHtml = createSimpleDeviceCard(device);
|
} else {
|
||||||
|
deviceContainer.insertAdjacentHTML('afterbegin', cardHtml);
|
||||||
if (existingCard) {
|
}
|
||||||
existingCard.outerHTML = cardHtml;
|
|
||||||
} else {
|
if (reapplyFilter) {
|
||||||
deviceContainer.insertAdjacentHTML('afterbegin', cardHtml);
|
applyDeviceFilter();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Re-apply filter after rendering
|
|
||||||
if (currentDeviceFilter !== 'all') {
|
|
||||||
applyDeviceFilter();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSimpleDeviceCard(device) {
|
function createSimpleDeviceCard(device) {
|
||||||
const protocol = device.protocol || 'ble';
|
const protocol = device.protocol || 'ble';
|
||||||
@@ -1254,12 +1363,19 @@ const BluetoothMode = (function() {
|
|||||||
// RSSI typically ranges from -100 (weak) to -30 (very strong)
|
// RSSI typically ranges from -100 (weak) to -30 (very strong)
|
||||||
const rssiPercent = rssi != null ? Math.max(0, Math.min(100, ((rssi + 100) / 70) * 100)) : 0;
|
const rssiPercent = rssi != null ? Math.max(0, Math.min(100, ((rssi + 100) / 70) * 100)) : 0;
|
||||||
|
|
||||||
const displayName = device.name || formatDeviceId(device.address);
|
const displayName = device.name || formatDeviceId(device.address);
|
||||||
const name = escapeHtml(displayName);
|
const name = escapeHtml(displayName);
|
||||||
const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown'));
|
const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown'));
|
||||||
const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : '';
|
const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : '';
|
||||||
const seenCount = device.seen_count || 0;
|
const seenCount = device.seen_count || 0;
|
||||||
const deviceIdEscaped = escapeHtml(device.device_id).replace(/'/g, "\\'");
|
const searchIndex = [
|
||||||
|
displayName,
|
||||||
|
device.address,
|
||||||
|
device.manufacturer_name,
|
||||||
|
device.tracker_name,
|
||||||
|
device.tracker_type,
|
||||||
|
agentName
|
||||||
|
].filter(Boolean).join(' ').toLowerCase();
|
||||||
|
|
||||||
// Protocol badge - compact
|
// Protocol badge - compact
|
||||||
const protoBadge = protocol === 'ble'
|
const protoBadge = protocol === 'ble'
|
||||||
@@ -1342,14 +1458,14 @@ const BluetoothMode = (function() {
|
|||||||
}
|
}
|
||||||
const secondaryInfo = secondaryParts.join(' · ');
|
const secondaryInfo = secondaryParts.join(' · ');
|
||||||
|
|
||||||
// Row border color - highlight trackers in red/orange
|
// Row border color - highlight trackers in red/orange
|
||||||
const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' :
|
const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' :
|
||||||
isTracker ? '#f97316' : rssiColor;
|
isTracker ? '#f97316' : rssiColor;
|
||||||
|
|
||||||
return '<div class="bt-device-row' + (isTracker ? ' is-tracker' : '') + '" data-bt-device-id="' + escapeHtml(device.device_id) + '" data-is-new="' + isNew + '" data-has-name="' + hasName + '" data-rssi="' + (rssi || -100) + '" data-is-tracker="' + isTracker + '" onclick="BluetoothMode.selectDevice(\'' + deviceIdEscaped + '\')" style="border-left-color:' + borderColor + ';">' +
|
return '<div class="bt-device-row' + (isTracker ? ' is-tracker' : '') + '" data-bt-device-id="' + escapeAttr(device.device_id) + '" data-is-new="' + isNew + '" data-has-name="' + hasName + '" data-rssi="' + (rssi || -100) + '" data-is-tracker="' + isTracker + '" data-search="' + escapeAttr(searchIndex) + '" role="button" tabindex="0" data-keyboard-activate="true" style="border-left-color:' + borderColor + ';">' +
|
||||||
'<div class="bt-row-main">' +
|
'<div class="bt-row-main">' +
|
||||||
'<div class="bt-row-left">' +
|
'<div class="bt-row-left">' +
|
||||||
protoBadge +
|
protoBadge +
|
||||||
'<span class="bt-device-name">' + name + '</span>' +
|
'<span class="bt-device-name">' + name + '</span>' +
|
||||||
trackerBadge +
|
trackerBadge +
|
||||||
irkBadge +
|
irkBadge +
|
||||||
@@ -1364,13 +1480,13 @@ const BluetoothMode = (function() {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
statusDot +
|
statusDot +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="bt-row-secondary">' + secondaryInfo + '</div>' +
|
'<div class="bt-row-secondary">' + secondaryInfo + '</div>' +
|
||||||
'<div class="bt-row-actions">' +
|
'<div class="bt-row-actions">' +
|
||||||
'<button class="bt-locate-btn" data-locate-id="' + escapeHtml(device.device_id) + '" onclick="event.stopPropagation(); BluetoothMode.locateById(this.dataset.locateId)">' +
|
'<button type="button" class="bt-locate-btn" data-locate-id="' + escapeAttr(device.device_id) + '">' +
|
||||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>' +
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>' +
|
||||||
'Locate</button>' +
|
'Locate</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1383,12 +1499,16 @@ const BluetoothMode = (function() {
|
|||||||
return '#ef4444';
|
return '#ef4444';
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = String(text);
|
div.textContent = String(text);
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeAttr(text) {
|
||||||
|
return escapeHtml(text).replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
async function setBaseline() {
|
async function setBaseline() {
|
||||||
try {
|
try {
|
||||||
@@ -1497,18 +1617,22 @@ const BluetoothMode = (function() {
|
|||||||
/**
|
/**
|
||||||
* Clear all collected data.
|
* Clear all collected data.
|
||||||
*/
|
*/
|
||||||
function clearData() {
|
function clearData() {
|
||||||
devices.clear();
|
devices.clear();
|
||||||
resetStats();
|
pendingDeviceIds.clear();
|
||||||
|
pendingDeviceFlush = false;
|
||||||
if (deviceContainer) {
|
selectedDeviceNeedsRefresh = false;
|
||||||
deviceContainer.innerHTML = '';
|
resetStats();
|
||||||
}
|
clearSelection();
|
||||||
|
|
||||||
updateDeviceCount();
|
if (deviceContainer) {
|
||||||
updateProximityZones();
|
if (typeof renderCollectionState === 'function') {
|
||||||
updateRadar();
|
renderCollectionState(deviceContainer, { type: 'empty', message: 'Start scanning to discover Bluetooth devices' });
|
||||||
}
|
} else {
|
||||||
|
deviceContainer.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle "Show All Agents" mode.
|
* Toggle "Show All Agents" mode.
|
||||||
@@ -1543,19 +1667,27 @@ const BluetoothMode = (function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
toRemove.forEach(deviceId => devices.delete(deviceId));
|
toRemove.forEach(deviceId => devices.delete(deviceId));
|
||||||
|
|
||||||
// Re-render device list
|
// Re-render device list
|
||||||
if (deviceContainer) {
|
if (deviceContainer) {
|
||||||
deviceContainer.innerHTML = '';
|
deviceContainer.innerHTML = '';
|
||||||
devices.forEach(device => renderDevice(device));
|
devices.forEach(device => renderDevice(device, false));
|
||||||
}
|
applyDeviceFilter();
|
||||||
|
if (devices.size === 0 && typeof renderCollectionState === 'function') {
|
||||||
updateDeviceCount();
|
renderCollectionState(deviceContainer, { type: 'empty', message: 'No devices for current agent' });
|
||||||
updateStatsFromDevices();
|
}
|
||||||
updateVisualizationPanels();
|
}
|
||||||
updateProximityZones();
|
|
||||||
updateRadar();
|
if (selectedDeviceId && !devices.has(selectedDeviceId)) {
|
||||||
|
clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDeviceCount();
|
||||||
|
updateStatsFromDevices();
|
||||||
|
updateVisualizationPanels();
|
||||||
|
updateProximityZones();
|
||||||
|
updateRadar();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1583,22 +1715,23 @@ const BluetoothMode = (function() {
|
|||||||
|
|
||||||
function doLocateHandoff(device) {
|
function doLocateHandoff(device) {
|
||||||
console.log('[BT] doLocateHandoff, BtLocate defined:', typeof BtLocate !== 'undefined');
|
console.log('[BT] doLocateHandoff, BtLocate defined:', typeof BtLocate !== 'undefined');
|
||||||
if (typeof BtLocate !== 'undefined') {
|
if (typeof BtLocate !== 'undefined') {
|
||||||
BtLocate.handoff({
|
BtLocate.handoff({
|
||||||
device_id: device.device_id,
|
device_id: device.device_id,
|
||||||
mac_address: device.address,
|
device_key: device.device_key || null,
|
||||||
address_type: device.address_type || null,
|
mac_address: device.address,
|
||||||
irk_hex: device.irk_hex || null,
|
address_type: device.address_type || null,
|
||||||
known_name: device.name || null,
|
irk_hex: device.irk_hex || null,
|
||||||
known_manufacturer: device.manufacturer_name || null,
|
known_name: device.name || null,
|
||||||
last_known_rssi: device.rssi_current,
|
known_manufacturer: device.manufacturer_name || null,
|
||||||
tx_power: device.tx_power || null,
|
last_known_rssi: device.rssi_current,
|
||||||
appearance_name: device.appearance_name || null,
|
tx_power: device.tx_power || null,
|
||||||
fingerprint_id: device.fingerprint_id || null,
|
appearance_name: device.appearance_name || null,
|
||||||
mac_cluster_count: device.mac_cluster_count || 0
|
fingerprint_id: device.fingerprint_id || device.fingerprint?.id || null,
|
||||||
});
|
mac_cluster_count: device.mac_cluster_count || 0
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Public API
|
// Public API
|
||||||
return {
|
return {
|
||||||
|
|||||||
+1070
-230
File diff suppressed because it is too large
Load Diff
+23
-8
@@ -23,6 +23,16 @@ const GPS = (function() {
|
|||||||
function init() {
|
function init() {
|
||||||
drawEmptySkyView();
|
drawEmptySkyView();
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
|
// Redraw sky view when theme changes
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
if (lastSky) {
|
||||||
|
drawSkyView(lastSky.satellites || []);
|
||||||
|
} else {
|
||||||
|
drawEmptySkyView();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
@@ -316,13 +326,18 @@ const GPS = (function() {
|
|||||||
|
|
||||||
ctx.clearRect(0, 0, w, h);
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
const cs = getComputedStyle(document.documentElement);
|
||||||
|
const bgColor = cs.getPropertyValue('--bg-card').trim() || '#0d1117';
|
||||||
|
const gridColor = cs.getPropertyValue('--border-color').trim() || '#2a3040';
|
||||||
|
const dimColor = cs.getPropertyValue('--text-dim').trim() || '#555';
|
||||||
|
const secondaryColor = cs.getPropertyValue('--text-secondary').trim() || '#888';
|
||||||
|
|
||||||
// Background
|
// Background
|
||||||
const bgStyle = getComputedStyle(document.documentElement).getPropertyValue('--bg-card').trim();
|
ctx.fillStyle = bgColor;
|
||||||
ctx.fillStyle = bgStyle || '#0d1117';
|
|
||||||
ctx.fillRect(0, 0, w, h);
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
// Elevation rings (0, 30, 60, 90)
|
// Elevation rings (0, 30, 60, 90)
|
||||||
ctx.strokeStyle = '#2a3040';
|
ctx.strokeStyle = gridColor;
|
||||||
ctx.lineWidth = 0.5;
|
ctx.lineWidth = 0.5;
|
||||||
[90, 60, 30].forEach(el => {
|
[90, 60, 30].forEach(el => {
|
||||||
const gr = r * (1 - el / 90);
|
const gr = r * (1 - el / 90);
|
||||||
@@ -330,7 +345,7 @@ const GPS = (function() {
|
|||||||
ctx.arc(cx, cy, gr, 0, Math.PI * 2);
|
ctx.arc(cx, cy, gr, 0, Math.PI * 2);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
// Label
|
// Label
|
||||||
ctx.fillStyle = '#555';
|
ctx.fillStyle = dimColor;
|
||||||
ctx.font = '9px Roboto Condensed, monospace';
|
ctx.font = '9px Roboto Condensed, monospace';
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
@@ -338,14 +353,14 @@ const GPS = (function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Horizon circle
|
// Horizon circle
|
||||||
ctx.strokeStyle = '#3a4050';
|
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
|
// Cardinal directions
|
||||||
ctx.fillStyle = '#888';
|
ctx.fillStyle = secondaryColor;
|
||||||
ctx.font = 'bold 11px Roboto Condensed, monospace';
|
ctx.font = 'bold 11px Roboto Condensed, monospace';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
@@ -355,7 +370,7 @@ const GPS = (function() {
|
|||||||
ctx.fillText('W', cx - r - 12, cy);
|
ctx.fillText('W', cx - r - 12, cy);
|
||||||
|
|
||||||
// Crosshairs
|
// Crosshairs
|
||||||
ctx.strokeStyle = '#2a3040';
|
ctx.strokeStyle = gridColor;
|
||||||
ctx.lineWidth = 0.5;
|
ctx.lineWidth = 0.5;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(cx, cy - r);
|
ctx.moveTo(cx, cy - r);
|
||||||
@@ -365,7 +380,7 @@ const GPS = (function() {
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Zenith dot
|
// Zenith dot
|
||||||
ctx.fillStyle = '#333';
|
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();
|
||||||
|
|||||||
@@ -401,10 +401,10 @@ const Meshtastic = (function() {
|
|||||||
|
|
||||||
// Position is nested in the response
|
// Position is nested in the response
|
||||||
const pos = info.position;
|
const pos = info.position;
|
||||||
if (pos && pos.latitude && pos.longitude) {
|
if (pos && pos.latitude !== undefined && pos.latitude !== null && pos.longitude !== undefined && pos.longitude !== null) {
|
||||||
if (posRow) posRow.style.display = 'flex';
|
if (posRow) posRow.style.display = 'flex';
|
||||||
if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`;
|
if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`;
|
||||||
} else {
|
} else {
|
||||||
if (posRow) posRow.style.display = 'none';
|
if (posRow) posRow.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ const SSTVGeneral = (function() {
|
|||||||
const deviceSelect = document.getElementById('deviceSelect');
|
const deviceSelect = document.getElementById('deviceSelect');
|
||||||
|
|
||||||
const frequency = parseFloat(freqInput?.value || '14.230');
|
const frequency = parseFloat(freqInput?.value || '14.230');
|
||||||
const modulation = modSelect?.value || 'usb';
|
const modulation = modSelect?.value || 'fm';
|
||||||
const device = parseInt(deviceSelect?.value || '0', 10);
|
const device = parseInt(deviceSelect?.value || '0', 10);
|
||||||
|
|
||||||
updateStatusUI('connecting', 'Starting...');
|
updateStatusUI('connecting', 'Starting...');
|
||||||
|
|||||||
+92
-44
@@ -12,14 +12,16 @@ const SSTV = (function() {
|
|||||||
let progress = 0;
|
let progress = 0;
|
||||||
let issMap = null;
|
let issMap = null;
|
||||||
let issMarker = null;
|
let issMarker = null;
|
||||||
let issTrackLine = null;
|
let issTrackLine = null;
|
||||||
let issPosition = null;
|
let issPosition = null;
|
||||||
let issUpdateInterval = null;
|
let issUpdateInterval = null;
|
||||||
let countdownInterval = null;
|
let countdownInterval = null;
|
||||||
let nextPassData = null;
|
let nextPassData = null;
|
||||||
|
let pendingMapInvalidate = false;
|
||||||
|
|
||||||
// ISS frequency
|
// ISS frequency
|
||||||
const ISS_FREQ = 145.800;
|
const ISS_FREQ = 145.800;
|
||||||
|
const ISS_MODULATION = 'fm';
|
||||||
|
|
||||||
// Signal scope state
|
// Signal scope state
|
||||||
let sstvScopeCtx = null;
|
let sstvScopeCtx = null;
|
||||||
@@ -36,15 +38,31 @@ const SSTV = (function() {
|
|||||||
/**
|
/**
|
||||||
* Initialize the SSTV mode
|
* Initialize the SSTV mode
|
||||||
*/
|
*/
|
||||||
function init() {
|
function init() {
|
||||||
checkStatus();
|
checkStatus();
|
||||||
loadImages();
|
loadImages();
|
||||||
loadLocationInputs();
|
loadLocationInputs();
|
||||||
loadIssSchedule();
|
loadIssSchedule();
|
||||||
initMap();
|
initMap();
|
||||||
startIssTracking();
|
startIssTracking();
|
||||||
startCountdown();
|
startCountdown();
|
||||||
}
|
// Ensure Leaflet recomputes dimensions after the SSTV pane becomes visible.
|
||||||
|
setTimeout(() => invalidateMap(), 80);
|
||||||
|
setTimeout(() => invalidateMap(), 260);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMapContainerVisible() {
|
||||||
|
if (!issMap || typeof issMap.getContainer !== 'function') return false;
|
||||||
|
const container = issMap.getContainer();
|
||||||
|
if (!container) return false;
|
||||||
|
if (container.offsetWidth <= 0 || container.offsetHeight <= 0) return false;
|
||||||
|
if (container.style && container.style.display === 'none') return false;
|
||||||
|
if (typeof window.getComputedStyle === 'function') {
|
||||||
|
const style = window.getComputedStyle(container);
|
||||||
|
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load location into input fields
|
* Load location into input fields
|
||||||
@@ -171,9 +189,9 @@ const SSTV = (function() {
|
|||||||
/**
|
/**
|
||||||
* Initialize Leaflet map for ISS tracking
|
* Initialize Leaflet map for ISS tracking
|
||||||
*/
|
*/
|
||||||
async function initMap() {
|
async function initMap() {
|
||||||
const mapContainer = document.getElementById('sstvIssMap');
|
const mapContainer = document.getElementById('sstvIssMap');
|
||||||
if (!mapContainer || issMap) return;
|
if (!mapContainer || issMap) return;
|
||||||
|
|
||||||
// Create map
|
// Create map
|
||||||
issMap = L.map('sstvIssMap', {
|
issMap = L.map('sstvIssMap', {
|
||||||
@@ -213,13 +231,21 @@ const SSTV = (function() {
|
|||||||
issMarker = L.marker([0, 0], { icon: issIcon }).addTo(issMap);
|
issMarker = L.marker([0, 0], { icon: issIcon }).addTo(issMap);
|
||||||
|
|
||||||
// Create ground track line
|
// Create ground track line
|
||||||
issTrackLine = L.polyline([], {
|
issTrackLine = L.polyline([], {
|
||||||
color: '#00d4ff',
|
color: '#00d4ff',
|
||||||
weight: 2,
|
weight: 2,
|
||||||
opacity: 0.6,
|
opacity: 0.6,
|
||||||
dashArray: '5, 5'
|
dashArray: '5, 5'
|
||||||
}).addTo(issMap);
|
}).addTo(issMap);
|
||||||
}
|
|
||||||
|
issMap.on('resize moveend zoomend', () => {
|
||||||
|
if (pendingMapInvalidate) invalidateMap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial layout passes for first-time mode load.
|
||||||
|
setTimeout(() => invalidateMap(), 40);
|
||||||
|
setTimeout(() => invalidateMap(), 180);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start ISS position tracking
|
* Start ISS position tracking
|
||||||
@@ -428,8 +454,9 @@ const SSTV = (function() {
|
|||||||
/**
|
/**
|
||||||
* Update map with ISS position
|
* Update map with ISS position
|
||||||
*/
|
*/
|
||||||
function updateMap() {
|
function updateMap() {
|
||||||
if (!issMap || !issPosition) return;
|
if (!issMap || !issPosition) return;
|
||||||
|
if (pendingMapInvalidate) invalidateMap();
|
||||||
|
|
||||||
const lat = issPosition.lat;
|
const lat = issPosition.lat;
|
||||||
const lon = issPosition.lon;
|
const lon = issPosition.lon;
|
||||||
@@ -489,9 +516,13 @@ const SSTV = (function() {
|
|||||||
issTrackLine.setLatLngs(segments.length > 0 ? segments : []);
|
issTrackLine.setLatLngs(segments.length > 0 ? segments : []);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pan map to follow ISS
|
// Pan map to follow ISS only when the map pane is currently renderable.
|
||||||
issMap.panTo([lat, lon], { animate: true, duration: 0.5 });
|
if (isMapContainerVisible()) {
|
||||||
}
|
issMap.panTo([lat, lon], { animate: true, duration: 0.5 });
|
||||||
|
} else {
|
||||||
|
pendingMapInvalidate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check current decoder status
|
* Check current decoder status
|
||||||
@@ -544,7 +575,7 @@ const SSTV = (function() {
|
|||||||
const response = await fetch('/sstv/start', {
|
const response = await fetch('/sstv/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ frequency, device })
|
body: JSON.stringify({ frequency, modulation: ISS_MODULATION, device })
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -554,9 +585,11 @@ const SSTV = (function() {
|
|||||||
if (typeof reserveDevice === 'function') {
|
if (typeof reserveDevice === 'function') {
|
||||||
reserveDevice(device, 'sstv');
|
reserveDevice(device, 'sstv');
|
||||||
}
|
}
|
||||||
updateStatusUI('listening', `${frequency} MHz`);
|
const tunedFrequency = Number(data.frequency || frequency);
|
||||||
|
const modulationText = String(data.modulation || ISS_MODULATION).toUpperCase();
|
||||||
|
updateStatusUI('listening', `${tunedFrequency.toFixed(3)} MHz ${modulationText}`);
|
||||||
startStream();
|
startStream();
|
||||||
showNotification('SSTV', `Listening on ${frequency} MHz`);
|
showNotification('SSTV', `Listening on ${tunedFrequency.toFixed(3)} MHz ${modulationText}`);
|
||||||
} else {
|
} else {
|
||||||
updateStatusUI('idle', 'Start failed');
|
updateStatusUI('idle', 'Start failed');
|
||||||
showStatusMessage(data.message || 'Failed to start decoder', 'error');
|
showStatusMessage(data.message || 'Failed to start decoder', 'error');
|
||||||
@@ -1302,13 +1335,27 @@ const SSTV = (function() {
|
|||||||
/**
|
/**
|
||||||
* Show status message
|
* Show status message
|
||||||
*/
|
*/
|
||||||
function showStatusMessage(message, type) {
|
function showStatusMessage(message, type) {
|
||||||
if (typeof showNotification === 'function') {
|
if (typeof showNotification === 'function') {
|
||||||
showNotification('SSTV', message);
|
showNotification('SSTV', message);
|
||||||
} else {
|
} else {
|
||||||
console.log(`[SSTV ${type}] ${message}`);
|
console.log(`[SSTV ${type}] ${message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate ISS map size after pane/layout changes.
|
||||||
|
*/
|
||||||
|
function invalidateMap() {
|
||||||
|
if (!issMap) return false;
|
||||||
|
if (!isMapContainerVisible()) {
|
||||||
|
pendingMapInvalidate = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
issMap.invalidateSize({ pan: false, animate: false });
|
||||||
|
pendingMapInvalidate = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Public API
|
// Public API
|
||||||
return {
|
return {
|
||||||
@@ -1323,11 +1370,12 @@ const SSTV = (function() {
|
|||||||
deleteAllImages,
|
deleteAllImages,
|
||||||
downloadImage,
|
downloadImage,
|
||||||
useGPS,
|
useGPS,
|
||||||
updateTLE,
|
updateTLE,
|
||||||
stopIssTracking,
|
stopIssTracking,
|
||||||
stopCountdown
|
stopCountdown,
|
||||||
};
|
invalidateMap
|
||||||
})();
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
// Initialize when DOM is ready (will be called by selectMode)
|
// Initialize when DOM is ready (will be called by selectMode)
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Weather Satellite Mode
|
* Weather Satellite Mode
|
||||||
* NOAA APT and Meteor LRPT decoder interface with auto-scheduler,
|
* NOAA APT and Meteor LRPT decoder interface with auto-scheduler,
|
||||||
* polar plot, ground track map, countdown, and timeline.
|
* polar plot, styled real-world map, countdown, and timeline.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const WeatherSat = (function() {
|
const WeatherSat = (function() {
|
||||||
// State
|
// State
|
||||||
@@ -11,36 +11,73 @@ const WeatherSat = (function() {
|
|||||||
let images = [];
|
let images = [];
|
||||||
let passes = [];
|
let passes = [];
|
||||||
let selectedPassIndex = -1;
|
let selectedPassIndex = -1;
|
||||||
let currentSatellite = null;
|
let currentSatellite = null;
|
||||||
let countdownInterval = null;
|
let countdownInterval = null;
|
||||||
let schedulerEnabled = false;
|
let schedulerEnabled = false;
|
||||||
let groundMap = null;
|
let groundMap = null;
|
||||||
let groundTrackLayer = null;
|
let groundTrackLayer = null;
|
||||||
let observerMarker = null;
|
let groundOverlayLayer = null;
|
||||||
let consoleEntries = [];
|
let groundGridLayer = null;
|
||||||
|
let satCrosshairMarker = null;
|
||||||
|
let observerMarker = null;
|
||||||
|
let consoleEntries = [];
|
||||||
let consoleCollapsed = false;
|
let consoleCollapsed = false;
|
||||||
let currentPhase = 'idle';
|
let currentPhase = 'idle';
|
||||||
let consoleAutoHideTimer = null;
|
let consoleAutoHideTimer = null;
|
||||||
let currentModalFilename = null;
|
let currentModalFilename = null;
|
||||||
|
let locationListenersAttached = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the Weather Satellite mode
|
* Initialize the Weather Satellite mode
|
||||||
*/
|
*/
|
||||||
function init() {
|
function init() {
|
||||||
checkStatus();
|
checkStatus();
|
||||||
loadImages();
|
loadImages();
|
||||||
loadLocationInputs();
|
loadLocationInputs();
|
||||||
loadPasses();
|
loadPasses();
|
||||||
startCountdownTimer();
|
startCountdownTimer();
|
||||||
checkSchedulerStatus();
|
checkSchedulerStatus();
|
||||||
initGroundMap();
|
initGroundMap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load observer location into input fields
|
* Get observer coordinates from shared location or local storage.
|
||||||
*/
|
*/
|
||||||
function loadLocationInputs() {
|
function getObserverCoords() {
|
||||||
const latInput = document.getElementById('wxsatObsLat');
|
let lat;
|
||||||
|
let lon;
|
||||||
|
|
||||||
|
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||||
|
const shared = ObserverLocation.getShared();
|
||||||
|
lat = Number(shared?.lat);
|
||||||
|
lon = Number(shared?.lon);
|
||||||
|
} else {
|
||||||
|
lat = Number(localStorage.getItem('observerLat'));
|
||||||
|
lon = Number(localStorage.getItem('observerLon'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFinite(lat) || !isFinite(lon)) return null;
|
||||||
|
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
|
||||||
|
return { lat, lon };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Center the ground map on current observer coordinates when available.
|
||||||
|
*/
|
||||||
|
function centerGroundMapOnObserver(zoom = 1) {
|
||||||
|
if (!groundMap) return;
|
||||||
|
const observer = getObserverCoords();
|
||||||
|
if (!observer) return;
|
||||||
|
const lat = Math.max(-85, Math.min(85, observer.lat));
|
||||||
|
const lon = normalizeLon(observer.lon);
|
||||||
|
groundMap.setView([lat, lon], zoom, { animate: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load observer location into input fields
|
||||||
|
*/
|
||||||
|
function loadLocationInputs() {
|
||||||
|
const latInput = document.getElementById('wxsatObsLat');
|
||||||
const lonInput = document.getElementById('wxsatObsLon');
|
const lonInput = document.getElementById('wxsatObsLon');
|
||||||
|
|
||||||
let storedLat = localStorage.getItem('observerLat');
|
let storedLat = localStorage.getItem('observerLat');
|
||||||
@@ -54,8 +91,13 @@ const WeatherSat = (function() {
|
|||||||
if (latInput && storedLat) latInput.value = storedLat;
|
if (latInput && storedLat) latInput.value = storedLat;
|
||||||
if (lonInput && storedLon) lonInput.value = storedLon;
|
if (lonInput && storedLon) lonInput.value = storedLon;
|
||||||
|
|
||||||
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
|
// Only attach listeners once — re-calling init() on mode switch must not
|
||||||
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
|
// accumulate duplicate listeners that fire loadPasses() multiple times.
|
||||||
|
if (!locationListenersAttached) {
|
||||||
|
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
|
||||||
|
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
|
||||||
|
locationListenersAttached = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,13 +114,14 @@ const WeatherSat = (function() {
|
|||||||
!isNaN(lon) && lon >= -180 && lon <= 180) {
|
!isNaN(lon) && lon >= -180 && lon <= 180) {
|
||||||
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||||
ObserverLocation.setShared({ lat, lon });
|
ObserverLocation.setShared({ lat, lon });
|
||||||
} else {
|
} else {
|
||||||
localStorage.setItem('observerLat', lat.toString());
|
localStorage.setItem('observerLat', lat.toString());
|
||||||
localStorage.setItem('observerLon', lon.toString());
|
localStorage.setItem('observerLon', lon.toString());
|
||||||
}
|
}
|
||||||
loadPasses();
|
loadPasses();
|
||||||
}
|
centerGroundMapOnObserver(1);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use GPS for location
|
* Use GPS for location
|
||||||
@@ -111,11 +154,12 @@ const WeatherSat = (function() {
|
|||||||
localStorage.setItem('observerLon', lon);
|
localStorage.setItem('observerLon', lon);
|
||||||
}
|
}
|
||||||
|
|
||||||
btn.innerHTML = originalText;
|
btn.innerHTML = originalText;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
showNotification('Weather Sat', 'Location updated');
|
showNotification('Weather Sat', 'Location updated');
|
||||||
loadPasses();
|
loadPasses();
|
||||||
},
|
centerGroundMapOnObserver(1);
|
||||||
|
},
|
||||||
(err) => {
|
(err) => {
|
||||||
btn.innerHTML = originalText;
|
btn.innerHTML = originalText;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
@@ -428,8 +472,17 @@ const WeatherSat = (function() {
|
|||||||
updateStatusUI('capturing', `Auto: ${p.name || p.satellite} ${p.frequency} MHz`);
|
updateStatusUI('capturing', `Auto: ${p.name || p.satellite} ${p.frequency} MHz`);
|
||||||
showNotification('Weather Sat', `Auto-capture started: ${p.name || p.satellite}`);
|
showNotification('Weather Sat', `Auto-capture started: ${p.name || p.satellite}`);
|
||||||
} else if (data.type === 'schedule_capture_complete') {
|
} else if (data.type === 'schedule_capture_complete') {
|
||||||
showNotification('Weather Sat', `Auto-capture complete: ${(data.pass || {}).name || ''}`);
|
const p = data.pass || {};
|
||||||
|
showNotification('Weather Sat', `Auto-capture complete: ${p.name || ''}`);
|
||||||
|
// Reset UI — the decoder's stop() doesn't emit a progress complete event
|
||||||
|
// when called internally by the scheduler, so we handle it here.
|
||||||
|
isRunning = false;
|
||||||
|
updateStatusUI('idle', 'Auto-capture complete');
|
||||||
|
const captureStatus = document.getElementById('wxsatCaptureStatus');
|
||||||
|
if (captureStatus) captureStatus.classList.remove('active');
|
||||||
|
updatePhaseIndicator('complete');
|
||||||
loadImages();
|
loadImages();
|
||||||
|
loadPasses();
|
||||||
} else if (data.type === 'schedule_capture_skipped') {
|
} else if (data.type === 'schedule_capture_skipped') {
|
||||||
const reason = data.reason || 'unknown';
|
const reason = data.reason || 'unknown';
|
||||||
const p = data.pass || {};
|
const p = data.pass || {};
|
||||||
@@ -446,10 +499,30 @@ const WeatherSat = (function() {
|
|||||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse pass timestamps, accepting legacy malformed UTC strings (+00:00Z).
|
||||||
|
*/
|
||||||
|
function parsePassDate(value) {
|
||||||
|
if (!value || typeof value !== 'string') return null;
|
||||||
|
|
||||||
|
let parsed = new Date(value);
|
||||||
|
if (!Number.isNaN(parsed.getTime())) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward-compatible cleanup for accidentally double-suffixed UTC timestamps.
|
||||||
|
parsed = new Date(value.replace(/\+00:00Z$/, 'Z'));
|
||||||
|
if (!Number.isNaN(parsed.getTime())) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load pass predictions (with trajectory + ground track)
|
* Load pass predictions (with trajectory + ground track)
|
||||||
*/
|
*/
|
||||||
async function loadPasses() {
|
async function loadPasses() {
|
||||||
let storedLat, storedLon;
|
let storedLat, storedLon;
|
||||||
|
|
||||||
// Use ObserverLocation if available, otherwise fall back to localStorage
|
// Use ObserverLocation if available, otherwise fall back to localStorage
|
||||||
@@ -462,29 +535,38 @@ const WeatherSat = (function() {
|
|||||||
storedLon = localStorage.getItem('observerLon');
|
storedLon = localStorage.getItem('observerLon');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!storedLat || !storedLon) {
|
if (!storedLat || !storedLon) {
|
||||||
renderPasses([]);
|
passes = [];
|
||||||
return;
|
selectedPassIndex = -1;
|
||||||
}
|
renderPasses([]);
|
||||||
|
renderTimeline([]);
|
||||||
|
updateCountdownFromPasses();
|
||||||
|
updateGroundTrack(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=24&min_elevation=15&trajectory=true&ground_track=true`;
|
const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=24&min_elevation=15&trajectory=true&ground_track=true`;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.status === 'ok') {
|
if (data.status === 'ok') {
|
||||||
passes = data.passes || [];
|
passes = data.passes || [];
|
||||||
renderPasses(passes);
|
selectedPassIndex = -1;
|
||||||
renderTimeline(passes);
|
renderPasses(passes);
|
||||||
updateCountdownFromPasses();
|
renderTimeline(passes);
|
||||||
// Auto-select first pass
|
updateCountdownFromPasses();
|
||||||
if (passes.length > 0 && selectedPassIndex < 0) {
|
// Always select the first upcoming pass so the polar plot
|
||||||
selectPass(0);
|
// and ground track reflect the current list after every refresh.
|
||||||
}
|
if (passes.length > 0) {
|
||||||
}
|
selectPass(0);
|
||||||
} catch (err) {
|
} else {
|
||||||
console.error('Failed to load passes:', err);
|
updateGroundTrack(null);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load passes:', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -536,13 +618,15 @@ const WeatherSat = (function() {
|
|||||||
const modeClass = pass.mode === 'APT' ? 'apt' : 'lrpt';
|
const modeClass = pass.mode === 'APT' ? 'apt' : 'lrpt';
|
||||||
const timeStr = pass.startTime || '--';
|
const timeStr = pass.startTime || '--';
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const passStart = new Date(pass.startTimeISO);
|
const passStart = parsePassDate(pass.startTimeISO);
|
||||||
const diffMs = passStart - now;
|
const diffMs = passStart ? passStart - now : NaN;
|
||||||
const diffMins = Math.floor(diffMs / 60000);
|
const diffMins = Number.isFinite(diffMs) ? Math.floor(diffMs / 60000) : NaN;
|
||||||
const isSelected = idx === selectedPassIndex;
|
const isSelected = idx === selectedPassIndex;
|
||||||
|
|
||||||
let countdown = '';
|
let countdown = '--';
|
||||||
if (diffMs < 0) {
|
if (!Number.isFinite(diffMs)) {
|
||||||
|
countdown = '--';
|
||||||
|
} else if (diffMs < 0) {
|
||||||
countdown = 'NOW';
|
countdown = 'NOW';
|
||||||
} else if (diffMins < 60) {
|
} else if (diffMins < 60) {
|
||||||
countdown = `in ${diffMins}m`;
|
countdown = `in ${diffMins}m`;
|
||||||
@@ -701,94 +785,342 @@ const WeatherSat = (function() {
|
|||||||
ctx.fillText(Math.round(maxEl) + '\u00b0', cx + r * maxR * Math.cos(maxAz), cy + r * maxR * Math.sin(maxAz) - 8);
|
ctx.fillText(Math.round(maxEl) + '\u00b0', cx + r * maxR * Math.cos(maxAz), cy + r * maxR * Math.sin(maxAz) - 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================
|
// ========================
|
||||||
// Ground Track Map
|
// Ground Track Map
|
||||||
// ========================
|
// ========================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize Leaflet ground track map
|
* Initialize styled real-world map panel.
|
||||||
*/
|
*/
|
||||||
function initGroundMap() {
|
async function initGroundMap() {
|
||||||
const container = document.getElementById('wxsatGroundMap');
|
const container = document.getElementById('wxsatGroundMap');
|
||||||
if (!container || groundMap) return;
|
if (!container) return;
|
||||||
if (typeof L === 'undefined') return;
|
if (typeof L === 'undefined') return;
|
||||||
|
const observer = getObserverCoords();
|
||||||
groundMap = L.map(container, {
|
const defaultCenter = observer
|
||||||
center: [20, 0],
|
? [Math.max(-85, Math.min(85, observer.lat)), normalizeLon(observer.lon)]
|
||||||
zoom: 2,
|
: [12, 0];
|
||||||
zoomControl: false,
|
const defaultZoom = 1;
|
||||||
attributionControl: false,
|
|
||||||
});
|
if (!groundMap) {
|
||||||
|
groundMap = L.map(container, {
|
||||||
// Check tile provider from settings
|
center: defaultCenter,
|
||||||
let tileUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
zoom: defaultZoom,
|
||||||
try {
|
minZoom: 1,
|
||||||
const provider = localStorage.getItem('tileProvider');
|
maxZoom: 7,
|
||||||
if (provider === 'osm') {
|
zoomControl: false,
|
||||||
tileUrl = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
attributionControl: false,
|
||||||
}
|
worldCopyJump: true,
|
||||||
} catch (e) {}
|
preferCanvas: true,
|
||||||
|
});
|
||||||
L.tileLayer(tileUrl, { maxZoom: 10 }).addTo(groundMap);
|
|
||||||
|
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
|
||||||
groundTrackLayer = L.layerGroup().addTo(groundMap);
|
await Settings.init();
|
||||||
|
Settings.createTileLayer().addTo(groundMap);
|
||||||
// Delayed invalidation to fix sizing
|
Settings.registerMap(groundMap);
|
||||||
setTimeout(() => { if (groundMap) groundMap.invalidateSize(); }, 200);
|
} else {
|
||||||
}
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||||
|
subdomains: 'abcd',
|
||||||
/**
|
maxZoom: 18,
|
||||||
* Update ground track on the map
|
noWrap: false,
|
||||||
*/
|
crossOrigin: true,
|
||||||
function updateGroundTrack(pass) {
|
className: 'tile-layer-cyan',
|
||||||
if (!groundMap || !groundTrackLayer) return;
|
}).addTo(groundMap);
|
||||||
|
}
|
||||||
groundTrackLayer.clearLayers();
|
|
||||||
|
groundGridLayer = L.layerGroup().addTo(groundMap);
|
||||||
const track = pass.groundTrack;
|
addStyledGridOverlay(groundGridLayer);
|
||||||
if (!track || track.length === 0) return;
|
|
||||||
|
groundTrackLayer = L.layerGroup().addTo(groundMap);
|
||||||
const color = pass.mode === 'LRPT' ? '#00ff88' : '#00d4ff';
|
groundOverlayLayer = L.layerGroup().addTo(groundMap);
|
||||||
|
}
|
||||||
// Draw polyline
|
|
||||||
const latlngs = track.map(p => [p.lat, p.lon]);
|
setTimeout(() => {
|
||||||
L.polyline(latlngs, { color, weight: 2, opacity: 0.8 }).addTo(groundTrackLayer);
|
if (!groundMap) return;
|
||||||
|
groundMap.invalidateSize(false);
|
||||||
// Start marker
|
groundMap.setView(defaultCenter, defaultZoom, { animate: false });
|
||||||
L.circleMarker(latlngs[0], {
|
updateGroundTrack(getSelectedPass());
|
||||||
radius: 5, color: '#00ff88', fillColor: '#00ff88', fillOpacity: 1, weight: 0,
|
}, 140);
|
||||||
}).addTo(groundTrackLayer);
|
}
|
||||||
|
|
||||||
// End marker
|
/**
|
||||||
L.circleMarker(latlngs[latlngs.length - 1], {
|
* Update map panel subtitle.
|
||||||
radius: 5, color: '#ff4444', fillColor: '#ff4444', fillOpacity: 1, weight: 0,
|
*/
|
||||||
}).addTo(groundTrackLayer);
|
function updateProjectionInfo(text) {
|
||||||
|
const infoEl = document.getElementById('wxsatMapInfo');
|
||||||
// Observer marker
|
if (infoEl) infoEl.textContent = text || '--';
|
||||||
let obsLat, obsLon;
|
}
|
||||||
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
|
||||||
const shared = ObserverLocation.getShared();
|
/**
|
||||||
obsLat = shared?.lat;
|
* Normalize longitude to [-180, 180).
|
||||||
obsLon = shared?.lon;
|
*/
|
||||||
} else {
|
function normalizeLon(value) {
|
||||||
obsLat = parseFloat(localStorage.getItem('observerLat'));
|
const lon = Number(value);
|
||||||
obsLon = parseFloat(localStorage.getItem('observerLon'));
|
if (!isFinite(lon)) return 0;
|
||||||
}
|
return ((((lon + 180) % 360) + 360) % 360) - 180;
|
||||||
const lat = obsLat;
|
}
|
||||||
const lon = obsLon;
|
|
||||||
if (!isNaN(lat) && !isNaN(lon)) {
|
/**
|
||||||
L.circleMarker([lat, lon], {
|
* Build track segments that do not cross the date line.
|
||||||
radius: 6, color: '#ffbb00', fillColor: '#ffbb00', fillOpacity: 0.8, weight: 1,
|
*/
|
||||||
}).addTo(groundTrackLayer);
|
function buildTrackSegments(track) {
|
||||||
}
|
const segments = [];
|
||||||
|
let currentSegment = [];
|
||||||
// Fit bounds
|
|
||||||
try {
|
track.forEach((point) => {
|
||||||
const bounds = L.latLngBounds(latlngs);
|
const lat = Number(point?.lat);
|
||||||
if (!isNaN(lat) && !isNaN(lon)) bounds.extend([lat, lon]);
|
const lon = normalizeLon(point?.lon);
|
||||||
groundMap.fitBounds(bounds, { padding: [20, 20] });
|
if (!isFinite(lat) || !isFinite(lon)) return;
|
||||||
} catch (e) {}
|
|
||||||
}
|
if (currentSegment.length > 0) {
|
||||||
|
const prevLon = currentSegment[currentSegment.length - 1][1];
|
||||||
|
if (Math.abs(lon - prevLon) > 180) {
|
||||||
|
if (currentSegment.length > 1) segments.push(currentSegment);
|
||||||
|
currentSegment = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSegment.push([lat, lon]);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentSegment.length > 1) segments.push(currentSegment);
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw a subtle graticule over the base map for a cyber/wireframe look.
|
||||||
|
*/
|
||||||
|
function addStyledGridOverlay(layer) {
|
||||||
|
if (!layer || typeof L === 'undefined') return;
|
||||||
|
layer.clearLayers();
|
||||||
|
|
||||||
|
for (let lon = -180; lon <= 180; lon += 30) {
|
||||||
|
const line = [];
|
||||||
|
for (let lat = -85; lat <= 85; lat += 5) line.push([lat, lon]);
|
||||||
|
L.polyline(line, {
|
||||||
|
color: '#4ed2ff',
|
||||||
|
weight: lon % 60 === 0 ? 1.1 : 0.8,
|
||||||
|
opacity: lon % 60 === 0 ? 0.2 : 0.12,
|
||||||
|
interactive: false,
|
||||||
|
lineCap: 'round',
|
||||||
|
}).addTo(layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let lat = -75; lat <= 75; lat += 15) {
|
||||||
|
const line = [];
|
||||||
|
for (let lon = -180; lon <= 180; lon += 5) line.push([lat, lon]);
|
||||||
|
L.polyline(line, {
|
||||||
|
color: '#5be7ff',
|
||||||
|
weight: lat % 30 === 0 ? 1.1 : 0.8,
|
||||||
|
opacity: lat % 30 === 0 ? 0.2 : 0.12,
|
||||||
|
interactive: false,
|
||||||
|
lineCap: 'round',
|
||||||
|
}).addTo(layer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSatelliteCrosshair() {
|
||||||
|
if (!groundOverlayLayer || !satCrosshairMarker) return;
|
||||||
|
groundOverlayLayer.removeLayer(satCrosshairMarker);
|
||||||
|
satCrosshairMarker = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSatelliteCrosshairIcon() {
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'wxsat-crosshair-icon',
|
||||||
|
iconSize: [30, 30],
|
||||||
|
iconAnchor: [15, 15],
|
||||||
|
html: `
|
||||||
|
<div class="wxsat-crosshair-marker">
|
||||||
|
<span class="wxsat-crosshair-h"></span>
|
||||||
|
<span class="wxsat-crosshair-v"></span>
|
||||||
|
<span class="wxsat-crosshair-ring"></span>
|
||||||
|
<span class="wxsat-crosshair-dot"></span>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update selected ground track and redraw map overlays.
|
||||||
|
*/
|
||||||
|
function updateGroundTrack(pass) {
|
||||||
|
if (!groundMap || !groundTrackLayer) return;
|
||||||
|
|
||||||
|
groundTrackLayer.clearLayers();
|
||||||
|
observerMarker = null;
|
||||||
|
|
||||||
|
if (!pass) {
|
||||||
|
clearSatelliteCrosshair();
|
||||||
|
updateProjectionInfo('--');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = pass?.groundTrack;
|
||||||
|
if (!Array.isArray(track) || track.length === 0) {
|
||||||
|
clearSatelliteCrosshair();
|
||||||
|
updateProjectionInfo(`${pass.name || pass.satellite || '--'} --`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const color = pass.mode === 'LRPT' ? '#27ffc6' : '#58ddff';
|
||||||
|
const glowClass = pass.mode === 'LRPT' ? 'wxsat-pass-track lrpt' : 'wxsat-pass-track apt';
|
||||||
|
const segments = buildTrackSegments(track);
|
||||||
|
const validPoints = track
|
||||||
|
.map((point) => [Number(point?.lat), normalizeLon(point?.lon)])
|
||||||
|
.filter((point) => isFinite(point[0]) && isFinite(point[1]));
|
||||||
|
|
||||||
|
segments.forEach((segment) => {
|
||||||
|
L.polyline(segment, {
|
||||||
|
color,
|
||||||
|
weight: 2.3,
|
||||||
|
opacity: 0.9,
|
||||||
|
className: glowClass,
|
||||||
|
interactive: false,
|
||||||
|
lineJoin: 'round',
|
||||||
|
}).addTo(groundTrackLayer);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validPoints.length > 0) {
|
||||||
|
L.circleMarker(validPoints[0], {
|
||||||
|
radius: 4.5,
|
||||||
|
color: '#00ffa2',
|
||||||
|
fillColor: '#00ffa2',
|
||||||
|
fillOpacity: 0.95,
|
||||||
|
weight: 0,
|
||||||
|
interactive: false,
|
||||||
|
}).addTo(groundTrackLayer);
|
||||||
|
|
||||||
|
L.circleMarker(validPoints[validPoints.length - 1], {
|
||||||
|
radius: 4.5,
|
||||||
|
color: '#ff5e5e',
|
||||||
|
fillColor: '#ff5e5e',
|
||||||
|
fillOpacity: 0.95,
|
||||||
|
weight: 0,
|
||||||
|
interactive: false,
|
||||||
|
}).addTo(groundTrackLayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
let obsLat;
|
||||||
|
let obsLon;
|
||||||
|
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||||
|
const shared = ObserverLocation.getShared();
|
||||||
|
obsLat = shared?.lat;
|
||||||
|
obsLon = shared?.lon;
|
||||||
|
} else {
|
||||||
|
obsLat = parseFloat(localStorage.getItem('observerLat'));
|
||||||
|
obsLon = parseFloat(localStorage.getItem('observerLon'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFinite(obsLat) && isFinite(obsLon)) {
|
||||||
|
observerMarker = L.circleMarker([obsLat, obsLon], {
|
||||||
|
radius: 5.5,
|
||||||
|
color: '#ffd45b',
|
||||||
|
fillColor: '#ffd45b',
|
||||||
|
fillOpacity: 0.8,
|
||||||
|
weight: 1,
|
||||||
|
className: 'wxsat-observer-marker',
|
||||||
|
interactive: false,
|
||||||
|
}).addTo(groundTrackLayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSatelliteCrosshair(pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedPass() {
|
||||||
|
if (selectedPassIndex < 0 || selectedPassIndex >= passes.length) return null;
|
||||||
|
return passes[selectedPassIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSatellitePositionForPass(pass, atTime = new Date()) {
|
||||||
|
const track = pass?.groundTrack;
|
||||||
|
if (!Array.isArray(track) || track.length === 0) return null;
|
||||||
|
|
||||||
|
const first = track[0];
|
||||||
|
if (track.length === 1) {
|
||||||
|
const lat = Number(first.lat);
|
||||||
|
const lon = Number(first.lon);
|
||||||
|
if (!isFinite(lat) || !isFinite(lon)) return null;
|
||||||
|
return { lat, lon };
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = parsePassDate(pass.startTimeISO);
|
||||||
|
const end = parsePassDate(pass.endTimeISO);
|
||||||
|
|
||||||
|
let fraction = 0;
|
||||||
|
if (start && end && end > start) {
|
||||||
|
const totalMs = end.getTime() - start.getTime();
|
||||||
|
const elapsedMs = atTime.getTime() - start.getTime();
|
||||||
|
fraction = Math.max(0, Math.min(1, elapsedMs / totalMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastIndex = track.length - 1;
|
||||||
|
const idxFloat = fraction * lastIndex;
|
||||||
|
const idx0 = Math.floor(idxFloat);
|
||||||
|
const idx1 = Math.min(lastIndex, idx0 + 1);
|
||||||
|
const t = idxFloat - idx0;
|
||||||
|
|
||||||
|
const p0 = track[idx0];
|
||||||
|
const p1 = track[idx1];
|
||||||
|
const lat0 = Number(p0?.lat);
|
||||||
|
const lon0 = Number(p0?.lon);
|
||||||
|
const lat1 = Number(p1?.lat);
|
||||||
|
const lon1 = Number(p1?.lon);
|
||||||
|
|
||||||
|
if (!isFinite(lat0) || !isFinite(lon0) || !isFinite(lat1) || !isFinite(lon1)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
lat: lat0 + ((lat1 - lat0) * t),
|
||||||
|
lon: lon0 + ((lon1 - lon0) * t),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSatelliteCrosshair(pass) {
|
||||||
|
if (!groundMap || !groundOverlayLayer || typeof L === 'undefined') return;
|
||||||
|
|
||||||
|
if (!pass) {
|
||||||
|
clearSatelliteCrosshair();
|
||||||
|
updateProjectionInfo('--');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = getSatellitePositionForPass(pass);
|
||||||
|
if (!position) {
|
||||||
|
clearSatelliteCrosshair();
|
||||||
|
updateProjectionInfo(`${pass.name || pass.satellite || '--'} --`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latlng = [position.lat, normalizeLon(position.lon)];
|
||||||
|
if (!satCrosshairMarker) {
|
||||||
|
satCrosshairMarker = L.marker(latlng, {
|
||||||
|
icon: createSatelliteCrosshairIcon(),
|
||||||
|
interactive: false,
|
||||||
|
keyboard: false,
|
||||||
|
zIndexOffset: 900,
|
||||||
|
}).addTo(groundOverlayLayer);
|
||||||
|
} else {
|
||||||
|
satCrosshairMarker.setLatLng(latlng);
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoText =
|
||||||
|
`${pass.name || pass.satellite || 'Satellite'} ` +
|
||||||
|
`${position.lat.toFixed(2)}°, ${normalizeLon(position.lon).toFixed(2)}°`;
|
||||||
|
updateProjectionInfo(infoText);
|
||||||
|
|
||||||
|
if (!satCrosshairMarker.getTooltip()) {
|
||||||
|
satCrosshairMarker.bindTooltip(infoText, {
|
||||||
|
direction: 'top',
|
||||||
|
offset: [0, -12],
|
||||||
|
opacity: 0.92,
|
||||||
|
className: 'wxsat-map-tooltip',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
satCrosshairMarker.setTooltipContent(infoText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ========================
|
// ========================
|
||||||
// Countdown
|
// Countdown
|
||||||
@@ -811,8 +1143,11 @@ const WeatherSat = (function() {
|
|||||||
let isActive = false;
|
let isActive = false;
|
||||||
|
|
||||||
for (const pass of passes) {
|
for (const pass of passes) {
|
||||||
const start = new Date(pass.startTimeISO);
|
const start = parsePassDate(pass.startTimeISO);
|
||||||
const end = new Date(pass.endTimeISO);
|
const end = parsePassDate(pass.endTimeISO);
|
||||||
|
if (!start || !end) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (end > now) {
|
if (end > now) {
|
||||||
nextPass = pass;
|
nextPass = pass;
|
||||||
isActive = start <= now;
|
isActive = start <= now;
|
||||||
@@ -841,7 +1176,19 @@ const WeatherSat = (function() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = new Date(nextPass.startTimeISO);
|
const target = parsePassDate(nextPass.startTimeISO);
|
||||||
|
if (!target) {
|
||||||
|
if (daysEl) daysEl.textContent = '--';
|
||||||
|
if (hoursEl) hoursEl.textContent = '--';
|
||||||
|
if (minsEl) minsEl.textContent = '--';
|
||||||
|
if (secsEl) secsEl.textContent = '--';
|
||||||
|
if (satEl) satEl.textContent = '--';
|
||||||
|
if (detailEl) detailEl.textContent = 'Invalid pass time';
|
||||||
|
if (boxes) boxes.querySelectorAll('.wxsat-countdown-box').forEach(b => {
|
||||||
|
b.classList.remove('imminent', 'active');
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
let diffMs = target - now;
|
let diffMs = target - now;
|
||||||
|
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
@@ -875,7 +1222,12 @@ const WeatherSat = (function() {
|
|||||||
b.classList.toggle('active', isActive);
|
b.classList.toggle('active', isActive);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Keep timeline cursor in sync
|
||||||
|
updateTimelineCursor();
|
||||||
|
// Keep selected satellite marker synchronized with time progression.
|
||||||
|
updateSatelliteCrosshair(getSelectedPass());
|
||||||
|
}
|
||||||
|
|
||||||
// ========================
|
// ========================
|
||||||
// Timeline
|
// Timeline
|
||||||
@@ -898,8 +1250,9 @@ const WeatherSat = (function() {
|
|||||||
const dayMs = 24 * 60 * 60 * 1000;
|
const dayMs = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
passList.forEach((pass, idx) => {
|
passList.forEach((pass, idx) => {
|
||||||
const start = new Date(pass.startTimeISO);
|
const start = parsePassDate(pass.startTimeISO);
|
||||||
const end = new Date(pass.endTimeISO);
|
const end = parsePassDate(pass.endTimeISO);
|
||||||
|
if (!start || !end) return;
|
||||||
|
|
||||||
const startPct = Math.max(0, Math.min(100, ((start - dayStart) / dayMs) * 100));
|
const startPct = Math.max(0, Math.min(100, ((start - dayStart) / dayMs) * 100));
|
||||||
const endPct = Math.max(0, Math.min(100, ((end - dayStart) / dayMs) * 100));
|
const endPct = Math.max(0, Math.min(100, ((end - dayStart) / dayMs) * 100));
|
||||||
@@ -939,12 +1292,13 @@ const WeatherSat = (function() {
|
|||||||
/**
|
/**
|
||||||
* Toggle auto-scheduler
|
* Toggle auto-scheduler
|
||||||
*/
|
*/
|
||||||
async function toggleScheduler() {
|
async function toggleScheduler(source) {
|
||||||
|
const checked = source?.checked ?? false;
|
||||||
|
|
||||||
const stripCheckbox = document.getElementById('wxsatAutoSchedule');
|
const stripCheckbox = document.getElementById('wxsatAutoSchedule');
|
||||||
const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule');
|
const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule');
|
||||||
const checked = stripCheckbox?.checked || sidebarCheckbox?.checked;
|
|
||||||
|
|
||||||
// Sync both checkboxes
|
// Sync both checkboxes to the source of truth
|
||||||
if (stripCheckbox) stripCheckbox.checked = checked;
|
if (stripCheckbox) stripCheckbox.checked = checked;
|
||||||
if (sidebarCheckbox) sidebarCheckbox.checked = checked;
|
if (sidebarCheckbox) sidebarCheckbox.checked = checked;
|
||||||
|
|
||||||
@@ -958,7 +1312,7 @@ const WeatherSat = (function() {
|
|||||||
/**
|
/**
|
||||||
* Enable auto-scheduler
|
* Enable auto-scheduler
|
||||||
*/
|
*/
|
||||||
async function enableScheduler() {
|
async function enableScheduler() {
|
||||||
let lat, lon;
|
let lat, lon;
|
||||||
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||||
const shared = ObserverLocation.getShared();
|
const shared = ObserverLocation.getShared();
|
||||||
@@ -982,41 +1336,60 @@ const WeatherSat = (function() {
|
|||||||
const gainInput = document.getElementById('weatherSatGain');
|
const gainInput = document.getElementById('weatherSatGain');
|
||||||
const biasTInput = document.getElementById('weatherSatBiasT');
|
const biasTInput = document.getElementById('weatherSatBiasT');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/weather-sat/schedule/enable', {
|
const response = await fetch('/weather-sat/schedule/enable', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
latitude: lat,
|
latitude: lat,
|
||||||
longitude: lon,
|
longitude: lon,
|
||||||
device: parseInt(deviceSelect?.value || '0', 10),
|
device: parseInt(deviceSelect?.value || '0', 10),
|
||||||
gain: parseFloat(gainInput?.value || '40'),
|
gain: parseFloat(gainInput?.value || '40'),
|
||||||
bias_t: biasTInput?.checked || false,
|
bias_t: biasTInput?.checked || false,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
let data = {};
|
||||||
schedulerEnabled = true;
|
try {
|
||||||
updateSchedulerUI(data);
|
data = await response.json();
|
||||||
startStream();
|
} catch (err) {
|
||||||
showNotification('Weather Sat', `Auto-scheduler enabled (${data.scheduled_count || 0} passes)`);
|
data = {};
|
||||||
} catch (err) {
|
}
|
||||||
console.error('Failed to enable scheduler:', err);
|
|
||||||
showNotification('Weather Sat', 'Failed to enable auto-scheduler');
|
if (!response.ok || !data || data.enabled !== true) {
|
||||||
}
|
schedulerEnabled = false;
|
||||||
}
|
updateSchedulerUI({ enabled: false, scheduled_count: 0 });
|
||||||
|
showNotification('Weather Sat', data.message || 'Failed to enable auto-scheduler');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
schedulerEnabled = true;
|
||||||
|
updateSchedulerUI(data);
|
||||||
|
startStream();
|
||||||
|
showNotification('Weather Sat', `Auto-scheduler enabled (${data.scheduled_count || 0} passes)`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to enable scheduler:', err);
|
||||||
|
schedulerEnabled = false;
|
||||||
|
updateSchedulerUI({ enabled: false, scheduled_count: 0 });
|
||||||
|
showNotification('Weather Sat', 'Failed to enable auto-scheduler');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disable auto-scheduler
|
* Disable auto-scheduler
|
||||||
*/
|
*/
|
||||||
async function disableScheduler() {
|
async function disableScheduler() {
|
||||||
try {
|
try {
|
||||||
await fetch('/weather-sat/schedule/disable', { method: 'POST' });
|
const response = await fetch('/weather-sat/schedule/disable', { method: 'POST' });
|
||||||
schedulerEnabled = false;
|
if (!response.ok) {
|
||||||
updateSchedulerUI({ enabled: false });
|
showNotification('Weather Sat', 'Failed to disable auto-scheduler');
|
||||||
if (!isRunning) stopStream();
|
return;
|
||||||
showNotification('Weather Sat', 'Auto-scheduler disabled');
|
}
|
||||||
} catch (err) {
|
schedulerEnabled = false;
|
||||||
|
updateSchedulerUI({ enabled: false });
|
||||||
|
if (!isRunning) stopStream();
|
||||||
|
showNotification('Weather Sat', 'Auto-scheduler disabled');
|
||||||
|
} catch (err) {
|
||||||
console.error('Failed to disable scheduler:', err);
|
console.error('Failed to disable scheduler:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1024,14 +1397,15 @@ const WeatherSat = (function() {
|
|||||||
/**
|
/**
|
||||||
* Check current scheduler status
|
* Check current scheduler status
|
||||||
*/
|
*/
|
||||||
async function checkSchedulerStatus() {
|
async function checkSchedulerStatus() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/weather-sat/schedule/status');
|
const response = await fetch('/weather-sat/schedule/status');
|
||||||
const data = await response.json();
|
if (!response.ok) return;
|
||||||
schedulerEnabled = data.enabled;
|
const data = await response.json();
|
||||||
updateSchedulerUI(data);
|
schedulerEnabled = data.enabled;
|
||||||
if (schedulerEnabled) startStream();
|
updateSchedulerUI(data);
|
||||||
} catch (err) {
|
if (schedulerEnabled) startStream();
|
||||||
|
} catch (err) {
|
||||||
// Scheduler endpoint may not exist yet
|
// Scheduler endpoint may not exist yet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1275,14 +1649,19 @@ const WeatherSat = (function() {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalidate ground map size (call after container becomes visible)
|
* Invalidate ground map size (call after container becomes visible)
|
||||||
*/
|
*/
|
||||||
function invalidateMap() {
|
function invalidateMap() {
|
||||||
if (groundMap) {
|
setTimeout(() => {
|
||||||
setTimeout(() => groundMap.invalidateSize(), 100);
|
if (!groundMap) {
|
||||||
}
|
initGroundMap();
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
groundMap.invalidateSize(false);
|
||||||
|
updateGroundTrack(getSelectedPass());
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
// ========================
|
// ========================
|
||||||
// Decoder Console
|
// Decoder Console
|
||||||
@@ -1386,9 +1765,29 @@ const WeatherSat = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspend background activity when leaving the mode.
|
||||||
|
* Closes the SSE stream and stops the countdown interval so they don't
|
||||||
|
* keep running while another mode is active. The stream is re-opened
|
||||||
|
* by init() or startStream() when the mode is next entered.
|
||||||
|
*/
|
||||||
|
function suspend() {
|
||||||
|
if (countdownInterval) {
|
||||||
|
clearInterval(countdownInterval);
|
||||||
|
countdownInterval = null;
|
||||||
|
}
|
||||||
|
// Only close the stream if nothing is actively capturing/scheduling —
|
||||||
|
// if a capture or scheduler is running we want it to continue on the
|
||||||
|
// server and the stream will reconnect on next init().
|
||||||
|
if (!isRunning && !schedulerEnabled) {
|
||||||
|
stopStream();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Public API
|
// Public API
|
||||||
return {
|
return {
|
||||||
init,
|
init,
|
||||||
|
suspend,
|
||||||
start,
|
start,
|
||||||
stop,
|
stop,
|
||||||
startPass,
|
startPass,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const KIWI_SAMPLE_RATE = 12000;
|
|||||||
|
|
||||||
// ============== INITIALIZATION ==============
|
// ============== INITIALIZATION ==============
|
||||||
|
|
||||||
function initWebSDR() {
|
async function initWebSDR() {
|
||||||
if (websdrInitialized) {
|
if (websdrInitialized) {
|
||||||
if (websdrMap) {
|
if (websdrMap) {
|
||||||
setTimeout(() => websdrMap.invalidateSize(), 100);
|
setTimeout(() => websdrMap.invalidateSize(), 100);
|
||||||
@@ -51,11 +51,18 @@ function initWebSDR() {
|
|||||||
maxBoundsViscosity: 1.0,
|
maxBoundsViscosity: 1.0,
|
||||||
});
|
});
|
||||||
|
|
||||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
|
||||||
attribution: '© OpenStreetMap contributors © CARTO',
|
await Settings.init();
|
||||||
subdomains: 'abcd',
|
Settings.createTileLayer().addTo(websdrMap);
|
||||||
maxZoom: 19,
|
Settings.registerMap(websdrMap);
|
||||||
}).addTo(websdrMap);
|
} else {
|
||||||
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||||
|
attribution: '© OpenStreetMap contributors © CARTO',
|
||||||
|
subdomains: 'abcd',
|
||||||
|
maxZoom: 19,
|
||||||
|
className: 'tile-layer-cyan',
|
||||||
|
}).addTo(websdrMap);
|
||||||
|
}
|
||||||
|
|
||||||
// Match background to tile ocean color so any remaining edge is seamless
|
// Match background to tile ocean color so any remaining edge is seamless
|
||||||
mapEl.style.background = '#1a1d29';
|
mapEl.style.background = '#1a1d29';
|
||||||
|
|||||||
+268
-162
@@ -120,10 +120,23 @@ const WiFiMode = (function() {
|
|||||||
let channelStats = [];
|
let channelStats = [];
|
||||||
let recommendations = [];
|
let recommendations = [];
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
let selectedNetwork = null;
|
let selectedNetwork = null;
|
||||||
let currentFilter = 'all';
|
let currentFilter = 'all';
|
||||||
let currentSort = { field: 'rssi', order: 'desc' };
|
let currentSort = { field: 'rssi', order: 'desc' };
|
||||||
|
let renderFramePending = false;
|
||||||
|
const pendingRender = {
|
||||||
|
table: false,
|
||||||
|
stats: false,
|
||||||
|
radar: false,
|
||||||
|
chart: false,
|
||||||
|
detail: false,
|
||||||
|
};
|
||||||
|
const listenersBound = {
|
||||||
|
scanTabs: false,
|
||||||
|
filters: false,
|
||||||
|
sort: false,
|
||||||
|
};
|
||||||
|
|
||||||
// Agent state
|
// Agent state
|
||||||
let showAllAgentsMode = false; // Show combined results from all agents
|
let showAllAgentsMode = false; // Show combined results from all agents
|
||||||
@@ -152,10 +165,11 @@ const WiFiMode = (function() {
|
|||||||
|
|
||||||
// Initialize components
|
// Initialize components
|
||||||
initScanModeTabs();
|
initScanModeTabs();
|
||||||
initNetworkFilters();
|
initNetworkFilters();
|
||||||
initSortControls();
|
initSortControls();
|
||||||
initProximityRadar();
|
initProximityRadar();
|
||||||
initChannelChart();
|
initChannelChart();
|
||||||
|
scheduleRender({ table: true, stats: true, radar: true, chart: true });
|
||||||
|
|
||||||
// Check if already scanning
|
// Check if already scanning
|
||||||
checkScanStatus();
|
checkScanStatus();
|
||||||
@@ -364,14 +378,16 @@ const WiFiMode = (function() {
|
|||||||
// Scan Mode Tabs
|
// Scan Mode Tabs
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
function initScanModeTabs() {
|
function initScanModeTabs() {
|
||||||
if (elements.scanModeQuick) {
|
if (listenersBound.scanTabs) return;
|
||||||
elements.scanModeQuick.addEventListener('click', () => setScanMode('quick'));
|
if (elements.scanModeQuick) {
|
||||||
}
|
elements.scanModeQuick.addEventListener('click', () => setScanMode('quick'));
|
||||||
if (elements.scanModeDeep) {
|
}
|
||||||
elements.scanModeDeep.addEventListener('click', () => setScanMode('deep'));
|
if (elements.scanModeDeep) {
|
||||||
}
|
elements.scanModeDeep.addEventListener('click', () => setScanMode('deep'));
|
||||||
}
|
}
|
||||||
|
listenersBound.scanTabs = true;
|
||||||
|
}
|
||||||
|
|
||||||
function setScanMode(mode) {
|
function setScanMode(mode) {
|
||||||
scanMode = mode;
|
scanMode = mode;
|
||||||
@@ -682,10 +698,10 @@ const WiFiMode = (function() {
|
|||||||
}, CONFIG.pollInterval);
|
}, CONFIG.pollInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
function processQuickScanResult(result) {
|
function processQuickScanResult(result) {
|
||||||
// Update networks
|
// Update networks
|
||||||
result.access_points.forEach(ap => {
|
result.access_points.forEach(ap => {
|
||||||
networks.set(ap.bssid, ap);
|
networks.set(ap.bssid, ap);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update channel stats (calculate from networks if not provided by API)
|
// Update channel stats (calculate from networks if not provided by API)
|
||||||
@@ -693,15 +709,12 @@ const WiFiMode = (function() {
|
|||||||
recommendations = result.recommendations || [];
|
recommendations = result.recommendations || [];
|
||||||
|
|
||||||
// If no channel stats from API, calculate from networks
|
// If no channel stats from API, calculate from networks
|
||||||
if (channelStats.length === 0 && networks.size > 0) {
|
if (channelStats.length === 0 && networks.size > 0) {
|
||||||
channelStats = calculateChannelStats();
|
channelStats = calculateChannelStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update UI
|
// Update UI
|
||||||
updateNetworkTable();
|
scheduleRender({ table: true, stats: true, radar: true, chart: true });
|
||||||
updateStats();
|
|
||||||
updateProximityRadar();
|
|
||||||
updateChannelChart();
|
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
result.access_points.forEach(ap => {
|
result.access_points.forEach(ap => {
|
||||||
@@ -910,22 +923,25 @@ const WiFiMode = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNetworkUpdate(network) {
|
function handleNetworkUpdate(network) {
|
||||||
networks.set(network.bssid, network);
|
networks.set(network.bssid, network);
|
||||||
updateNetworkRow(network);
|
scheduleRender({
|
||||||
updateStats();
|
table: true,
|
||||||
updateProximityRadar();
|
stats: true,
|
||||||
updateChannelChart();
|
radar: true,
|
||||||
|
chart: true,
|
||||||
if (onNetworkUpdate) onNetworkUpdate(network);
|
detail: selectedNetwork === network.bssid,
|
||||||
}
|
});
|
||||||
|
|
||||||
function handleClientUpdate(client) {
|
if (onNetworkUpdate) onNetworkUpdate(network);
|
||||||
clients.set(client.mac, client);
|
}
|
||||||
updateStats();
|
|
||||||
|
function handleClientUpdate(client) {
|
||||||
// Update client display if this client belongs to the selected network
|
clients.set(client.mac, client);
|
||||||
updateClientInList(client);
|
scheduleRender({ stats: true });
|
||||||
|
|
||||||
|
// Update client display if this client belongs to the selected network
|
||||||
|
updateClientInList(client);
|
||||||
|
|
||||||
if (onClientUpdate) onClientUpdate(client);
|
if (onClientUpdate) onClientUpdate(client);
|
||||||
}
|
}
|
||||||
@@ -939,32 +955,37 @@ const WiFiMode = (function() {
|
|||||||
if (onProbeRequest) onProbeRequest(probe);
|
if (onProbeRequest) onProbeRequest(probe);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleHiddenRevealed(bssid, revealedSsid) {
|
function handleHiddenRevealed(bssid, revealedSsid) {
|
||||||
const network = networks.get(bssid);
|
const network = networks.get(bssid);
|
||||||
if (network) {
|
if (network) {
|
||||||
network.revealed_essid = revealedSsid;
|
network.revealed_essid = revealedSsid;
|
||||||
network.display_name = `${revealedSsid} (revealed)`;
|
network.display_name = `${revealedSsid} (revealed)`;
|
||||||
updateNetworkRow(network);
|
scheduleRender({
|
||||||
|
table: true,
|
||||||
// Show notification
|
detail: selectedNetwork === bssid,
|
||||||
showInfo(`Hidden SSID revealed: ${revealedSsid}`);
|
});
|
||||||
}
|
|
||||||
}
|
// Show notification
|
||||||
|
showInfo(`Hidden SSID revealed: ${revealedSsid}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Network Table
|
// Network Table
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
function initNetworkFilters() {
|
function initNetworkFilters() {
|
||||||
if (!elements.networkFilters) return;
|
if (listenersBound.filters) return;
|
||||||
|
if (!elements.networkFilters) return;
|
||||||
elements.networkFilters.addEventListener('click', (e) => {
|
|
||||||
if (e.target.matches('.wifi-filter-btn')) {
|
elements.networkFilters.addEventListener('click', (e) => {
|
||||||
const filter = e.target.dataset.filter;
|
if (e.target.matches('.wifi-filter-btn')) {
|
||||||
setNetworkFilter(filter);
|
const filter = e.target.dataset.filter;
|
||||||
}
|
setNetworkFilter(filter);
|
||||||
});
|
}
|
||||||
}
|
});
|
||||||
|
listenersBound.filters = true;
|
||||||
|
}
|
||||||
|
|
||||||
function setNetworkFilter(filter) {
|
function setNetworkFilter(filter) {
|
||||||
currentFilter = filter;
|
currentFilter = filter;
|
||||||
@@ -979,10 +1000,11 @@ const WiFiMode = (function() {
|
|||||||
updateNetworkTable();
|
updateNetworkTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
function initSortControls() {
|
function initSortControls() {
|
||||||
if (!elements.networkTable) return;
|
if (listenersBound.sort) return;
|
||||||
|
if (!elements.networkTable) return;
|
||||||
elements.networkTable.addEventListener('click', (e) => {
|
|
||||||
|
elements.networkTable.addEventListener('click', (e) => {
|
||||||
const th = e.target.closest('th[data-sort]');
|
const th = e.target.closest('th[data-sort]');
|
||||||
if (th) {
|
if (th) {
|
||||||
const field = th.dataset.sort;
|
const field = th.dataset.sort;
|
||||||
@@ -992,16 +1014,54 @@ const WiFiMode = (function() {
|
|||||||
currentSort.field = field;
|
currentSort.field = field;
|
||||||
currentSort.order = 'desc';
|
currentSort.order = 'desc';
|
||||||
}
|
}
|
||||||
updateNetworkTable();
|
updateNetworkTable();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
if (elements.networkTableBody) {
|
||||||
function updateNetworkTable() {
|
elements.networkTableBody.addEventListener('click', (e) => {
|
||||||
if (!elements.networkTableBody) return;
|
const row = e.target.closest('tr[data-bssid]');
|
||||||
|
if (!row) return;
|
||||||
// Filter networks
|
selectNetwork(row.dataset.bssid);
|
||||||
let filtered = Array.from(networks.values());
|
});
|
||||||
|
}
|
||||||
|
listenersBound.sort = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRender(flags = {}) {
|
||||||
|
pendingRender.table = pendingRender.table || Boolean(flags.table);
|
||||||
|
pendingRender.stats = pendingRender.stats || Boolean(flags.stats);
|
||||||
|
pendingRender.radar = pendingRender.radar || Boolean(flags.radar);
|
||||||
|
pendingRender.chart = pendingRender.chart || Boolean(flags.chart);
|
||||||
|
pendingRender.detail = pendingRender.detail || Boolean(flags.detail);
|
||||||
|
|
||||||
|
if (renderFramePending) return;
|
||||||
|
renderFramePending = true;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
renderFramePending = false;
|
||||||
|
|
||||||
|
if (pendingRender.table) updateNetworkTable();
|
||||||
|
if (pendingRender.stats) updateStats();
|
||||||
|
if (pendingRender.radar) updateProximityRadar();
|
||||||
|
if (pendingRender.chart) updateChannelChart();
|
||||||
|
if (pendingRender.detail && selectedNetwork) {
|
||||||
|
updateDetailPanel(selectedNetwork, { refreshClients: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingRender.table = false;
|
||||||
|
pendingRender.stats = false;
|
||||||
|
pendingRender.radar = false;
|
||||||
|
pendingRender.chart = false;
|
||||||
|
pendingRender.detail = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNetworkTable() {
|
||||||
|
if (!elements.networkTableBody) return;
|
||||||
|
|
||||||
|
// Filter networks
|
||||||
|
let filtered = Array.from(networks.values());
|
||||||
|
|
||||||
switch (currentFilter) {
|
switch (currentFilter) {
|
||||||
case 'hidden':
|
case 'hidden':
|
||||||
@@ -1051,22 +1111,44 @@ const WiFiMode = (function() {
|
|||||||
return bVal > aVal ? 1 : bVal < aVal ? -1 : 0;
|
return bVal > aVal ? 1 : bVal < aVal ? -1 : 0;
|
||||||
} else {
|
} else {
|
||||||
return aVal > bVal ? 1 : aVal < bVal ? -1 : 0;
|
return aVal > bVal ? 1 : aVal < bVal ? -1 : 0;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
let message = 'Start scanning to discover networks';
|
||||||
|
let type = 'empty';
|
||||||
|
if (isScanning) {
|
||||||
|
message = 'Scanning for networks...';
|
||||||
|
type = 'loading';
|
||||||
|
} else if (networks.size > 0) {
|
||||||
|
message = 'No networks match current filters';
|
||||||
|
}
|
||||||
|
if (typeof renderCollectionState === 'function') {
|
||||||
|
renderCollectionState(elements.networkTableBody, {
|
||||||
|
type,
|
||||||
|
message,
|
||||||
|
columns: 7,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
elements.networkTableBody.innerHTML = `<tr class="wifi-network-placeholder"><td colspan="7"><div class="placeholder-text">${escapeHtml(message)}</div></td></tr>`;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render table
|
||||||
|
elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
// Render table
|
function createNetworkRow(network) {
|
||||||
elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join('');
|
const rssi = network.rssi_current;
|
||||||
}
|
const security = network.security || 'Unknown';
|
||||||
|
const signalClass = rssi >= -50 ? 'signal-strong' :
|
||||||
function createNetworkRow(network) {
|
rssi >= -70 ? 'signal-medium' :
|
||||||
const rssi = network.rssi_current;
|
rssi >= -85 ? 'signal-weak' : 'signal-very-weak';
|
||||||
const signalClass = rssi >= -50 ? 'signal-strong' :
|
|
||||||
rssi >= -70 ? 'signal-medium' :
|
const securityClass = security === 'Open' ? 'security-open' :
|
||||||
rssi >= -85 ? 'signal-weak' : 'signal-very-weak';
|
security === 'WEP' ? 'security-wep' :
|
||||||
|
security.includes('WPA3') ? 'security-wpa3' : 'security-wpa';
|
||||||
const securityClass = network.security === 'Open' ? 'security-open' :
|
|
||||||
network.security === 'WEP' ? 'security-wep' :
|
|
||||||
network.security.includes('WPA3') ? 'security-wpa3' : 'security-wpa';
|
|
||||||
|
|
||||||
const hiddenBadge = network.is_hidden ? '<span class="badge badge-hidden">Hidden</span>' : '';
|
const hiddenBadge = network.is_hidden ? '<span class="badge badge-hidden">Hidden</span>' : '';
|
||||||
const newBadge = network.is_new ? '<span class="badge badge-new">New</span>' : '';
|
const newBadge = network.is_new ? '<span class="badge badge-new">New</span>' : '';
|
||||||
@@ -1075,22 +1157,25 @@ const WiFiMode = (function() {
|
|||||||
const agentName = network._agent || 'Local';
|
const agentName = network._agent || 'Local';
|
||||||
const agentClass = agentName === 'Local' ? 'agent-local' : 'agent-remote';
|
const agentClass = agentName === 'Local' ? 'agent-local' : 'agent-remote';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr class="wifi-network-row ${network.bssid === selectedNetwork ? 'selected' : ''}"
|
<tr class="wifi-network-row ${network.bssid === selectedNetwork ? 'selected' : ''}"
|
||||||
data-bssid="${escapeHtml(network.bssid)}"
|
data-bssid="${escapeHtml(network.bssid)}"
|
||||||
onclick="WiFiMode.selectNetwork('${escapeHtml(network.bssid)}')">
|
role="button"
|
||||||
<td class="col-essid">
|
tabindex="0"
|
||||||
<span class="essid">${escapeHtml(network.display_name || network.essid || '[Hidden]')}</span>
|
data-keyboard-activate="true"
|
||||||
${hiddenBadge}${newBadge}
|
aria-label="Select network ${escapeHtml(network.display_name || network.essid || '[Hidden]')}">
|
||||||
</td>
|
<td class="col-essid">
|
||||||
|
<span class="essid">${escapeHtml(network.display_name || network.essid || '[Hidden]')}</span>
|
||||||
|
${hiddenBadge}${newBadge}
|
||||||
|
</td>
|
||||||
<td class="col-bssid"><code>${escapeHtml(network.bssid)}</code></td>
|
<td class="col-bssid"><code>${escapeHtml(network.bssid)}</code></td>
|
||||||
<td class="col-channel">${network.channel || '-'}</td>
|
<td class="col-channel">${network.channel || '-'}</td>
|
||||||
<td class="col-rssi">
|
<td class="col-rssi">
|
||||||
<span class="rssi-value ${signalClass}">${rssi !== null ? rssi : '-'}</span>
|
<span class="rssi-value ${signalClass}">${rssi != null ? rssi : '-'}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-security">
|
<td class="col-security">
|
||||||
<span class="security-badge ${securityClass}">${escapeHtml(network.security)}</span>
|
<span class="security-badge ${securityClass}">${escapeHtml(security)}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-clients">${network.client_count || 0}</td>
|
<td class="col-clients">${network.client_count || 0}</td>
|
||||||
<td class="col-agent">
|
<td class="col-agent">
|
||||||
<span class="agent-badge ${agentClass}">${escapeHtml(agentName)}</span>
|
<span class="agent-badge ${agentClass}">${escapeHtml(agentName)}</span>
|
||||||
@@ -1099,15 +1184,12 @@ const WiFiMode = (function() {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateNetworkRow(network) {
|
function updateNetworkRow(network) {
|
||||||
const row = elements.networkTableBody?.querySelector(`tr[data-bssid="${network.bssid}"]`);
|
scheduleRender({
|
||||||
if (row) {
|
table: true,
|
||||||
row.outerHTML = createNetworkRow(network);
|
detail: selectedNetwork === network.bssid,
|
||||||
} else {
|
});
|
||||||
// Add new row
|
}
|
||||||
updateNetworkTable();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectNetwork(bssid) {
|
function selectNetwork(bssid) {
|
||||||
selectedNetwork = bssid;
|
selectedNetwork = bssid;
|
||||||
@@ -1130,8 +1212,9 @@ const WiFiMode = (function() {
|
|||||||
// Detail Panel
|
// Detail Panel
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
function updateDetailPanel(bssid) {
|
function updateDetailPanel(bssid, options = {}) {
|
||||||
if (!elements.detailDrawer) return;
|
const { refreshClients = true } = options;
|
||||||
|
if (!elements.detailDrawer) return;
|
||||||
|
|
||||||
const network = networks.get(bssid);
|
const network = networks.get(bssid);
|
||||||
if (!network) {
|
if (!network) {
|
||||||
@@ -1176,9 +1259,11 @@ const WiFiMode = (function() {
|
|||||||
// Show the drawer
|
// Show the drawer
|
||||||
elements.detailDrawer.classList.add('open');
|
elements.detailDrawer.classList.add('open');
|
||||||
|
|
||||||
// Fetch and display clients for this network
|
// Fetch and display clients for this network
|
||||||
fetchClientsForNetwork(network.bssid);
|
if (refreshClients) {
|
||||||
}
|
fetchClientsForNetwork(network.bssid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function closeDetail() {
|
function closeDetail() {
|
||||||
selectedNetwork = null;
|
selectedNetwork = null;
|
||||||
@@ -1194,12 +1279,18 @@ const WiFiMode = (function() {
|
|||||||
// Client Display
|
// Client Display
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
async function fetchClientsForNetwork(bssid) {
|
async function fetchClientsForNetwork(bssid) {
|
||||||
if (!elements.detailClientList) return;
|
if (!elements.detailClientList) return;
|
||||||
|
const listContainer = elements.detailClientList.querySelector('.wifi-client-list');
|
||||||
try {
|
|
||||||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
if (listContainer && typeof renderCollectionState === 'function') {
|
||||||
let response;
|
renderCollectionState(listContainer, { type: 'loading', message: 'Loading clients...' });
|
||||||
|
elements.detailClientList.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||||||
|
let response;
|
||||||
|
|
||||||
if (isAgentMode) {
|
if (isAgentMode) {
|
||||||
// Route through agent proxy
|
// Route through agent proxy
|
||||||
@@ -1208,28 +1299,44 @@ const WiFiMode = (function() {
|
|||||||
response = await fetch(`${CONFIG.apiBase}/clients?bssid=${encodeURIComponent(bssid)}&associated=true`);
|
response = await fetch(`${CONFIG.apiBase}/clients?bssid=${encodeURIComponent(bssid)}&associated=true`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// Hide client list on error
|
if (listContainer && typeof renderCollectionState === 'function') {
|
||||||
elements.detailClientList.style.display = 'none';
|
renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' });
|
||||||
return;
|
elements.detailClientList.style.display = 'block';
|
||||||
}
|
} else {
|
||||||
|
elements.detailClientList.style.display = 'none';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
// Handle agent response format (may be nested in 'result')
|
// Handle agent response format (may be nested in 'result')
|
||||||
const result = isAgentMode && data.result ? data.result : data;
|
const result = isAgentMode && data.result ? data.result : data;
|
||||||
const clientList = result.clients || [];
|
const clientList = result.clients || [];
|
||||||
|
|
||||||
if (clientList.length > 0) {
|
if (clientList.length > 0) {
|
||||||
renderClientList(clientList, bssid);
|
renderClientList(clientList, bssid);
|
||||||
elements.detailClientList.style.display = 'block';
|
elements.detailClientList.style.display = 'block';
|
||||||
} else {
|
} else {
|
||||||
elements.detailClientList.style.display = 'none';
|
const countBadge = document.getElementById('wifiClientCountBadge');
|
||||||
}
|
if (countBadge) countBadge.textContent = '0';
|
||||||
} catch (error) {
|
if (listContainer && typeof renderCollectionState === 'function') {
|
||||||
console.debug('[WiFiMode] Error fetching clients:', error);
|
renderCollectionState(listContainer, { type: 'empty', message: 'No associated clients' });
|
||||||
elements.detailClientList.style.display = 'none';
|
elements.detailClientList.style.display = 'block';
|
||||||
}
|
} else {
|
||||||
}
|
elements.detailClientList.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('[WiFiMode] Error fetching clients:', error);
|
||||||
|
if (listContainer && typeof renderCollectionState === 'function') {
|
||||||
|
renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' });
|
||||||
|
elements.detailClientList.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
elements.detailClientList.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderClientList(clientList, bssid) {
|
function renderClientList(clientList, bssid) {
|
||||||
const container = elements.detailClientList?.querySelector('.wifi-client-list');
|
const container = elements.detailClientList?.querySelector('.wifi-client-list');
|
||||||
@@ -1586,17 +1693,16 @@ const WiFiMode = (function() {
|
|||||||
/**
|
/**
|
||||||
* Clear all collected data.
|
* Clear all collected data.
|
||||||
*/
|
*/
|
||||||
function clearData() {
|
function clearData() {
|
||||||
networks.clear();
|
networks.clear();
|
||||||
clients.clear();
|
clients.clear();
|
||||||
probeRequests = [];
|
probeRequests = [];
|
||||||
channelStats = [];
|
channelStats = [];
|
||||||
recommendations = [];
|
recommendations = [];
|
||||||
|
if (selectedNetwork) {
|
||||||
updateNetworkTable();
|
closeDetail();
|
||||||
updateStats();
|
}
|
||||||
updateProximityRadar();
|
scheduleRender({ table: true, stats: true, radar: true, chart: true });
|
||||||
updateChannelChart();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1642,12 +1748,12 @@ const WiFiMode = (function() {
|
|||||||
clientsToRemove.push(mac);
|
clientsToRemove.push(mac);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
clientsToRemove.forEach(mac => clients.delete(mac));
|
clientsToRemove.forEach(mac => clients.delete(mac));
|
||||||
|
if (selectedNetwork && !networks.has(selectedNetwork)) {
|
||||||
updateNetworkTable();
|
closeDetail();
|
||||||
updateStats();
|
}
|
||||||
updateProximityRadar();
|
scheduleRender({ table: true, stats: true, radar: true, chart: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh WiFi interfaces from current agent.
|
* Refresh WiFi interfaces from current agent.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_dashboard.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_dashboard.css') }}">
|
||||||
<script>
|
<script>
|
||||||
@@ -599,7 +599,7 @@
|
|||||||
if (saved) {
|
if (saved) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(saved);
|
const parsed = JSON.parse(saved);
|
||||||
if (parsed.lat && parsed.lon) return parsed;
|
if (parsed.lat !== undefined && parsed.lat !== null && parsed.lon !== undefined && parsed.lon !== null) return parsed;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
return { lat: 51.5074, lon: -0.1278 };
|
return { lat: 51.5074, lon: -0.1278 };
|
||||||
@@ -985,7 +985,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Distance calculation
|
// Distance calculation
|
||||||
if (ac.lat && ac.lon) {
|
if (ac.lat !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) {
|
||||||
const distance = calculateDistanceNm(
|
const distance = calculateDistanceNm(
|
||||||
observerLocation.lat, observerLocation.lon,
|
observerLocation.lat, observerLocation.lon,
|
||||||
ac.lat, ac.lon
|
ac.lat, ac.lon
|
||||||
@@ -1037,7 +1037,7 @@
|
|||||||
fastest = ac.speed;
|
fastest = ac.speed;
|
||||||
fastestIcao = icao;
|
fastestIcao = icao;
|
||||||
}
|
}
|
||||||
if (ac.lat && ac.lon) {
|
if (ac.lat !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) {
|
||||||
const dist = calculateDistanceNm(
|
const dist = calculateDistanceNm(
|
||||||
observerLocation.lat, observerLocation.lon,
|
observerLocation.lat, observerLocation.lon,
|
||||||
ac.lat, ac.lon
|
ac.lat, ac.lon
|
||||||
@@ -1345,21 +1345,41 @@ ACARS: ${r.statistics.acarsMessages} messages`;
|
|||||||
}
|
}
|
||||||
trailLines[icao] = [];
|
trailLines[icao] = [];
|
||||||
|
|
||||||
// Create gradient segments
|
// Group consecutive same-altitude-color points into single polylines.
|
||||||
|
// This reduces layer count from O(trail length) to O(color band changes),
|
||||||
|
// which is typically 1-2 polylines per aircraft instead of up to 99.
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (let i = 1; i < trail.length; i++) {
|
let runColor = getAltitudeColor(trail[0].alt);
|
||||||
const p1 = trail[i-1];
|
let runPoints = [[trail[0].lat, trail[0].lon]];
|
||||||
const p2 = trail[i];
|
let runEndTime = trail[0].time;
|
||||||
const age = (now - p2.time) / 1000; // seconds
|
|
||||||
const opacity = Math.max(0.2, 1 - (age / 120)); // Fade over 2 minutes
|
|
||||||
|
|
||||||
const color = getAltitudeColor(p2.alt);
|
for (let i = 1; i < trail.length; i++) {
|
||||||
const line = L.polyline([[p1.lat, p1.lon], [p2.lat, p2.lon]], {
|
const p = trail[i];
|
||||||
color: color,
|
const color = getAltitudeColor(p.alt);
|
||||||
weight: 2,
|
|
||||||
opacity: opacity
|
if (color !== runColor) {
|
||||||
}).addTo(radarMap);
|
// Flush the current color run as one polyline
|
||||||
trailLines[icao].push(line);
|
if (runPoints.length >= 2) {
|
||||||
|
const opacity = Math.max(0.2, 1 - ((now - runEndTime) / 1000 / 120));
|
||||||
|
trailLines[icao].push(
|
||||||
|
L.polyline(runPoints, { color: runColor, weight: 2, opacity }).addTo(radarMap)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Start a new run, sharing the junction point for visual continuity
|
||||||
|
runColor = color;
|
||||||
|
runPoints = [[trail[i-1].lat, trail[i-1].lon], [p.lat, p.lon]];
|
||||||
|
} else {
|
||||||
|
runPoints.push([p.lat, p.lon]);
|
||||||
|
}
|
||||||
|
runEndTime = p.time;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush the final run
|
||||||
|
if (runPoints.length >= 2) {
|
||||||
|
const opacity = Math.max(0.2, 1 - ((now - runEndTime) / 1000 / 120));
|
||||||
|
trailLines[icao].push(
|
||||||
|
L.polyline(runPoints, { color: runColor, weight: 2, opacity }).addTo(radarMap)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1397,19 +1417,19 @@ ACARS: ${r.statistics.acarsMessages} messages`;
|
|||||||
const meters = nm * 1852;
|
const meters = nm * 1852;
|
||||||
const circle = L.circle([observerLocation.lat, observerLocation.lon], {
|
const circle = L.circle([observerLocation.lat, observerLocation.lon], {
|
||||||
radius: meters,
|
radius: meters,
|
||||||
color: '#00ff88',
|
color: '#4a9eff',
|
||||||
fillColor: 'transparent',
|
fillColor: 'transparent',
|
||||||
fillOpacity: 0,
|
fillOpacity: 0,
|
||||||
weight: 1,
|
weight: 1,
|
||||||
opacity: 0.4,
|
opacity: 0.3,
|
||||||
dashArray: '5, 5'
|
dashArray: '4 4'
|
||||||
});
|
});
|
||||||
|
|
||||||
const labelLat = observerLocation.lat + (nm * 0.0166);
|
const labelLat = observerLocation.lat + (nm * 0.0166);
|
||||||
const label = L.marker([labelLat, observerLocation.lon], {
|
const label = L.marker([labelLat, observerLocation.lon], {
|
||||||
icon: L.divIcon({
|
icon: L.divIcon({
|
||||||
className: 'range-label',
|
className: 'range-label',
|
||||||
html: `<span style="color: #00ff88; font-size: 10px; background: rgba(0,0,0,0.7); padding: 1px 4px; border-radius: 2px;">${Math.round(nm)} nm</span>`,
|
html: `<span style="color: #4a9eff; font-size: 10px; background: rgba(0,0,0,0.7); padding: 1px 4px; border-radius: 2px;">${Math.round(nm)} nm</span>`,
|
||||||
iconSize: [40, 12],
|
iconSize: [40, 12],
|
||||||
iconAnchor: [20, 6]
|
iconAnchor: [20, 6]
|
||||||
})
|
})
|
||||||
@@ -1535,7 +1555,7 @@ ACARS: ${r.statistics.acarsMessages} messages`;
|
|||||||
gpsEventSource.onmessage = (event) => {
|
gpsEventSource.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
if (data.type === 'position' && data.latitude && data.longitude) {
|
if (data.type === 'position' && data.latitude !== undefined && data.latitude !== null && data.longitude !== undefined && data.longitude !== null) {
|
||||||
updateLocationFromGps(data);
|
updateLocationFromGps(data);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1607,7 +1627,7 @@ ACARS: ${r.statistics.acarsMessages} messages`;
|
|||||||
Object.keys(markerState).forEach(icao => delete markerState[icao]);
|
Object.keys(markerState).forEach(icao => delete markerState[icao]);
|
||||||
pendingMarkerUpdates.clear();
|
pendingMarkerUpdates.clear();
|
||||||
Object.keys(aircraft).forEach(icao => {
|
Object.keys(aircraft).forEach(icao => {
|
||||||
if (aircraft[icao].lat && aircraft[icao].lon) {
|
if (aircraft[icao].lat !== undefined && aircraft[icao].lat !== null && aircraft[icao].lon !== undefined && aircraft[icao].lon !== null) {
|
||||||
pendingMarkerUpdates.add(icao);
|
pendingMarkerUpdates.add(icao);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2536,7 +2556,7 @@ sudo make install</code>
|
|||||||
updateStatistics(icao, aircraft[icao]);
|
updateStatistics(icao, aircraft[icao]);
|
||||||
|
|
||||||
// Record trail point
|
// Record trail point
|
||||||
if (data.lat && data.lon) {
|
if (data.lat !== undefined && data.lat !== null && data.lon !== undefined && data.lon !== null) {
|
||||||
recordTrailPoint(icao, data.lat, data.lon, data.altitude);
|
recordTrailPoint(icao, data.lat, data.lon, data.altitude);
|
||||||
if (showTrails) {
|
if (showTrails) {
|
||||||
updateTrailLine(icao);
|
updateTrailLine(icao);
|
||||||
@@ -2551,7 +2571,7 @@ sudo make install</code>
|
|||||||
|
|
||||||
function updateMarkerImmediate(icao) {
|
function updateMarkerImmediate(icao) {
|
||||||
const ac = aircraft[icao];
|
const ac = aircraft[icao];
|
||||||
if (!ac || !ac.lat || !ac.lon) return;
|
if (!ac || ac.lat === undefined || ac.lat === null || ac.lon === undefined || ac.lon === null) return;
|
||||||
|
|
||||||
if (!passesFilter(icao, ac)) {
|
if (!passesFilter(icao, ac)) {
|
||||||
if (markers[icao]) {
|
if (markers[icao]) {
|
||||||
@@ -2788,7 +2808,7 @@ sudo make install</code>
|
|||||||
updateFlightLookupBtn();
|
updateFlightLookupBtn();
|
||||||
|
|
||||||
const ac = aircraft[icao];
|
const ac = aircraft[icao];
|
||||||
if (ac && ac.lat && ac.lon) {
|
if (ac && ac.lat !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) {
|
||||||
radarMap.setView([ac.lat, ac.lon], 10);
|
radarMap.setView([ac.lat, ac.lon], 10);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3859,36 +3879,37 @@ sudo make install</code>
|
|||||||
function startVdl2Stream(isAgentMode = false) {
|
function startVdl2Stream(isAgentMode = false) {
|
||||||
if (vdl2EventSource) vdl2EventSource.close();
|
if (vdl2EventSource) vdl2EventSource.close();
|
||||||
|
|
||||||
// Use different stream endpoint for agent mode
|
// For remote agent mode, stream directly from the selected agent via controller proxy.
|
||||||
const streamUrl = isAgentMode ? '/controller/stream/all' : '/vdl2/stream';
|
// This does not depend on push ingestion being enabled.
|
||||||
|
const streamUrl = isAgentMode && vdl2CurrentAgent !== null
|
||||||
|
? `/controller/agents/${vdl2CurrentAgent}/vdl2/stream`
|
||||||
|
: '/vdl2/stream';
|
||||||
vdl2EventSource = new EventSource(streamUrl);
|
vdl2EventSource = new EventSource(streamUrl);
|
||||||
|
|
||||||
vdl2EventSource.onmessage = function(e) {
|
vdl2EventSource.onmessage = function(e) {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
|
let message = null;
|
||||||
|
|
||||||
if (isAgentMode) {
|
// Backward compatibility: handle wrapped multi-agent payloads if encountered.
|
||||||
// Handle multi-agent stream format
|
if (data.scan_type === 'vdl2' && data.payload && data.payload.type === 'vdl2') {
|
||||||
if (data.scan_type === 'vdl2' && data.payload) {
|
message = data.payload;
|
||||||
const payload = data.payload;
|
if (isAgentMode) {
|
||||||
if (payload.type === 'vdl2') {
|
message.agent_name = data.agent_name || message.agent_name || 'Remote Agent';
|
||||||
vdl2MessageCount++;
|
|
||||||
if (typeof stats !== 'undefined') stats.vdl2Messages = (stats.vdl2Messages || 0) + 1;
|
|
||||||
document.getElementById('vdl2Count').textContent = vdl2MessageCount;
|
|
||||||
document.getElementById('stripVdl2').textContent = vdl2MessageCount;
|
|
||||||
payload.agent_name = data.agent_name;
|
|
||||||
addVdl2Message(payload);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else if (data.type === 'vdl2') {
|
||||||
// Local stream format
|
message = data;
|
||||||
if (data.type === 'vdl2') {
|
if (isAgentMode && !message.agent_name) {
|
||||||
vdl2MessageCount++;
|
message.agent_name = 'Remote Agent';
|
||||||
if (typeof stats !== 'undefined') stats.vdl2Messages = (stats.vdl2Messages || 0) + 1;
|
|
||||||
document.getElementById('vdl2Count').textContent = vdl2MessageCount;
|
|
||||||
document.getElementById('stripVdl2').textContent = vdl2MessageCount;
|
|
||||||
addVdl2Message(data);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
vdl2MessageCount++;
|
||||||
|
if (typeof stats !== 'undefined') stats.vdl2Messages = (stats.vdl2Messages || 0) + 1;
|
||||||
|
document.getElementById('vdl2Count').textContent = vdl2MessageCount;
|
||||||
|
document.getElementById('stripVdl2').textContent = vdl2MessageCount;
|
||||||
|
addVdl2Message(message);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
vdl2EventSource.onerror = function() {
|
vdl2EventSource.onerror = function() {
|
||||||
@@ -3899,52 +3920,6 @@ sudo make install</code>
|
|||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start polling fallback for agent mode
|
|
||||||
if (isAgentMode) {
|
|
||||||
startVdl2Polling();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track last VDL2 message count for polling
|
|
||||||
let lastVdl2MessageCount = 0;
|
|
||||||
|
|
||||||
function startVdl2Polling() {
|
|
||||||
if (vdl2PollTimer) return;
|
|
||||||
lastVdl2MessageCount = 0;
|
|
||||||
|
|
||||||
const pollInterval = 2000;
|
|
||||||
vdl2PollTimer = setInterval(async () => {
|
|
||||||
if (!isVdl2Running || !vdl2CurrentAgent) {
|
|
||||||
clearInterval(vdl2PollTimer);
|
|
||||||
vdl2PollTimer = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/controller/agents/${vdl2CurrentAgent}/vdl2/data`);
|
|
||||||
if (!response.ok) return;
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const result = data.result || data;
|
|
||||||
const messages = result.data || [];
|
|
||||||
|
|
||||||
if (messages.length > lastVdl2MessageCount) {
|
|
||||||
const newMessages = messages.slice(lastVdl2MessageCount);
|
|
||||||
newMessages.forEach(msg => {
|
|
||||||
vdl2MessageCount++;
|
|
||||||
if (typeof stats !== 'undefined') stats.vdl2Messages = (stats.vdl2Messages || 0) + 1;
|
|
||||||
document.getElementById('vdl2Count').textContent = vdl2MessageCount;
|
|
||||||
document.getElementById('stripVdl2').textContent = vdl2MessageCount;
|
|
||||||
msg.agent_name = result.agent_name || 'Remote Agent';
|
|
||||||
addVdl2Message(msg);
|
|
||||||
});
|
|
||||||
lastVdl2MessageCount = messages.length;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('VDL2 polling error:', err);
|
|
||||||
}
|
|
||||||
}, pollInterval);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
@@ -4907,7 +4882,7 @@ sudo make install</code>
|
|||||||
<!-- Help Modal -->
|
<!-- Help Modal -->
|
||||||
{% include 'partials/help-modal.html' %}
|
{% include 'partials/help-modal.html' %}
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
|
||||||
|
|
||||||
<!-- Agent Manager -->
|
<!-- Agent Manager -->
|
||||||
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
|
||||||
@@ -5149,7 +5124,7 @@ sudo make install</code>
|
|||||||
const gps = typeof data.agent.gps_coords === 'string'
|
const gps = typeof data.agent.gps_coords === 'string'
|
||||||
? JSON.parse(data.agent.gps_coords)
|
? JSON.parse(data.agent.gps_coords)
|
||||||
: data.agent.gps_coords;
|
: data.agent.gps_coords;
|
||||||
if (gps.lat && gps.lon) {
|
if (gps.lat !== undefined && gps.lat !== null && gps.lon !== undefined && gps.lon !== null) {
|
||||||
document.getElementById('obsLat').value = gps.lat.toFixed(4);
|
document.getElementById('obsLat').value = gps.lat.toFixed(4);
|
||||||
document.getElementById('obsLon').value = gps.lon.toFixed(4);
|
document.getElementById('obsLon').value = gps.lon.toFixed(4);
|
||||||
updateObserverLoc();
|
updateObserverLoc();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_history.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_history.css') }}">
|
||||||
</head>
|
</head>
|
||||||
@@ -778,7 +778,7 @@
|
|||||||
<!-- Help Modal -->
|
<!-- Help Modal -->
|
||||||
{% include 'partials/help-modal.html' %}
|
{% include 'partials/help-modal.html' %}
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
|
||||||
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/agents.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/agents.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
|
||||||
<style>
|
<style>
|
||||||
.agents-container {
|
.agents-container {
|
||||||
@@ -562,7 +562,7 @@
|
|||||||
<!-- Help Modal -->
|
<!-- Help Modal -->
|
||||||
{% include 'partials/help-modal.html' %}
|
{% include 'partials/help-modal.html' %}
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
|
||||||
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
|
||||||
<script>
|
<script>
|
||||||
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
|
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
|
||||||
@@ -750,7 +750,7 @@
|
|||||||
stats.fastestSpeed = data.speed;
|
stats.fastestSpeed = data.speed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.lat && data.lon) {
|
if (data.lat !== undefined && data.lat !== null && data.lon !== undefined && data.lon !== null) {
|
||||||
const dist = calculateDistance(observerLocation.lat, observerLocation.lon, data.lat, data.lon);
|
const dist = calculateDistance(observerLocation.lat, observerLocation.lon, data.lat, data.lon);
|
||||||
if (dist > stats.maxRange) stats.maxRange = dist;
|
if (dist > stats.maxRange) stats.maxRange = dist;
|
||||||
if (dist < stats.closestDistance) stats.closestDistance = dist;
|
if (dist < stats.closestDistance) stats.closestDistance = dist;
|
||||||
@@ -1019,7 +1019,7 @@
|
|||||||
gpsEventSource.onmessage = (event) => {
|
gpsEventSource.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
if (data.type === 'position' && data.latitude && data.longitude) {
|
if (data.type === 'position' && data.latitude !== undefined && data.latitude !== null && data.longitude !== undefined && data.longitude !== null) {
|
||||||
updateLocationFromGps(data);
|
updateLocationFromGps(data);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1345,7 +1345,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add position marker if coordinates present
|
// Add position marker if coordinates present
|
||||||
if (data.latitude && data.longitude) {
|
if (data.latitude !== undefined && data.latitude !== null && data.longitude !== undefined && data.longitude !== null) {
|
||||||
addDscPositionMarker(data);
|
addDscPositionMarker(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1562,7 +1562,7 @@
|
|||||||
<!-- Help Modal -->
|
<!-- Help Modal -->
|
||||||
{% include 'partials/help-modal.html' %}
|
{% include 'partials/help-modal.html' %}
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
|
||||||
|
|
||||||
<!-- Agent Manager -->
|
<!-- Agent Manager -->
|
||||||
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
|
||||||
@@ -1600,7 +1600,7 @@
|
|||||||
const gps = typeof data.agent.gps_coords === 'string'
|
const gps = typeof data.agent.gps_coords === 'string'
|
||||||
? JSON.parse(data.agent.gps_coords)
|
? JSON.parse(data.agent.gps_coords)
|
||||||
: data.agent.gps_coords;
|
: data.agent.gps_coords;
|
||||||
if (gps.lat && gps.lon) {
|
if (gps.lat !== undefined && gps.lat !== null && gps.lon !== undefined && gps.lon !== null) {
|
||||||
document.getElementById('obsLat').value = gps.lat.toFixed(4);
|
document.getElementById('obsLat').value = gps.lat.toFixed(4);
|
||||||
document.getElementById('obsLon').value = gps.lon.toFixed(4);
|
document.getElementById('obsLon').value = gps.lon.toFixed(4);
|
||||||
updateObserverLoc();
|
updateObserverLoc();
|
||||||
|
|||||||
+370
-1026
File diff suppressed because it is too large
Load Diff
@@ -133,7 +133,17 @@
|
|||||||
const currentTheme = html.getAttribute('data-theme') || 'dark';
|
const currentTheme = html.getAttribute('data-theme') || 'dark';
|
||||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||||
html.setAttribute('data-theme', newTheme);
|
html.setAttribute('data-theme', newTheme);
|
||||||
localStorage.setItem('theme', newTheme);
|
localStorage.setItem('intercept-theme', newTheme);
|
||||||
|
|
||||||
|
const btn = document.getElementById('themeToggle');
|
||||||
|
if (btn) btn.textContent = newTheme === 'light' ? '🌙' : '☀️';
|
||||||
|
|
||||||
|
// Persist to server
|
||||||
|
fetch('/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ theme: newTheme })
|
||||||
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply saved theme
|
// Apply saved theme
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/agents.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/agents.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
@@ -787,7 +787,7 @@
|
|||||||
|
|
||||||
entry.rssiByAgent.forEach((info, agentName) => {
|
entry.rssiByAgent.forEach((info, agentName) => {
|
||||||
const gps = agentGPS.get(agentName);
|
const gps = agentGPS.get(agentName);
|
||||||
if (gps && gps.lat && gps.lon) {
|
if (gps && gps.lat !== undefined && gps.lat !== null && gps.lon !== undefined && gps.lon !== null) {
|
||||||
observations.push({
|
observations.push({
|
||||||
agent_name: agentName,
|
agent_name: agentName,
|
||||||
agent_lat: gps.lat,
|
agent_lat: gps.lat,
|
||||||
@@ -1073,7 +1073,7 @@
|
|||||||
const coords = agent.gps_coords;
|
const coords = agent.gps_coords;
|
||||||
const lat = coords.lat || coords.latitude;
|
const lat = coords.lat || coords.latitude;
|
||||||
const lon = coords.lon || coords.longitude;
|
const lon = coords.lon || coords.longitude;
|
||||||
if (lat && lon) {
|
if (lat !== undefined && lat !== null && lon !== undefined && lon !== null) {
|
||||||
agentGPS.set(agent.name, { lat, lon });
|
agentGPS.set(agent.name, { lat, lon });
|
||||||
addLogEntry('gps', `Agent "${agent.name}" GPS: ${lat.toFixed(4)}, ${lon.toFixed(4)}`);
|
addLogEntry('gps', `Agent "${agent.name}" GPS: ${lat.toFixed(4)}, ${lon.toFixed(4)}`);
|
||||||
}
|
}
|
||||||
@@ -1117,7 +1117,7 @@
|
|||||||
<!-- Help Modal -->
|
<!-- Help Modal -->
|
||||||
{% include 'partials/help-modal.html' %}
|
{% include 'partials/help-modal.html' %}
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
|
||||||
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,20 +4,20 @@
|
|||||||
#}
|
#}
|
||||||
|
|
||||||
<!-- Help Modal -->
|
<!-- Help Modal -->
|
||||||
<div id="helpModal" class="help-modal" onclick="if(event.target === this) hideHelp()">
|
<div id="helpModal" class="help-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="helpModalTitle" onclick="if(event.target === this) hideHelp()">
|
||||||
<div class="help-content">
|
<div class="help-content" tabindex="-1">
|
||||||
<button class="help-close" onclick="hideHelp()">×</button>
|
<button type="button" class="help-close" onclick="hideHelp()" aria-label="Close help">×</button>
|
||||||
<h2>iNTERCEPT Help</h2>
|
<h2 id="helpModalTitle">iNTERCEPT Help</h2>
|
||||||
|
|
||||||
<div class="help-tabs">
|
<div class="help-tabs" role="tablist" aria-label="Help sections">
|
||||||
<button class="help-tab active" data-tab="icons" onclick="switchHelpTab('icons')">Icons</button>
|
<button type="button" class="help-tab active" data-tab="icons" onclick="switchHelpTab('icons')" role="tab" aria-controls="help-icons" aria-selected="true">Icons</button>
|
||||||
<button class="help-tab" data-tab="modes" onclick="switchHelpTab('modes')">Modes</button>
|
<button type="button" class="help-tab" data-tab="modes" onclick="switchHelpTab('modes')" role="tab" aria-controls="help-modes" aria-selected="false">Modes</button>
|
||||||
<button class="help-tab" data-tab="wifi" onclick="switchHelpTab('wifi')">WiFi</button>
|
<button type="button" class="help-tab" data-tab="wifi" onclick="switchHelpTab('wifi')" role="tab" aria-controls="help-wifi" aria-selected="false">WiFi</button>
|
||||||
<button class="help-tab" data-tab="tips" onclick="switchHelpTab('tips')">Tips</button>
|
<button type="button" class="help-tab" data-tab="tips" onclick="switchHelpTab('tips')" role="tab" aria-controls="help-tips" aria-selected="false">Tips</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Icons Section -->
|
<!-- Icons Section -->
|
||||||
<div id="help-icons" class="help-section active">
|
<div id="help-icons" class="help-section active" role="tabpanel">
|
||||||
<h3>Stats Bar Icons</h3>
|
<h3>Stats Bar Icons</h3>
|
||||||
<div class="icon-grid">
|
<div class="icon-grid">
|
||||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span><span class="desc">POCSAG messages decoded</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span><span class="desc">POCSAG messages decoded</span></div>
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modes Section -->
|
<!-- Modes Section -->
|
||||||
<div id="help-modes" class="help-section">
|
<div id="help-modes" class="help-section" role="tabpanel" hidden>
|
||||||
<h3>Pager Mode</h3>
|
<h3>Pager Mode</h3>
|
||||||
<ul class="tip-list">
|
<ul class="tip-list">
|
||||||
<li>Decodes POCSAG and FLEX pager signals using RTL-SDR</li>
|
<li>Decodes POCSAG and FLEX pager signals using RTL-SDR</li>
|
||||||
@@ -254,7 +254,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- WiFi Section -->
|
<!-- WiFi Section -->
|
||||||
<div id="help-wifi" class="help-section">
|
<div id="help-wifi" class="help-section" role="tabpanel" hidden>
|
||||||
<h3>Monitor Mode</h3>
|
<h3>Monitor Mode</h3>
|
||||||
<ul class="tip-list">
|
<ul class="tip-list">
|
||||||
<li><strong>Enable Monitor:</strong> Puts WiFi adapter in monitor mode for passive scanning</li>
|
<li><strong>Enable Monitor:</strong> Puts WiFi adapter in monitor mode for passive scanning</li>
|
||||||
@@ -302,7 +302,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tips Section -->
|
<!-- Tips Section -->
|
||||||
<div id="help-tips" class="help-section">
|
<div id="help-tips" class="help-section" role="tabpanel" hidden>
|
||||||
<h3>General Tips</h3>
|
<h3>General Tips</h3>
|
||||||
<ul class="tip-list">
|
<ul class="tip-list">
|
||||||
<li><strong>Collapsible sections:</strong> Click any section header (∇) to collapse/expand</li>
|
<li><strong>Collapsible sections:</strong> Click any section header (∇) to collapse/expand</li>
|
||||||
@@ -360,41 +360,62 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Help modal functions - defined here so all pages have them
|
// Help modal functions - defined here so all pages have them
|
||||||
(function() {
|
(function() {
|
||||||
// Only define if not already defined (index.html defines its own)
|
let lastHelpFocusEl = null;
|
||||||
if (typeof window.showHelp === 'undefined') {
|
|
||||||
window.showHelp = function() {
|
// Only define if not already defined (index.html defines its own)
|
||||||
document.getElementById('helpModal').classList.add('active');
|
if (typeof window.showHelp === 'undefined') {
|
||||||
document.body.style.overflow = 'hidden';
|
window.showHelp = function() {
|
||||||
};
|
const modal = document.getElementById('helpModal');
|
||||||
}
|
lastHelpFocusEl = document.activeElement;
|
||||||
|
modal.classList.add('active');
|
||||||
if (typeof window.hideHelp === 'undefined') {
|
modal.setAttribute('aria-hidden', 'false');
|
||||||
window.hideHelp = function() {
|
const content = modal.querySelector('.help-content');
|
||||||
document.getElementById('helpModal').classList.remove('active');
|
if (content) content.focus();
|
||||||
document.body.style.overflow = '';
|
document.body.style.overflow = 'hidden';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window.switchHelpTab === 'undefined') {
|
if (typeof window.hideHelp === 'undefined') {
|
||||||
window.switchHelpTab = function(tab) {
|
window.hideHelp = function() {
|
||||||
document.querySelectorAll('.help-tab').forEach(t => t.classList.remove('active'));
|
const modal = document.getElementById('helpModal');
|
||||||
document.querySelectorAll('.help-section').forEach(s => s.classList.remove('active'));
|
modal.classList.remove('active');
|
||||||
document.querySelector('.help-tab[data-tab="' + tab + '"]').classList.add('active');
|
modal.setAttribute('aria-hidden', 'true');
|
||||||
document.getElementById('help-' + tab).classList.add('active');
|
document.body.style.overflow = '';
|
||||||
};
|
if (lastHelpFocusEl && typeof lastHelpFocusEl.focus === 'function') {
|
||||||
}
|
lastHelpFocusEl.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window.switchHelpTab === 'undefined') {
|
||||||
|
window.switchHelpTab = function(tab) {
|
||||||
|
document.querySelectorAll('.help-tab').forEach(t => {
|
||||||
|
const isActive = t.dataset.tab === tab;
|
||||||
|
t.classList.toggle('active', isActive);
|
||||||
|
t.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.help-section').forEach(s => {
|
||||||
|
const isActive = s.id === ('help-' + tab);
|
||||||
|
s.classList.toggle('active', isActive);
|
||||||
|
s.hidden = !isActive;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Keyboard shortcuts for help (only add once)
|
// Keyboard shortcuts for help (only add once)
|
||||||
if (!window._helpKeyboardSetup) {
|
if (!window._helpKeyboardSetup) {
|
||||||
window._helpKeyboardSetup = true;
|
window._helpKeyboardSetup = true;
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Escape') hideHelp();
|
if (e.key === 'Escape') {
|
||||||
// Open help with F1 or ? key (when not typing in an input)
|
const modal = document.getElementById('helpModal');
|
||||||
var helpModal = document.getElementById('helpModal');
|
if (modal && modal.classList.contains('active')) hideHelp();
|
||||||
if (helpModal && (e.key === 'F1' || (e.key === '?' && !e.target.matches('input, textarea, select'))) && !helpModal.classList.contains('active')) {
|
}
|
||||||
e.preventDefault();
|
// Open help with F1 or ? key (when not typing in an input)
|
||||||
showHelp();
|
var helpModal = document.getElementById('helpModal');
|
||||||
|
if (helpModal && (e.key === 'F1' || (e.key === '?' && !e.target.matches('input, textarea, select'))) && !helpModal.classList.contains('active')) {
|
||||||
|
e.preventDefault();
|
||||||
|
showHelp();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
<div id="analyticsMode" class="mode-content">
|
<div id="analyticsMode" class="mode-content">
|
||||||
{# Analytics Dashboard Sidebar Panel #}
|
{# Analytics Dashboard Sidebar Panel #}
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||||
<span>Summary</span>
|
<span>Summary</span>
|
||||||
<span class="collapse-icon">▼</span>
|
<span class="collapse-icon">▼</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="section-content">
|
<div class="section-content">
|
||||||
<div class="analytics-grid" id="analyticsSummaryCards">
|
<div class="analytics-grid" id="analyticsSummaryCards">
|
||||||
@@ -55,13 +55,31 @@
|
|||||||
<div class="card-sparkline" id="analyticsSparkMesh"></div>
|
<div class="card-sparkline" id="analyticsSparkMesh"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||||
<span>Mode Health</span>
|
<span>Operational Insights</span>
|
||||||
<span class="collapse-icon">▼</span>
|
<span class="collapse-icon">▼</span>
|
||||||
|
</h3>
|
||||||
|
<div class="section-content">
|
||||||
|
<div class="analytics-insight-grid" id="analyticsInsights">
|
||||||
|
<div class="analytics-empty">Insights loading...</div>
|
||||||
|
</div>
|
||||||
|
<div class="analytics-top-changes">
|
||||||
|
<div class="analytics-section-header">Top Changes</div>
|
||||||
|
<div id="analyticsTopChanges">
|
||||||
|
<div class="analytics-empty">No change signals yet</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||||
|
<span>Mode Health</span>
|
||||||
|
<span class="collapse-icon">▼</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="section-content">
|
<div class="section-content">
|
||||||
<div class="analytics-health" id="analyticsHealth"></div>
|
<div class="analytics-health" id="analyticsHealth"></div>
|
||||||
@@ -77,13 +95,25 @@
|
|||||||
<div class="squawk-emergency" id="analyticsSquawkPanel">
|
<div class="squawk-emergency" id="analyticsSquawkPanel">
|
||||||
<div class="squawk-title">Active Emergency Codes</div>
|
<div class="squawk-title">Active Emergency Codes</div>
|
||||||
<div id="analyticsSquawkList"></div>
|
<div id="analyticsSquawkList"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||||
<span>Recent Alerts</span>
|
<span>Temporal Patterns</span>
|
||||||
|
<span class="collapse-icon">▼</span>
|
||||||
|
</h3>
|
||||||
|
<div class="section-content">
|
||||||
|
<div id="analyticsPatternList">
|
||||||
|
<div class="analytics-empty">No recurring patterns detected</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||||
|
<span>Recent Alerts</span>
|
||||||
<span class="collapse-icon">▼</span>
|
<span class="collapse-icon">▼</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="section-content">
|
<div class="section-content">
|
||||||
@@ -105,23 +135,60 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||||
<span>Geofences</span>
|
<span>Geofences</span>
|
||||||
<span class="collapse-icon">▼</span>
|
<span class="collapse-icon">▼</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="section-content">
|
<div class="section-content">
|
||||||
<div id="analyticsGeofenceList"></div>
|
<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;">
|
<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
|
+ Add Zone
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||||
<span>Export Data</span>
|
<span>Target View</span>
|
||||||
<span class="collapse-icon">▼</span>
|
<span class="collapse-icon">▼</span>
|
||||||
|
</h3>
|
||||||
|
<div class="section-content">
|
||||||
|
<div class="analytics-target-toolbar">
|
||||||
|
<input id="analyticsTargetQuery" type="text" placeholder="Search callsign, ICAO, MMSI, MAC, SSID, node..." onkeydown="if(event.key==='Enter'){Analytics.searchTarget();}">
|
||||||
|
<button onclick="Analytics.searchTarget()">Search</button>
|
||||||
|
</div>
|
||||||
|
<div id="analyticsTargetSummary" class="analytics-target-summary">Search to correlate entities across modes</div>
|
||||||
|
<div id="analyticsTargetResults">
|
||||||
|
<div class="analytics-empty">No target selected</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||||
|
<span>Session Replay</span>
|
||||||
|
<span class="collapse-icon">▼</span>
|
||||||
|
</h3>
|
||||||
|
<div class="section-content">
|
||||||
|
<div class="analytics-replay-toolbar">
|
||||||
|
<select id="analyticsReplaySelect"></select>
|
||||||
|
<button onclick="Analytics.loadReplay()">Load</button>
|
||||||
|
<button onclick="Analytics.playReplay()">Play</button>
|
||||||
|
<button onclick="Analytics.pauseReplay()">Pause</button>
|
||||||
|
<button onclick="Analytics.stepReplay()">Step</button>
|
||||||
|
</div>
|
||||||
|
<div id="analyticsReplayMeta" class="analytics-target-summary">No replay loaded</div>
|
||||||
|
<div id="analyticsReplayTimeline">
|
||||||
|
<div class="analytics-empty">Select a recording to replay key events</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||||
|
<span>Export Data</span>
|
||||||
|
<span class="collapse-icon">▼</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="section-content">
|
<div class="section-content">
|
||||||
<div class="export-controls">
|
<div class="export-controls">
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
|
|
||||||
<!-- Message Container for status cards -->
|
<!-- Message Container for status cards -->
|
||||||
<div id="btMessageContainer"></div>
|
<div id="btMessageContainer"></div>
|
||||||
|
<div id="btBaselineStatus" style="margin-top: 6px; font-size: 10px; color: var(--text-dim);">No baseline</div>
|
||||||
|
|
||||||
<button class="run-btn" id="startBtBtn" onclick="btStartScan()">
|
<button class="run-btn" id="startBtBtn" onclick="btStartScan()">
|
||||||
Start Scanning
|
Start Scanning
|
||||||
|
|||||||
@@ -43,7 +43,8 @@
|
|||||||
<option value="50.950|usb">50.950 MHz USB - SSTV calling</option>
|
<option value="50.950|usb">50.950 MHz USB - SSTV calling</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="2 m (VHF)">
|
<optgroup label="2 m (VHF)">
|
||||||
<option value="145.625|fm">145.625 MHz FM - Simplex</option>
|
<option value="145.500|fm">145.500 MHz FM - Simplex</option>
|
||||||
|
<option value="145.880|fm">145.880 MHz FM - SONATE-2 (Martin M1)</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="70 cm (UHF)">
|
<optgroup label="70 cm (UHF)">
|
||||||
<option value="433.775|fm">433.775 MHz FM - Simplex</option>
|
<option value="433.775|fm">433.775 MHz FM - Simplex</option>
|
||||||
@@ -57,9 +58,9 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Modulation</label>
|
<label>Modulation</label>
|
||||||
<select id="sstvGeneralModulation" style="width: 100%; padding: 6px 8px; font-family: var(--font-mono); font-size: 11px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary);">
|
<select id="sstvGeneralModulation" style="width: 100%; padding: 6px 8px; font-family: var(--font-mono); font-size: 11px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary);">
|
||||||
|
<option value="fm" selected>FM (Frequency Modulation)</option>
|
||||||
<option value="usb">USB (Upper Sideband)</option>
|
<option value="usb">USB (Upper Sideband)</option>
|
||||||
<option value="lsb">LSB (Lower Sideband)</option>
|
<option value="lsb">LSB (Lower Sideband)</option>
|
||||||
<option value="fm">FM (Frequency Modulation)</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<!-- WEATHER SATELLITE MODE -->
|
<!-- WEATHER SATELLITE MODE -->
|
||||||
<div id="weatherSatMode" class="mode-content">
|
<div id="weatherSatMode" class="mode-content">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3>Weather Satellite Decoder</h3>
|
<h3>Weather Satellite Decoder</h3>
|
||||||
<div class="alpha-mode-notice">
|
<div class="alpha-mode-notice">
|
||||||
ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions.
|
ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions.
|
||||||
</div>
|
</div>
|
||||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
||||||
Receive and decode weather images from NOAA and Meteor satellites.
|
Receive and decode weather images from NOAA and Meteor satellites.
|
||||||
Uses SatDump for live SDR capture and image processing.
|
Uses SatDump for live SDR capture and image processing.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3>Satellite</h3>
|
<h3>Satellite</h3>
|
||||||
@@ -226,7 +226,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label style="display: flex; align-items: center; gap: 6px;">
|
<label style="display: flex; align-items: center; gap: 6px;">
|
||||||
<input type="checkbox" id="wxsatSidebarAutoSchedule" onchange="WeatherSat.toggleScheduler()" style="width: auto;">
|
<input type="checkbox" id="wxsatSidebarAutoSchedule" onchange="WeatherSat.toggleScheduler(this)" style="width: auto;">
|
||||||
Enable Auto-Capture
|
Enable Auto-Capture
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+80
-61
@@ -17,17 +17,17 @@
|
|||||||
{% macro mode_item(mode, label, icon_svg, href=None) -%}
|
{% macro mode_item(mode, label, icon_svg, href=None) -%}
|
||||||
{%- set is_active = 'active' if active_mode == mode else '' -%}
|
{%- set is_active = 'active' if active_mode == mode else '' -%}
|
||||||
{%- if href %}
|
{%- if href %}
|
||||||
<a href="{{ href }}" class="mode-nav-btn {{ is_active }}" style="text-decoration: none;">
|
<a href="{{ href }}" class="mode-nav-btn {{ is_active }}" style="text-decoration: none;" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode">
|
||||||
<span class="nav-icon icon">{{ icon_svg | safe }}</span>
|
<span class="nav-icon icon">{{ icon_svg | safe }}</span>
|
||||||
<span class="nav-label">{{ label }}</span>
|
<span class="nav-label">{{ label }}</span>
|
||||||
</a>
|
</a>
|
||||||
{%- elif is_index_page %}
|
{%- elif is_index_page %}
|
||||||
<button class="mode-nav-btn {{ is_active }}" onclick="switchMode('{{ mode }}')">
|
<button type="button" class="mode-nav-btn {{ is_active }}" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode" onclick="switchMode('{{ mode }}')">
|
||||||
<span class="nav-icon icon">{{ icon_svg | safe }}</span>
|
<span class="nav-icon icon">{{ icon_svg | safe }}</span>
|
||||||
<span class="nav-label">{{ label }}</span>
|
<span class="nav-label">{{ label }}</span>
|
||||||
</button>
|
</button>
|
||||||
{%- else %}
|
{%- else %}
|
||||||
<a href="/?mode={{ mode }}" class="mode-nav-btn {{ is_active }}" style="text-decoration: none;">
|
<a href="/?mode={{ mode }}" class="mode-nav-btn {{ is_active }}" style="text-decoration: none;" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode">
|
||||||
<span class="nav-icon icon">{{ icon_svg | safe }}</span>
|
<span class="nav-icon icon">{{ icon_svg | safe }}</span>
|
||||||
<span class="nav-label">{{ label }}</span>
|
<span class="nav-label">{{ label }}</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -37,15 +37,15 @@
|
|||||||
{% macro mobile_item(mode, label, icon_svg, href=None) -%}
|
{% macro mobile_item(mode, label, icon_svg, href=None) -%}
|
||||||
{%- set is_active = 'active' if active_mode == mode else '' -%}
|
{%- set is_active = 'active' if active_mode == mode else '' -%}
|
||||||
{%- if href %}
|
{%- if href %}
|
||||||
<a href="{{ href }}" class="mobile-nav-btn {{ is_active }}" style="text-decoration: none;">
|
<a href="{{ href }}" class="mobile-nav-btn {{ is_active }}" style="text-decoration: none;" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode">
|
||||||
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
|
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
|
||||||
</a>
|
</a>
|
||||||
{%- elif is_index_page %}
|
{%- elif is_index_page %}
|
||||||
<button class="mobile-nav-btn {{ is_active }}" data-mode="{{ mode }}" onclick="switchMode('{{ mode }}')">
|
<button type="button" class="mobile-nav-btn {{ is_active }}" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode" onclick="switchMode('{{ mode }}')">
|
||||||
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
|
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
|
||||||
</button>
|
</button>
|
||||||
{%- else %}
|
{%- else %}
|
||||||
<a href="/?mode={{ mode }}" class="mobile-nav-btn {{ is_active }}" style="text-decoration: none;">
|
<a href="/?mode={{ mode }}" class="mobile-nav-btn {{ is_active }}" style="text-decoration: none;" data-mode="{{ mode }}" data-mode-label="{{ label }}" aria-label="{{ label }} mode">
|
||||||
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
|
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
|
||||||
</a>
|
</a>
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
@@ -53,11 +53,11 @@
|
|||||||
|
|
||||||
{# Desktop Navigation - uses existing CSS class names for compatibility #}
|
{# Desktop Navigation - uses existing CSS class names for compatibility #}
|
||||||
<nav class="mode-nav" id="mainNav">
|
<nav class="mode-nav" id="mainNav">
|
||||||
{# SDR / RF Group #}
|
{# Signals Group #}
|
||||||
<div class="mode-nav-dropdown" data-group="sdr">
|
<div class="mode-nav-dropdown" data-group="signals">
|
||||||
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('sdr')"{% endif %}>
|
<button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('signals')"{% endif %}>
|
||||||
<span class="nav-icon 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="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg></span>
|
<span class="nav-icon icon"><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="M22 12h-1"/><path d="M1 12h1"/></svg></span>
|
||||||
<span class="nav-label">SDR / RF</span>
|
<span class="nav-label">Signals</span>
|
||||||
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -65,51 +65,30 @@
|
|||||||
{{ 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('adsb', 'Aircraft', '<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>', '/adsb/dashboard') }}
|
|
||||||
{{ mode_item('ais', 'Vessels', '<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>', '/ais/dashboard') }}
|
|
||||||
|
|
||||||
|
|
||||||
{{ mode_item('aprs', 'APRS', '<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>') }}
|
|
||||||
{{ 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('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('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('meshtastic', 'Meshtastic', '<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>') }}
|
|
||||||
{{ 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('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>') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Wireless Group #}
|
{# Tracking Group #}
|
||||||
<div class="mode-nav-dropdown" data-group="wireless">
|
<div class="mode-nav-dropdown" data-group="tracking">
|
||||||
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('wireless')"{% endif %}>
|
<button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('tracking')"{% endif %}>
|
||||||
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span>
|
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></span>
|
||||||
<span class="nav-label">Wireless</span>
|
<span class="nav-label">Tracking</span>
|
||||||
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="mode-nav-dropdown-menu">
|
<div class="mode-nav-dropdown-menu">
|
||||||
{{ mode_item('wifi', 'WiFi', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg>') }}
|
{{ mode_item('adsb', 'Aircraft', '<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>', '/adsb/dashboard') }}
|
||||||
{{ mode_item('bluetooth', 'Bluetooth', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }}
|
{{ mode_item('ais', 'Vessels', '<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>', '/ais/dashboard') }}
|
||||||
{{ mode_item('bt_locate', 'BT Locate', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/><path d="M9.5 8.5l3 3 2-4-2 4-3 3"/></svg>') }}
|
{{ mode_item('aprs', 'APRS', '<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>') }}
|
||||||
</div>
|
{{ mode_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Security Group #}
|
|
||||||
<div class="mode-nav-dropdown" data-group="security">
|
|
||||||
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('security')"{% endif %}>
|
|
||||||
<span class="nav-icon icon"><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></span>
|
|
||||||
<span class="nav-label">Security</span>
|
|
||||||
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="mode-nav-dropdown-menu">
|
|
||||||
{{ mode_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
|
||||||
{{ mode_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Space Group #}
|
{# Space Group #}
|
||||||
<div class="mode-nav-dropdown" data-group="space">
|
<div class="mode-nav-dropdown" data-group="space">
|
||||||
<button class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('space')"{% endif %}>
|
<button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('space')"{% endif %}>
|
||||||
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg></span>
|
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg></span>
|
||||||
<span class="nav-label">Space</span>
|
<span class="nav-label">Space</span>
|
||||||
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||||
@@ -124,11 +103,42 @@
|
|||||||
{{ mode_item('sstv', 'ISS SSTV', '<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"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg>') }}
|
{{ mode_item('sstv', 'ISS SSTV', '<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"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg>') }}
|
||||||
{{ mode_item('weathersat', 'Weather Sat', '<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"/><path d="M2 12h20"/><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('weathersat', 'Weather Sat', '<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"/><path d="M2 12h20"/><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('sstv_general', 'HF SSTV', '<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"/><circle cx="12" cy="12" r="3"/><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('sstv_general', 'HF SSTV', '<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"/><circle cx="12" cy="12" r="3"/><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('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
|
|
||||||
{{ mode_item('spaceweather', 'Space Weather', '<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="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>') }}
|
{{ mode_item('spaceweather', 'Space Weather', '<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="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Wireless Group #}
|
||||||
|
<div class="mode-nav-dropdown" data-group="wireless">
|
||||||
|
<button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('wireless')"{% endif %}>
|
||||||
|
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span>
|
||||||
|
<span class="nav-label">Wireless</span>
|
||||||
|
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mode-nav-dropdown-menu">
|
||||||
|
{{ mode_item('wifi', 'WiFi', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg>') }}
|
||||||
|
{{ mode_item('bluetooth', 'Bluetooth', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }}
|
||||||
|
{{ mode_item('bt_locate', 'BT Locate', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/><path d="M9.5 8.5l3 3 2-4-2 4-3 3"/></svg>') }}
|
||||||
|
{{ mode_item('meshtastic', 'Meshtastic', '<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>') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Intel Group #}
|
||||||
|
<div class="mode-nav-dropdown" data-group="intel">
|
||||||
|
<button type="button" class="mode-nav-dropdown-btn"{% if is_index_page %} onclick="toggleNavDropdown('intel')"{% endif %}>
|
||||||
|
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
|
||||||
|
<span class="nav-label">Intel</span>
|
||||||
|
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mode-nav-dropdown-menu">
|
||||||
|
{{ mode_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
||||||
|
{{ mode_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
|
||||||
|
{{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||||
|
{{ mode_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# Dynamic dashboard button (shown when in satellite mode) #}
|
{# Dynamic dashboard button (shown when in satellite mode) #}
|
||||||
<div class="mode-nav-actions">
|
<div class="mode-nav-actions">
|
||||||
<a href="/satellite/dashboard" target="_blank" class="nav-action-btn" id="satelliteDashboardBtn" style="display: none;">
|
<a href="/satellite/dashboard" target="_blank" class="nav-action-btn" id="satelliteDashboardBtn" style="display: none;">
|
||||||
@@ -150,11 +160,11 @@
|
|||||||
</a>
|
</a>
|
||||||
<div class="nav-divider"></div>
|
<div class="nav-divider"></div>
|
||||||
<div class="nav-tools">
|
<div class="nav-tools">
|
||||||
<button class="nav-tool-btn" onclick="toggleAnimations()" title="Toggle Animations">
|
<button type="button" class="nav-tool-btn" onclick="toggleAnimations()" title="Toggle Animations" aria-label="Toggle animations">
|
||||||
<span class="icon-effects-on icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg></span>
|
<span class="icon-effects-on icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg></span>
|
||||||
<span class="icon-effects-off icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/><line x1="2" y1="2" x2="22" y2="22"/></svg></span>
|
<span class="icon-effects-off icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/><line x1="2" y1="2" x2="22" y2="22"/></svg></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-tool-btn" onclick="toggleTheme()" title="Toggle Light/Dark Theme">
|
<button type="button" class="nav-tool-btn" onclick="toggleTheme()" title="Toggle Light/Dark Theme" aria-label="Toggle theme">
|
||||||
<span class="icon-moon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></span>
|
<span class="icon-moon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></span>
|
||||||
<span class="icon-sun 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="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span>
|
<span class="icon-sun 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="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -164,11 +174,11 @@
|
|||||||
<a href="/controller/manage" class="nav-tool-btn" title="Manage Remote Agents" style="text-decoration: none;">
|
<a href="/controller/manage" class="nav-tool-btn" title="Manage Remote Agents" style="text-decoration: none;">
|
||||||
<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="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></span>
|
<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="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></span>
|
||||||
</a>
|
</a>
|
||||||
<button class="nav-tool-btn" onclick="showSettings()" title="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 class="nav-tool-btn" onclick="showHelp()" title="Help & Documentation">?</button>
|
<button type="button" class="nav-tool-btn" onclick="showHelp()" title="Help & Documentation" aria-label="Open help">?</button>
|
||||||
<button class="nav-tool-btn" onclick="logout(event)" title="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>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,19 +187,18 @@
|
|||||||
|
|
||||||
{# Mobile Navigation Bar #}
|
{# Mobile Navigation Bar #}
|
||||||
<nav class="mobile-nav-bar" id="mobileNavBar">
|
<nav class="mobile-nav-bar" id="mobileNavBar">
|
||||||
|
{# Signals #}
|
||||||
{{ 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>') }}
|
||||||
|
{# 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') }}
|
||||||
{{ mobile_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>', '/ais/dashboard') }}
|
{{ mobile_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>', '/ais/dashboard') }}
|
||||||
|
|
||||||
|
|
||||||
{{ mobile_item('aprs', 'APRS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>') }}
|
{{ mobile_item('aprs', 'APRS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>') }}
|
||||||
{{ mobile_item('wifi', 'WiFi', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg>') }}
|
{{ mobile_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
|
||||||
{{ mobile_item('bluetooth', 'BT', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }}
|
{# Space #}
|
||||||
{{ mobile_item('bt_locate', 'Locate', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></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>') }}
|
|
||||||
{% if is_index_page %}
|
{% if is_index_page %}
|
||||||
{{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>') }}
|
{{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>') }}
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -198,13 +207,17 @@
|
|||||||
{{ mobile_item('sstv', 'SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
|
{{ mobile_item('sstv', 'SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
|
||||||
{{ mobile_item('weathersat', 'WxSat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><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('weathersat', 'WxSat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><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('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
|
{{ mobile_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
|
||||||
{{ mobile_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
|
|
||||||
{{ mobile_item('spaceweather', 'SpaceWx', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/></svg>') }}
|
{{ mobile_item('spaceweather', 'SpaceWx', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/></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>') }}
|
{# Wireless #}
|
||||||
{{ 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('wifi', 'WiFi', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg>') }}
|
||||||
|
{{ mobile_item('bluetooth', 'BT', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }}
|
||||||
|
{{ mobile_item('bt_locate', 'Locate', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></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>') }}
|
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
|
||||||
|
{# Intel #}
|
||||||
|
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
||||||
|
{{ mobile_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
|
||||||
|
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||||
{{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
{{ 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('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>') }}
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{# JavaScript stub for pages that don't have switchMode defined #}
|
{# JavaScript stub for pages that don't have switchMode defined #}
|
||||||
@@ -244,7 +257,8 @@
|
|||||||
const current = html.getAttribute('data-animations') || 'on';
|
const current = html.getAttribute('data-animations') || 'on';
|
||||||
const next = current === 'on' ? 'off' : 'on';
|
const next = current === 'on' ? 'off' : 'on';
|
||||||
html.setAttribute('data-animations', next);
|
html.setAttribute('data-animations', next);
|
||||||
localStorage.setItem('animations', next);
|
localStorage.setItem('intercept-animations', next);
|
||||||
|
localStorage.removeItem('animations');
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +317,12 @@
|
|||||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedAnimations = localStorage.getItem('intercept-animations');
|
const legacyAnimations = localStorage.getItem('animations');
|
||||||
|
const savedAnimations = localStorage.getItem('intercept-animations') || legacyAnimations;
|
||||||
|
if (legacyAnimations && !localStorage.getItem('intercept-animations')) {
|
||||||
|
localStorage.setItem('intercept-animations', legacyAnimations);
|
||||||
|
localStorage.removeItem('animations');
|
||||||
|
}
|
||||||
if (savedAnimations) {
|
if (savedAnimations) {
|
||||||
document.documentElement.setAttribute('data-animations', savedAnimations);
|
document.documentElement.setAttribute('data-animations', savedAnimations);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
<!-- Settings Modal -->
|
<!-- Settings Modal -->
|
||||||
<div id="settingsModal" class="settings-modal" onclick="if(event.target === this) hideSettings()">
|
<div id="settingsModal" class="settings-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="settingsModalTitle" onclick="if(event.target === this) hideSettings()">
|
||||||
<div class="settings-content">
|
<div class="settings-content" tabindex="-1">
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<h2>
|
<h2 id="settingsModalTitle">
|
||||||
<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>
|
||||||
Settings
|
Settings
|
||||||
</h2>
|
</h2>
|
||||||
<button class="settings-close" onclick="hideSettings()">×</button>
|
<button type="button" class="settings-close" onclick="hideSettings()" aria-label="Close settings">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-tabs">
|
<div class="settings-tabs" role="tablist" aria-label="Settings sections">
|
||||||
<button class="settings-tab active" data-tab="offline" onclick="switchSettingsTab('offline')">Offline</button>
|
<button type="button" class="settings-tab active" data-tab="offline" onclick="switchSettingsTab('offline')" role="tab" aria-controls="settings-offline" aria-selected="true">Offline</button>
|
||||||
<button class="settings-tab" data-tab="location" onclick="switchSettingsTab('location')">Location</button>
|
<button type="button" class="settings-tab" data-tab="location" onclick="switchSettingsTab('location')" role="tab" aria-controls="settings-location" aria-selected="false">Location</button>
|
||||||
<button class="settings-tab" data-tab="display" onclick="switchSettingsTab('display')">Display</button>
|
<button type="button" class="settings-tab" data-tab="display" onclick="switchSettingsTab('display')" role="tab" aria-controls="settings-display" aria-selected="false">Display</button>
|
||||||
<button class="settings-tab" data-tab="updates" onclick="switchSettingsTab('updates')">Updates</button>
|
<button type="button" class="settings-tab" data-tab="updates" onclick="switchSettingsTab('updates')" role="tab" aria-controls="settings-updates" aria-selected="false">Updates</button>
|
||||||
<button class="settings-tab" data-tab="tools" onclick="switchSettingsTab('tools')">Tools</button>
|
<button type="button" class="settings-tab" data-tab="tools" onclick="switchSettingsTab('tools')" role="tab" aria-controls="settings-tools" aria-selected="false">Tools</button>
|
||||||
<button class="settings-tab" data-tab="alerts" onclick="switchSettingsTab('alerts')">Alerts</button>
|
<button type="button" class="settings-tab" data-tab="alerts" onclick="switchSettingsTab('alerts')" role="tab" aria-controls="settings-alerts" aria-selected="false">Alerts</button>
|
||||||
<button class="settings-tab" data-tab="recording" onclick="switchSettingsTab('recording')">Recording</button>
|
<button type="button" class="settings-tab" data-tab="recording" onclick="switchSettingsTab('recording')" role="tab" aria-controls="settings-recording" aria-selected="false">Recording</button>
|
||||||
<button class="settings-tab" data-tab="about" onclick="switchSettingsTab('about')">About</button>
|
<button type="button" class="settings-tab" data-tab="about" onclick="switchSettingsTab('about')" role="tab" aria-controls="settings-about" aria-selected="false">About</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Offline Section -->
|
<!-- Offline Section -->
|
||||||
<div id="settings-offline" class="settings-section active">
|
<div id="settings-offline" class="settings-section active" role="tabpanel">
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<div class="settings-group-title">Offline Mode</div>
|
<div class="settings-group-title">Offline Mode</div>
|
||||||
|
|
||||||
@@ -72,9 +72,9 @@
|
|||||||
<span class="settings-label-desc">Map background imagery</span>
|
<span class="settings-label-desc">Map background imagery</span>
|
||||||
</div>
|
</div>
|
||||||
<select id="tileProvider" class="settings-select" onchange="Settings.setTileProvider(this.value)">
|
<select id="tileProvider" class="settings-select" onchange="Settings.setTileProvider(this.value)">
|
||||||
<option value="openstreetmap">OpenStreetMap</option>
|
<option value="cartodb_dark_cyan">Intercept Default</option>
|
||||||
<option value="cartodb_dark">CartoDB Dark</option>
|
<option value="cartodb_dark">CartoDB Dark</option>
|
||||||
<option value="cartodb_dark_cyan">CartoDB Dark (Cyan Tint)</option>
|
<option value="openstreetmap">OpenStreetMap</option>
|
||||||
<option value="cartodb_light">CartoDB Positron</option>
|
<option value="cartodb_light">CartoDB Positron</option>
|
||||||
<option value="esri_world">ESRI World Imagery</option>
|
<option value="esri_world">ESRI World Imagery</option>
|
||||||
<option value="custom">Custom URL</option>
|
<option value="custom">Custom URL</option>
|
||||||
@@ -291,6 +291,72 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Rule Builder</div>
|
||||||
|
<div class="settings-row" style="border-bottom: none; padding-top: 0;">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Rule Name</span>
|
||||||
|
<span class="settings-label-desc">Human-friendly title for this alert</span>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="alertsRuleName" class="settings-input" placeholder="New alert rule" style="width: 220px;">
|
||||||
|
</div>
|
||||||
|
<div class="settings-row" style="border-bottom: none;">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Mode</span>
|
||||||
|
<span class="settings-label-desc">Filter to a specific mode or all</span>
|
||||||
|
</div>
|
||||||
|
<select id="alertsRuleMode" class="settings-select" style="width: 220px;">
|
||||||
|
<option value="">All modes</option>
|
||||||
|
<option value="pager">Pager</option>
|
||||||
|
<option value="sensor">433 Sensors</option>
|
||||||
|
<option value="wifi">WiFi</option>
|
||||||
|
<option value="bluetooth">Bluetooth</option>
|
||||||
|
<option value="adsb">ADS-B</option>
|
||||||
|
<option value="ais">AIS</option>
|
||||||
|
<option value="acars">ACARS</option>
|
||||||
|
<option value="vdl2">VDL2</option>
|
||||||
|
<option value="aprs">APRS</option>
|
||||||
|
<option value="dsc">DSC</option>
|
||||||
|
<option value="meshtastic">Meshtastic</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row" style="border-bottom: none;">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Event Type</span>
|
||||||
|
<span class="settings-label-desc">Optional event type (for example <code>device_update</code>)</span>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="alertsRuleEventType" class="settings-input" placeholder="Optional" style="width: 220px;">
|
||||||
|
</div>
|
||||||
|
<div class="settings-row" style="border-bottom: none;">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Match Filter</span>
|
||||||
|
<span class="settings-label-desc">Optional key/value exact match (for example <code>address</code> + MAC)</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:8px;">
|
||||||
|
<input type="text" id="alertsRuleMatchKey" class="settings-input" placeholder="key" style="width: 100px;">
|
||||||
|
<input type="text" id="alertsRuleMatchValue" class="settings-input" placeholder="value" style="width: 112px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row" style="border-bottom: none;">
|
||||||
|
<div class="settings-label">
|
||||||
|
<span class="settings-label-text">Severity</span>
|
||||||
|
<span class="settings-label-desc">Controls priority coloring and notifications</span>
|
||||||
|
</div>
|
||||||
|
<select id="alertsRuleSeverity" class="settings-select" style="width: 220px;">
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="medium" selected>Medium</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
<option value="critical">Critical</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 8px;">
|
||||||
|
<button class="check-assets-btn" onclick="AlertCenter.saveRule()">Save Rule</button>
|
||||||
|
<button class="check-assets-btn" onclick="AlertCenter.clearRuleForm()">Clear</button>
|
||||||
|
<button class="check-assets-btn" onclick="AlertCenter.loadRules()">Refresh Rules</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="alertsRuleEditingId" value="">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<div class="settings-group-title">Quick Rules</div>
|
<div class="settings-group-title">Quick Rules</div>
|
||||||
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||||
@@ -301,6 +367,13 @@
|
|||||||
Use Bluetooth device details to add specific device watchlist alerts.
|
Use Bluetooth device details to add specific device watchlist alerts.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="settings-group-title">Active Rules</div>
|
||||||
|
<div id="alertsRulesList" class="settings-feed">
|
||||||
|
<div class="settings-feed-empty">No rules yet</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recording Section -->
|
<!-- Recording Section -->
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}">
|
||||||
<script>
|
<script>
|
||||||
@@ -609,11 +609,17 @@
|
|||||||
const cy = canvas.height / 2;
|
const cy = canvas.height / 2;
|
||||||
const radius = Math.max(10, Math.min(cx, cy) - 40);
|
const radius = Math.max(10, Math.min(cx, cy) - 40);
|
||||||
|
|
||||||
ctx.fillStyle = '#0a0a0f';
|
const cs = getComputedStyle(document.documentElement);
|
||||||
|
const bgColor = cs.getPropertyValue('--bg-card').trim() || '#0a0a0f';
|
||||||
|
const accentCyan = cs.getPropertyValue('--accent-cyan').trim() || '#00d4ff';
|
||||||
|
const accentRed = cs.getPropertyValue('--accent-red').trim() || '#ff4444';
|
||||||
|
const textDim = cs.getPropertyValue('--text-dim').trim() || 'rgba(0,212,255,0.4)';
|
||||||
|
|
||||||
|
ctx.fillStyle = bgColor;
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
// Elevation rings
|
// Elevation rings
|
||||||
ctx.strokeStyle = 'rgba(0, 212, 255, 0.15)';
|
ctx.strokeStyle = accentCyan + '26'; // ~15% opacity
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
for (let el = 30; el <= 90; el += 30) {
|
for (let el = 30; el <= 90; el += 30) {
|
||||||
const r = radius * (1 - el / 90);
|
const r = radius * (1 - el / 90);
|
||||||
@@ -621,20 +627,20 @@
|
|||||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(0, 212, 255, 0.4)';
|
ctx.fillStyle = textDim;
|
||||||
ctx.font = '10px Roboto Condensed';
|
ctx.font = '10px Roboto Condensed';
|
||||||
ctx.fillText(el + '°', cx + 5, cy - r + 12);
|
ctx.fillText(el + '°', cx + 5, cy - r + 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Horizon
|
// Horizon
|
||||||
ctx.strokeStyle = 'rgba(0, 212, 255, 0.3)';
|
ctx.strokeStyle = accentCyan + '4D'; // ~30% opacity
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Cardinal lines
|
// Cardinal lines
|
||||||
ctx.strokeStyle = 'rgba(0, 212, 255, 0.1)';
|
ctx.strokeStyle = accentCyan + '1A'; // ~10% opacity
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
for (let az = 0; az < 360; az += 45) {
|
for (let az = 0; az < 360; az += 45) {
|
||||||
const angle = (az - 90) * Math.PI / 180;
|
const angle = (az - 90) * Math.PI / 180;
|
||||||
@@ -647,10 +653,10 @@
|
|||||||
// Cardinal labels
|
// Cardinal labels
|
||||||
ctx.font = 'bold 14px Orbitron';
|
ctx.font = 'bold 14px Orbitron';
|
||||||
const labels = [
|
const labels = [
|
||||||
{ text: 'N', az: 0, color: '#ff4444' },
|
{ text: 'N', az: 0, color: accentRed },
|
||||||
{ text: 'E', az: 90, color: '#00d4ff' },
|
{ text: 'E', az: 90, color: accentCyan },
|
||||||
{ text: 'S', az: 180, color: '#00d4ff' },
|
{ text: 'S', az: 180, color: accentCyan },
|
||||||
{ text: 'W', az: 270, color: '#00d4ff' }
|
{ text: 'W', az: 270, color: accentCyan }
|
||||||
];
|
];
|
||||||
labels.forEach(l => {
|
labels.forEach(l => {
|
||||||
const angle = (l.az - 90) * Math.PI / 180;
|
const angle = (l.az - 90) * Math.PI / 180;
|
||||||
@@ -978,10 +984,18 @@
|
|||||||
const cy = canvas.height / 2;
|
const cy = canvas.height / 2;
|
||||||
const radius = Math.max(10, Math.min(cx, cy) - 40);
|
const radius = Math.max(10, Math.min(cx, cy) - 40);
|
||||||
|
|
||||||
ctx.fillStyle = '#0a0a0f';
|
const cs = getComputedStyle(document.documentElement);
|
||||||
|
const bgColor = cs.getPropertyValue('--bg-card').trim() || '#0a0a0f';
|
||||||
|
const accentCyan = cs.getPropertyValue('--accent-cyan').trim() || '#00d4ff';
|
||||||
|
const accentRed = cs.getPropertyValue('--accent-red').trim() || '#ff4444';
|
||||||
|
const accentGreen = cs.getPropertyValue('--accent-green').trim() || '#00ff88';
|
||||||
|
const textPrimary = cs.getPropertyValue('--text-primary').trim() || '#fff';
|
||||||
|
const textDim = cs.getPropertyValue('--text-dim').trim() || 'rgba(0,212,255,0.4)';
|
||||||
|
|
||||||
|
ctx.fillStyle = bgColor;
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
ctx.strokeStyle = 'rgba(0, 212, 255, 0.15)';
|
ctx.strokeStyle = accentCyan + '26';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
for (let elRing = 30; elRing <= 90; elRing += 30) {
|
for (let elRing = 30; elRing <= 90; elRing += 30) {
|
||||||
const r = radius * (1 - elRing / 90);
|
const r = radius * (1 - elRing / 90);
|
||||||
@@ -989,18 +1003,18 @@
|
|||||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(0, 212, 255, 0.4)';
|
ctx.fillStyle = textDim;
|
||||||
ctx.font = '10px Roboto Condensed';
|
ctx.font = '10px Roboto Condensed';
|
||||||
ctx.fillText(elRing + '°', cx + 5, cy - r + 12);
|
ctx.fillText(elRing + '°', cx + 5, cy - r + 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.strokeStyle = 'rgba(0, 212, 255, 0.3)';
|
ctx.strokeStyle = accentCyan + '4D';
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
ctx.strokeStyle = 'rgba(0, 212, 255, 0.1)';
|
ctx.strokeStyle = accentCyan + '1A';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
for (let azLine = 0; azLine < 360; azLine += 45) {
|
for (let azLine = 0; azLine < 360; azLine += 45) {
|
||||||
const angle = (azLine - 90) * Math.PI / 180;
|
const angle = (azLine - 90) * Math.PI / 180;
|
||||||
@@ -1012,10 +1026,10 @@
|
|||||||
|
|
||||||
ctx.font = 'bold 14px Orbitron';
|
ctx.font = 'bold 14px Orbitron';
|
||||||
const labels = [
|
const labels = [
|
||||||
{ text: 'N', az: 0, color: '#ff4444' },
|
{ text: 'N', az: 0, color: accentRed },
|
||||||
{ text: 'E', az: 90, color: '#00d4ff' },
|
{ text: 'E', az: 90, color: accentCyan },
|
||||||
{ text: 'S', az: 180, color: '#00d4ff' },
|
{ text: 'S', az: 180, color: accentCyan },
|
||||||
{ text: 'W', az: 270, color: '#00d4ff' }
|
{ text: 'W', az: 270, color: accentCyan }
|
||||||
];
|
];
|
||||||
labels.forEach(l => {
|
labels.forEach(l => {
|
||||||
const angle = (l.az - 90) * Math.PI / 180;
|
const angle = (l.az - 90) * Math.PI / 180;
|
||||||
@@ -1048,21 +1062,21 @@
|
|||||||
ctx.arc(x, y, 10, 0, Math.PI * 2);
|
ctx.arc(x, y, 10, 0, Math.PI * 2);
|
||||||
ctx.fillStyle = color;
|
ctx.fillStyle = color;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.strokeStyle = '#fff';
|
ctx.strokeStyle = textPrimary;
|
||||||
ctx.lineWidth = 3;
|
ctx.lineWidth = 3;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
ctx.font = 'bold 11px Orbitron';
|
ctx.font = 'bold 11px Orbitron';
|
||||||
ctx.fillStyle = '#fff';
|
ctx.fillStyle = textPrimary;
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.fillText(satellites[selectedSatellite]?.name || 'SAT', x, y - 20);
|
ctx.fillText(satellites[selectedSatellite]?.name || 'SAT', x, y - 20);
|
||||||
|
|
||||||
ctx.font = '10px Roboto Condensed';
|
ctx.font = '10px Roboto Condensed';
|
||||||
ctx.fillStyle = el > 0 ? '#00ff88' : '#ff4444';
|
ctx.fillStyle = el > 0 ? accentGreen : accentRed;
|
||||||
ctx.fillText(el.toFixed(1) + '°', x, y + 25);
|
ctx.fillText(el.toFixed(1) + '°', x, y + 25);
|
||||||
} else {
|
} else {
|
||||||
ctx.font = '12px Rajdhani';
|
ctx.font = '12px Rajdhani';
|
||||||
ctx.fillStyle = '#ff4444';
|
ctx.fillStyle = accentRed;
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.fillText('BELOW HORIZON', cx, cy + radius + 35);
|
ctx.fillText('BELOW HORIZON', cx, cy + radius + 35);
|
||||||
}
|
}
|
||||||
@@ -1076,6 +1090,11 @@
|
|||||||
const cy = canvas.height / 2;
|
const cy = canvas.height / 2;
|
||||||
const radius = Math.max(10, Math.min(cx, cy) - 40);
|
const radius = Math.max(10, Math.min(cx, cy) - 40);
|
||||||
|
|
||||||
|
const cs = getComputedStyle(document.documentElement);
|
||||||
|
const accentRed = cs.getPropertyValue('--accent-red').trim() || '#ff4444';
|
||||||
|
const accentGreen = cs.getPropertyValue('--accent-green').trim() || '#00ff88';
|
||||||
|
const textPrimary = cs.getPropertyValue('--text-primary').trim() || '#fff';
|
||||||
|
|
||||||
if (el > -5) {
|
if (el > -5) {
|
||||||
const posEl = Math.max(0, el);
|
const posEl = Math.max(0, el);
|
||||||
const r = radius * (1 - posEl / 90);
|
const r = radius * (1 - posEl / 90);
|
||||||
@@ -1097,17 +1116,17 @@
|
|||||||
ctx.arc(x, y, 10, 0, Math.PI * 2);
|
ctx.arc(x, y, 10, 0, Math.PI * 2);
|
||||||
ctx.fillStyle = color;
|
ctx.fillStyle = color;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.strokeStyle = '#fff';
|
ctx.strokeStyle = textPrimary;
|
||||||
ctx.lineWidth = 3;
|
ctx.lineWidth = 3;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
ctx.font = 'bold 11px Orbitron';
|
ctx.font = 'bold 11px Orbitron';
|
||||||
ctx.fillStyle = '#fff';
|
ctx.fillStyle = textPrimary;
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.fillText(satellites[selectedSatellite]?.name || 'SAT', x, y - 20);
|
ctx.fillText(satellites[selectedSatellite]?.name || 'SAT', x, y - 20);
|
||||||
|
|
||||||
ctx.font = '10px Roboto Condensed';
|
ctx.font = '10px Roboto Condensed';
|
||||||
ctx.fillStyle = el > 0 ? '#00ff88' : '#ff4444';
|
ctx.fillStyle = el > 0 ? accentGreen : accentRed;
|
||||||
ctx.fillText(el.toFixed(1) + '°', x, y + 25);
|
ctx.fillText(el.toFixed(1) + '°', x, y + 25);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1119,7 +1138,7 @@
|
|||||||
<!-- Help Modal -->
|
<!-- Help Modal -->
|
||||||
{% include 'partials/help-modal.html' %}
|
{% include 'partials/help-modal.html' %}
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
|
||||||
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+153
-4
@@ -354,15 +354,15 @@ class TestVISDetector:
|
|||||||
assert mode_name == 'Scottie1'
|
assert mode_name == 'Scottie1'
|
||||||
|
|
||||||
def test_detect_pd120(self):
|
def test_detect_pd120(self):
|
||||||
"""Should detect PD120 VIS code (93)."""
|
"""Should detect PD120 VIS code (95)."""
|
||||||
detector = VISDetector()
|
detector = VISDetector()
|
||||||
header = generate_vis_header(93) # PD120
|
header = generate_vis_header(95) # PD120
|
||||||
audio = np.concatenate([np.zeros(2400), header, np.zeros(2400)])
|
audio = np.concatenate([np.zeros(2400), header, np.zeros(2400)])
|
||||||
|
|
||||||
result = detector.feed(audio)
|
result = detector.feed(audio)
|
||||||
assert result is not None
|
assert result is not None
|
||||||
vis_code, mode_name = result
|
vis_code, mode_name = result
|
||||||
assert vis_code == 93
|
assert vis_code == 95
|
||||||
assert mode_name == 'PD120'
|
assert mode_name == 'PD120'
|
||||||
|
|
||||||
def test_noise_rejection(self):
|
def test_noise_rejection(self):
|
||||||
@@ -395,6 +395,121 @@ class TestVISDetector:
|
|||||||
assert vis_code == 8
|
assert vis_code == 8
|
||||||
assert mode_name == 'Robot36'
|
assert mode_name == 'Robot36'
|
||||||
|
|
||||||
|
def test_noisy_leader_detection(self):
|
||||||
|
"""Should detect VIS despite intermittent None windows in leader.
|
||||||
|
|
||||||
|
Simulates HF fading by inserting short silence gaps (which produce
|
||||||
|
ambiguous tone classification) into the leader tone.
|
||||||
|
"""
|
||||||
|
detector = VISDetector()
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# Build leader1 with gaps: 50ms tone, 10ms silence, repeated
|
||||||
|
# Total ~300ms of leader with interruptions
|
||||||
|
for _ in range(6):
|
||||||
|
parts.append(generate_tone(FREQ_LEADER, 0.050))
|
||||||
|
parts.append(np.zeros(int(SAMPLE_RATE * 0.010))) # 10ms gap
|
||||||
|
|
||||||
|
# Break (1200 Hz, 10ms)
|
||||||
|
parts.append(generate_tone(FREQ_SYNC, 0.010))
|
||||||
|
|
||||||
|
# Leader 2 (clean)
|
||||||
|
parts.append(generate_tone(FREQ_LEADER, 0.300))
|
||||||
|
|
||||||
|
# Start bit + data bits + parity + stop (standard for Robot36 = VIS 8)
|
||||||
|
parts.append(generate_tone(FREQ_SYNC, 0.030)) # start bit
|
||||||
|
vis_code = 8
|
||||||
|
ones_count = 0
|
||||||
|
for i in range(8):
|
||||||
|
bit = (vis_code >> i) & 1
|
||||||
|
if bit:
|
||||||
|
ones_count += 1
|
||||||
|
parts.append(generate_tone(FREQ_VIS_BIT_1, 0.030))
|
||||||
|
else:
|
||||||
|
parts.append(generate_tone(FREQ_VIS_BIT_0, 0.030))
|
||||||
|
parity = ones_count % 2
|
||||||
|
parts.append(generate_tone(
|
||||||
|
FREQ_VIS_BIT_1 if parity else FREQ_VIS_BIT_0, 0.030))
|
||||||
|
parts.append(generate_tone(FREQ_SYNC, 0.030)) # stop bit
|
||||||
|
|
||||||
|
audio = np.concatenate([np.zeros(2400)] + parts + [np.zeros(2400)])
|
||||||
|
result = detector.feed(audio)
|
||||||
|
assert result is not None
|
||||||
|
assert result[0] == 8
|
||||||
|
assert result[1] == 'Robot36'
|
||||||
|
|
||||||
|
def test_vis_error_correction_parity_bit(self):
|
||||||
|
"""Should recover when only the parity bit is corrupted."""
|
||||||
|
detector = VISDetector()
|
||||||
|
# Generate Martin1 header (VIS 44) but flip the parity bit
|
||||||
|
parts = []
|
||||||
|
parts.append(generate_tone(FREQ_LEADER, 0.300))
|
||||||
|
parts.append(generate_tone(FREQ_SYNC, 0.010))
|
||||||
|
parts.append(generate_tone(FREQ_LEADER, 0.300))
|
||||||
|
parts.append(generate_tone(FREQ_SYNC, 0.030)) # start bit
|
||||||
|
|
||||||
|
vis_code = 44 # Martin1
|
||||||
|
ones_count = 0
|
||||||
|
for i in range(8):
|
||||||
|
bit = (vis_code >> i) & 1
|
||||||
|
if bit:
|
||||||
|
ones_count += 1
|
||||||
|
parts.append(generate_tone(FREQ_VIS_BIT_1, 0.030))
|
||||||
|
else:
|
||||||
|
parts.append(generate_tone(FREQ_VIS_BIT_0, 0.030))
|
||||||
|
|
||||||
|
# Wrong parity (flip it)
|
||||||
|
correct_parity = ones_count % 2
|
||||||
|
wrong_parity = 1 - correct_parity
|
||||||
|
parts.append(generate_tone(
|
||||||
|
FREQ_VIS_BIT_1 if wrong_parity else FREQ_VIS_BIT_0, 0.030))
|
||||||
|
parts.append(generate_tone(FREQ_SYNC, 0.030)) # stop bit
|
||||||
|
|
||||||
|
audio = np.concatenate([np.zeros(2400)] + parts + [np.zeros(2400)])
|
||||||
|
result = detector.feed(audio)
|
||||||
|
assert result is not None
|
||||||
|
assert result[0] == 44
|
||||||
|
assert result[1] == 'Martin1'
|
||||||
|
|
||||||
|
def test_vis_error_correction_data_bit(self):
|
||||||
|
"""Should recover Martin1 when one data bit is flipped by HF noise.
|
||||||
|
|
||||||
|
Simulates: Martin1 (VIS 44) transmitted correctly, but bit 0 is
|
||||||
|
corrupted during reception. The parity bit is received correctly
|
||||||
|
(computed for the original code 44), so parity check fails → error
|
||||||
|
correction tries flipping each data bit and finds VIS 44.
|
||||||
|
"""
|
||||||
|
detector = VISDetector()
|
||||||
|
original_code = 44 # Martin1
|
||||||
|
corrupted_code = 44 ^ 1 # flip bit 0 → 45
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
parts.append(generate_tone(FREQ_LEADER, 0.300))
|
||||||
|
parts.append(generate_tone(FREQ_SYNC, 0.010))
|
||||||
|
parts.append(generate_tone(FREQ_LEADER, 0.300))
|
||||||
|
parts.append(generate_tone(FREQ_SYNC, 0.030)) # start bit
|
||||||
|
|
||||||
|
# Transmit corrupted data bits
|
||||||
|
for i in range(8):
|
||||||
|
bit = (corrupted_code >> i) & 1
|
||||||
|
if bit:
|
||||||
|
parts.append(generate_tone(FREQ_VIS_BIT_1, 0.030))
|
||||||
|
else:
|
||||||
|
parts.append(generate_tone(FREQ_VIS_BIT_0, 0.030))
|
||||||
|
|
||||||
|
# Parity bit computed for the ORIGINAL code (received correctly)
|
||||||
|
original_ones = bin(original_code).count('1')
|
||||||
|
parity = original_ones % 2
|
||||||
|
parts.append(generate_tone(
|
||||||
|
FREQ_VIS_BIT_1 if parity else FREQ_VIS_BIT_0, 0.030))
|
||||||
|
parts.append(generate_tone(FREQ_SYNC, 0.030)) # stop bit
|
||||||
|
|
||||||
|
audio = np.concatenate([np.zeros(2400)] + parts + [np.zeros(2400)])
|
||||||
|
result = detector.feed(audio)
|
||||||
|
assert result is not None
|
||||||
|
assert result[0] == 44
|
||||||
|
assert result[1] == 'Martin1'
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Mode spec tests
|
# Mode spec tests
|
||||||
@@ -405,7 +520,7 @@ class TestModes:
|
|||||||
|
|
||||||
def test_all_vis_codes_have_modes(self):
|
def test_all_vis_codes_have_modes(self):
|
||||||
"""All defined VIS codes should have matching mode specs."""
|
"""All defined VIS codes should have matching mode specs."""
|
||||||
for vis_code in [8, 12, 44, 40, 60, 56, 93, 95]:
|
for vis_code in [8, 12, 44, 40, 60, 56, 95, 97, 99, 98, 96, 76]:
|
||||||
mode = get_mode(vis_code)
|
mode = get_mode(vis_code)
|
||||||
assert mode is not None, f"No mode for VIS code {vis_code}"
|
assert mode is not None, f"No mode for VIS code {vis_code}"
|
||||||
|
|
||||||
@@ -570,6 +685,40 @@ class TestImageDecoder:
|
|||||||
assert img is not None
|
assert img is not None
|
||||||
assert img.size == (320, 240)
|
assert img.size == (320, 240)
|
||||||
|
|
||||||
|
def test_slant_correction_wraps_rows_without_blank_wedge(self):
|
||||||
|
"""Slant correction should rotate rows, not introduce black fill."""
|
||||||
|
PIL = pytest.importorskip('PIL')
|
||||||
|
from utils.sstv.image_decoder import SSTVImageDecoder
|
||||||
|
|
||||||
|
decoder = SSTVImageDecoder(SCOTTIE_1)
|
||||||
|
decoder._sync_deviations = [float(i * 4) for i in range(SCOTTIE_1.height)]
|
||||||
|
|
||||||
|
source = np.full((SCOTTIE_1.height, SCOTTIE_1.width, 3), 128, dtype=np.uint8)
|
||||||
|
img = PIL.Image.fromarray(source, 'RGB')
|
||||||
|
|
||||||
|
corrected = decoder._apply_slant_correction(img)
|
||||||
|
corrected_arr = np.array(corrected)
|
||||||
|
|
||||||
|
# If correction clips/fills, zeros appear. Circular shift should preserve all values.
|
||||||
|
assert corrected_arr.min() == 128
|
||||||
|
assert corrected_arr.max() == 128
|
||||||
|
|
||||||
|
def test_slant_correction_skips_implausible_drift(self):
|
||||||
|
"""Very large estimated drift should be treated as a bad fit and ignored."""
|
||||||
|
PIL = pytest.importorskip('PIL')
|
||||||
|
from utils.sstv.image_decoder import SSTVImageDecoder
|
||||||
|
|
||||||
|
decoder = SSTVImageDecoder(SCOTTIE_1)
|
||||||
|
decoder._sync_deviations = [float(i * 40) for i in range(SCOTTIE_1.height)]
|
||||||
|
|
||||||
|
source = np.full((SCOTTIE_1.height, SCOTTIE_1.width, 3), 177, dtype=np.uint8)
|
||||||
|
img = PIL.Image.fromarray(source, 'RGB')
|
||||||
|
|
||||||
|
corrected = decoder._apply_slant_correction(img)
|
||||||
|
|
||||||
|
# Implausible slope should return original image unchanged.
|
||||||
|
assert np.array_equal(np.array(corrected), source)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# SSTVDecoder orchestrator tests
|
# SSTVDecoder orchestrator tests
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Tests for ISS SSTV route behavior."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from utils.sstv import ISS_SSTV_FREQ
|
||||||
|
|
||||||
|
|
||||||
|
def _login_session(client) -> None:
|
||||||
|
"""Mark the Flask test session as authenticated."""
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess['logged_in'] = True
|
||||||
|
sess['username'] = 'test'
|
||||||
|
sess['role'] = 'admin'
|
||||||
|
|
||||||
|
|
||||||
|
class TestSSTVRoutes:
|
||||||
|
"""ISS SSTV route tests."""
|
||||||
|
|
||||||
|
def test_status_reports_fm_modulation(self, client):
|
||||||
|
"""GET /sstv/status should report the fixed ISS modulation."""
|
||||||
|
_login_session(client)
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.decoder_available = 'python-sstv'
|
||||||
|
mock_decoder.is_running = False
|
||||||
|
mock_decoder.get_images.return_value = []
|
||||||
|
mock_decoder.doppler_enabled = False
|
||||||
|
mock_decoder.last_doppler_info = None
|
||||||
|
|
||||||
|
with patch('routes.sstv.is_sstv_available', return_value=True), \
|
||||||
|
patch('routes.sstv.get_sstv_decoder', return_value=mock_decoder):
|
||||||
|
response = client.get('/sstv/status')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['available'] is True
|
||||||
|
assert data['modulation'] == 'fm'
|
||||||
|
assert data['iss_frequency'] == ISS_SSTV_FREQ
|
||||||
|
|
||||||
|
def test_start_uses_fm_and_normalizes_supported_iss_frequency(self, client):
|
||||||
|
"""POST /sstv/start should enforce FM and snap near ISS values."""
|
||||||
|
_login_session(client)
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.is_running = False
|
||||||
|
mock_decoder.start.return_value = True
|
||||||
|
mock_decoder.doppler_enabled = False
|
||||||
|
mock_decoder.last_doppler_info = None
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'frequency': ISS_SSTV_FREQ + 0.02, # Within tolerance; should normalize.
|
||||||
|
'modulation': 'FM',
|
||||||
|
'device': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch('routes.sstv.is_sstv_available', return_value=True), \
|
||||||
|
patch('routes.sstv.get_sstv_decoder', return_value=mock_decoder), \
|
||||||
|
patch('routes.sstv.app_module.claim_sdr_device', return_value=None):
|
||||||
|
response = client.post(
|
||||||
|
'/sstv/start',
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'started'
|
||||||
|
assert data['modulation'] == 'fm'
|
||||||
|
assert data['frequency'] == pytest.approx(ISS_SSTV_FREQ)
|
||||||
|
|
||||||
|
mock_decoder.start.assert_called_once()
|
||||||
|
call_kwargs = mock_decoder.start.call_args.kwargs
|
||||||
|
assert call_kwargs['modulation'] == 'fm'
|
||||||
|
assert call_kwargs['frequency'] == pytest.approx(ISS_SSTV_FREQ)
|
||||||
|
|
||||||
|
def test_start_rejects_non_fm_modulation(self, client):
|
||||||
|
"""POST /sstv/start should reject non-FM modulation requests."""
|
||||||
|
_login_session(client)
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.is_running = False
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'frequency': ISS_SSTV_FREQ,
|
||||||
|
'modulation': 'usb',
|
||||||
|
'device': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch('routes.sstv.is_sstv_available', return_value=True), \
|
||||||
|
patch('routes.sstv.get_sstv_decoder', return_value=mock_decoder):
|
||||||
|
response = client.post(
|
||||||
|
'/sstv/start',
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
assert 'Modulation must be fm' in data['message']
|
||||||
|
mock_decoder.start.assert_not_called()
|
||||||
|
|
||||||
|
def test_start_rejects_non_iss_frequency(self, client):
|
||||||
|
"""POST /sstv/start should reject unsupported non-ISS frequencies."""
|
||||||
|
_login_session(client)
|
||||||
|
mock_decoder = MagicMock()
|
||||||
|
mock_decoder.is_running = False
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'frequency': 14.230,
|
||||||
|
'modulation': 'fm',
|
||||||
|
'device': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch('routes.sstv.is_sstv_available', return_value=True), \
|
||||||
|
patch('routes.sstv.get_sstv_decoder', return_value=mock_decoder):
|
||||||
|
response = client.post(
|
||||||
|
'/sstv/start',
|
||||||
|
data=json.dumps(payload),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
assert 'Supported ISS SSTV frequency' in data['message']
|
||||||
|
mock_decoder.start.assert_not_called()
|
||||||
@@ -7,10 +7,10 @@ and ground track generation.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import MagicMock, patch
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from utils.weather_sat_predict import predict_passes
|
from utils.weather_sat_predict import _format_utc_iso, predict_passes
|
||||||
|
|
||||||
|
|
||||||
class TestPredictPasses:
|
class TestPredictPasses:
|
||||||
@@ -673,3 +673,20 @@ class TestPassDataStructure:
|
|||||||
with patch.dict('sys.modules', {'skyfield': None, 'skyfield.api': None}):
|
with patch.dict('sys.modules', {'skyfield': None, 'skyfield.api': None}):
|
||||||
with pytest.raises((ImportError, AttributeError)):
|
with pytest.raises((ImportError, AttributeError)):
|
||||||
predict_passes(lat=51.5, lon=-0.1)
|
predict_passes(lat=51.5, lon=-0.1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTimestampFormatting:
|
||||||
|
"""Tests for UTC timestamp serialization helpers."""
|
||||||
|
|
||||||
|
def test_format_utc_iso_from_aware_datetime(self):
|
||||||
|
"""Aware UTC datetimes should not get a duplicate UTC suffix."""
|
||||||
|
dt = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
value = _format_utc_iso(dt)
|
||||||
|
assert value == '2024-01-01T12:00:00Z'
|
||||||
|
assert '+00:00Z' not in value
|
||||||
|
|
||||||
|
def test_format_utc_iso_from_naive_datetime(self):
|
||||||
|
"""Naive datetimes should be treated as UTC and serialized consistently."""
|
||||||
|
dt = datetime(2024, 1, 1, 12, 0, 0)
|
||||||
|
value = _format_utc_iso(dt)
|
||||||
|
assert value == '2024-01-01T12:00:00Z'
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from utils.weather_sat_scheduler import (
|
|||||||
WeatherSatScheduler,
|
WeatherSatScheduler,
|
||||||
ScheduledPass,
|
ScheduledPass,
|
||||||
get_weather_sat_scheduler,
|
get_weather_sat_scheduler,
|
||||||
|
_parse_utc_iso,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -327,7 +328,7 @@ class TestWeatherSatScheduler:
|
|||||||
assert len(passes) == 1
|
assert len(passes) == 1
|
||||||
assert passes[0]['id'] == 'NOAA-18_202401011200'
|
assert passes[0]['id'] == 'NOAA-18_202401011200'
|
||||||
|
|
||||||
@patch('utils.weather_sat_scheduler.predict_passes')
|
@patch('utils.weather_sat_predict.predict_passes')
|
||||||
@patch('threading.Timer')
|
@patch('threading.Timer')
|
||||||
def test_refresh_passes(self, mock_timer, mock_predict):
|
def test_refresh_passes(self, mock_timer, mock_predict):
|
||||||
"""_refresh_passes() should schedule future passes."""
|
"""_refresh_passes() should schedule future passes."""
|
||||||
@@ -361,7 +362,7 @@ class TestWeatherSatScheduler:
|
|||||||
assert scheduler._passes[0].satellite == 'NOAA-18'
|
assert scheduler._passes[0].satellite == 'NOAA-18'
|
||||||
mock_timer_instance.start.assert_called()
|
mock_timer_instance.start.assert_called()
|
||||||
|
|
||||||
@patch('utils.weather_sat_scheduler.predict_passes')
|
@patch('utils.weather_sat_predict.predict_passes')
|
||||||
def test_refresh_passes_skip_past(self, mock_predict):
|
def test_refresh_passes_skip_past(self, mock_predict):
|
||||||
"""_refresh_passes() should skip passes that already started."""
|
"""_refresh_passes() should skip passes that already started."""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -389,7 +390,42 @@ class TestWeatherSatScheduler:
|
|||||||
# Should not schedule past passes
|
# Should not schedule past passes
|
||||||
assert len(scheduler._passes) == 0
|
assert len(scheduler._passes) == 0
|
||||||
|
|
||||||
@patch('utils.weather_sat_scheduler.predict_passes')
|
@patch('utils.weather_sat_predict.predict_passes')
|
||||||
|
@patch('threading.Timer')
|
||||||
|
def test_refresh_passes_active_window_triggers_immediately(self, mock_timer, mock_predict):
|
||||||
|
"""_refresh_passes() should trigger immediately during an active pass window."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
active_pass = {
|
||||||
|
'id': 'NOAA-18_ACTIVE',
|
||||||
|
'satellite': 'NOAA-18',
|
||||||
|
'name': 'NOAA 18',
|
||||||
|
'frequency': 137.9125,
|
||||||
|
'mode': 'APT',
|
||||||
|
'startTimeISO': (now - timedelta(minutes=2)).isoformat(),
|
||||||
|
'endTimeISO': (now + timedelta(minutes=8)).isoformat(),
|
||||||
|
'maxEl': 45.0,
|
||||||
|
'duration': 10.0,
|
||||||
|
'quality': 'good',
|
||||||
|
}
|
||||||
|
mock_predict.return_value = [active_pass]
|
||||||
|
|
||||||
|
pass_timer = MagicMock()
|
||||||
|
refresh_timer = MagicMock()
|
||||||
|
mock_timer.side_effect = [pass_timer, refresh_timer]
|
||||||
|
|
||||||
|
scheduler = WeatherSatScheduler()
|
||||||
|
scheduler._enabled = True
|
||||||
|
scheduler._lat = 51.5
|
||||||
|
scheduler._lon = -0.1
|
||||||
|
|
||||||
|
scheduler._refresh_passes()
|
||||||
|
|
||||||
|
assert len(scheduler._passes) == 1
|
||||||
|
first_delay = mock_timer.call_args_list[0][0][0]
|
||||||
|
assert first_delay == pytest.approx(0.0, abs=0.01)
|
||||||
|
pass_timer.start.assert_called_once()
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_predict.predict_passes')
|
||||||
def test_refresh_passes_disabled(self, mock_predict):
|
def test_refresh_passes_disabled(self, mock_predict):
|
||||||
"""_refresh_passes() should do nothing when disabled."""
|
"""_refresh_passes() should do nothing when disabled."""
|
||||||
scheduler = WeatherSatScheduler()
|
scheduler = WeatherSatScheduler()
|
||||||
@@ -399,7 +435,7 @@ class TestWeatherSatScheduler:
|
|||||||
|
|
||||||
mock_predict.assert_not_called()
|
mock_predict.assert_not_called()
|
||||||
|
|
||||||
@patch('utils.weather_sat_scheduler.predict_passes')
|
@patch('utils.weather_sat_predict.predict_passes')
|
||||||
def test_refresh_passes_error_handling(self, mock_predict):
|
def test_refresh_passes_error_handling(self, mock_predict):
|
||||||
"""_refresh_passes() should handle prediction errors."""
|
"""_refresh_passes() should handle prediction errors."""
|
||||||
mock_predict.side_effect = Exception('TLE error')
|
mock_predict.side_effect = Exception('TLE error')
|
||||||
@@ -716,10 +752,28 @@ class TestSchedulerConfiguration:
|
|||||||
assert WEATHER_SAT_CAPTURE_BUFFER_SECONDS >= 0
|
assert WEATHER_SAT_CAPTURE_BUFFER_SECONDS >= 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestUtcIsoParsing:
|
||||||
|
"""Tests for UTC ISO timestamp parsing."""
|
||||||
|
|
||||||
|
def test_parse_utc_iso_with_z_suffix(self):
|
||||||
|
"""_parse_utc_iso should handle Z timestamps."""
|
||||||
|
dt = _parse_utc_iso('2026-02-19T12:34:56Z')
|
||||||
|
assert dt.tzinfo == timezone.utc
|
||||||
|
assert dt.hour == 12
|
||||||
|
assert dt.minute == 34
|
||||||
|
assert dt.second == 56
|
||||||
|
|
||||||
|
def test_parse_utc_iso_with_legacy_suffix(self):
|
||||||
|
"""_parse_utc_iso should handle legacy +00:00Z timestamps."""
|
||||||
|
dt = _parse_utc_iso('2026-02-19T12:34:56+00:00Z')
|
||||||
|
assert dt.tzinfo == timezone.utc
|
||||||
|
assert dt.hour == 12
|
||||||
|
|
||||||
|
|
||||||
class TestSchedulerIntegration:
|
class TestSchedulerIntegration:
|
||||||
"""Integration tests for scheduler."""
|
"""Integration tests for scheduler."""
|
||||||
|
|
||||||
@patch('utils.weather_sat_scheduler.predict_passes')
|
@patch('utils.weather_sat_predict.predict_passes')
|
||||||
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
|
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
|
||||||
@patch('threading.Timer')
|
@patch('threading.Timer')
|
||||||
def test_full_scheduling_cycle(self, mock_timer, mock_get_decoder, mock_predict):
|
def test_full_scheduling_cycle(self, mock_timer, mock_get_decoder, mock_predict):
|
||||||
|
|||||||
+72
-47
@@ -81,25 +81,31 @@ def resolve_rpa(irk: bytes, address: str) -> bool:
|
|||||||
return computed_hash == expected_hash
|
return computed_hash == expected_hash
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LocateTarget:
|
class LocateTarget:
|
||||||
"""Target device specification for locate session."""
|
"""Target device specification for locate session."""
|
||||||
mac_address: str | None = None
|
mac_address: str | None = None
|
||||||
name_pattern: str | None = None
|
name_pattern: str | None = None
|
||||||
irk_hex: str | None = None
|
irk_hex: str | None = None
|
||||||
device_id: str | None = None
|
device_id: str | None = None
|
||||||
# Hand-off metadata from Bluetooth mode
|
device_key: str | None = None
|
||||||
known_name: str | None = None
|
fingerprint_id: str | None = None
|
||||||
known_manufacturer: str | None = None
|
# Hand-off metadata from Bluetooth mode
|
||||||
last_known_rssi: int | None = None
|
known_name: str | None = None
|
||||||
|
known_manufacturer: str | None = None
|
||||||
def matches(self, device: BTDeviceAggregate) -> bool:
|
last_known_rssi: int | None = None
|
||||||
"""Check if a device matches this target."""
|
|
||||||
# Match by device_id (exact)
|
def matches(self, device: BTDeviceAggregate) -> bool:
|
||||||
if self.device_id and device.device_id == self.device_id:
|
"""Check if a device matches this target."""
|
||||||
return True
|
# Match by stable device key (survives MAC randomization for many devices)
|
||||||
|
if self.device_key and getattr(device, 'device_key', None) == self.device_key:
|
||||||
# Match by device_id address portion (without :address_type suffix)
|
return True
|
||||||
|
|
||||||
|
# Match by device_id (exact)
|
||||||
|
if self.device_id and device.device_id == self.device_id:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Match by device_id address portion (without :address_type suffix)
|
||||||
if self.device_id and ':' in self.device_id:
|
if self.device_id and ':' in self.device_id:
|
||||||
target_addr_part = self.device_id.rsplit(':', 1)[0].upper()
|
target_addr_part = self.device_id.rsplit(':', 1)[0].upper()
|
||||||
dev_addr = (device.address or '').upper()
|
dev_addr = (device.address or '').upper()
|
||||||
@@ -107,38 +113,57 @@ class LocateTarget:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
# Match by MAC/address (case-insensitive, normalize separators)
|
# Match by MAC/address (case-insensitive, normalize separators)
|
||||||
if self.mac_address:
|
if self.mac_address:
|
||||||
dev_addr = (device.address or '').upper().replace('-', ':')
|
dev_addr = (device.address or '').upper().replace('-', ':')
|
||||||
target_addr = self.mac_address.upper().replace('-', ':')
|
target_addr = self.mac_address.upper().replace('-', ':')
|
||||||
if dev_addr == target_addr:
|
if dev_addr == target_addr:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Match by RPA resolution
|
# Match by payload fingerprint (guard against low-stability generic fingerprints)
|
||||||
if self.irk_hex:
|
if self.fingerprint_id:
|
||||||
try:
|
dev_fp = getattr(device, 'payload_fingerprint_id', None)
|
||||||
irk = bytes.fromhex(self.irk_hex)
|
dev_fp_stability = getattr(device, 'payload_fingerprint_stability', 0.0) or 0.0
|
||||||
if len(irk) == 16 and device.address and resolve_rpa(irk, device.address):
|
if dev_fp and dev_fp == self.fingerprint_id and dev_fp_stability >= 0.35:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Match by RPA resolution
|
||||||
|
if self.irk_hex:
|
||||||
|
try:
|
||||||
|
irk = bytes.fromhex(self.irk_hex)
|
||||||
|
if len(irk) == 16 and device.address and resolve_rpa(irk, device.address):
|
||||||
return True
|
return True
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
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():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Match by known_name from handoff (exact name match)
|
# Match by known_name from handoff (exact or loose normalized match)
|
||||||
return bool(self.known_name and device.name and self.known_name.lower() == device.name.lower())
|
if self.known_name and device.name:
|
||||||
|
target_name = self.known_name.strip().lower()
|
||||||
def to_dict(self) -> dict:
|
device_name = device.name.strip().lower()
|
||||||
return {
|
if target_name and (
|
||||||
'mac_address': self.mac_address,
|
target_name == device_name
|
||||||
'name_pattern': self.name_pattern,
|
or target_name in device_name
|
||||||
'irk_hex': self.irk_hex,
|
or device_name in target_name
|
||||||
'device_id': self.device_id,
|
):
|
||||||
'known_name': self.known_name,
|
return True
|
||||||
'known_manufacturer': self.known_manufacturer,
|
|
||||||
'last_known_rssi': self.last_known_rssi,
|
return False
|
||||||
}
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
'mac_address': self.mac_address,
|
||||||
|
'name_pattern': self.name_pattern,
|
||||||
|
'irk_hex': self.irk_hex,
|
||||||
|
'device_id': self.device_id,
|
||||||
|
'device_key': self.device_key,
|
||||||
|
'fingerprint_id': self.fingerprint_id,
|
||||||
|
'known_name': self.known_name,
|
||||||
|
'known_manufacturer': self.known_manufacturer,
|
||||||
|
'last_known_rssi': self.last_known_rssi,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class DistanceEstimator:
|
class DistanceEstimator:
|
||||||
|
|||||||
@@ -6,16 +6,42 @@ from typing import Any
|
|||||||
|
|
||||||
from utils.alerts import get_alert_manager
|
from utils.alerts import get_alert_manager
|
||||||
from utils.recording import get_recording_manager
|
from utils.recording import get_recording_manager
|
||||||
|
from utils.temporal_patterns import get_pattern_detector
|
||||||
|
|
||||||
IGNORE_TYPES = {'keepalive', 'ping'}
|
IGNORE_TYPES = {'keepalive', 'ping'}
|
||||||
|
|
||||||
|
|
||||||
|
DEVICE_ID_FIELDS = (
|
||||||
|
'device_id',
|
||||||
|
'id',
|
||||||
|
'mac',
|
||||||
|
'mac_address',
|
||||||
|
'address',
|
||||||
|
'bssid',
|
||||||
|
'station_mac',
|
||||||
|
'client_mac',
|
||||||
|
'icao',
|
||||||
|
'callsign',
|
||||||
|
'mmsi',
|
||||||
|
'uuid',
|
||||||
|
'hash',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def process_event(mode: str, event: dict | Any, event_type: str | None = None) -> None:
|
def process_event(mode: str, event: dict | Any, event_type: str | None = None) -> None:
|
||||||
if event_type in IGNORE_TYPES:
|
if event_type in IGNORE_TYPES:
|
||||||
return
|
return
|
||||||
if not isinstance(event, dict):
|
if not isinstance(event, dict):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
device_id = _extract_device_id(event)
|
||||||
|
if device_id:
|
||||||
|
try:
|
||||||
|
get_pattern_detector().record_event(device_id=device_id, mode=mode)
|
||||||
|
except Exception:
|
||||||
|
# Pattern tracking should not break ingest pipeline
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
get_recording_manager().record_event(mode, event, event_type)
|
get_recording_manager().record_event(mode, event, event_type)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -27,3 +53,22 @@ def process_event(mode: str, event: dict | Any, event_type: str | None = None) -
|
|||||||
except Exception:
|
except Exception:
|
||||||
# Alert failures should never break streaming
|
# Alert failures should never break streaming
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_device_id(event: dict) -> str | None:
|
||||||
|
for field in DEVICE_ID_FIELDS:
|
||||||
|
value = event.get(field)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
text = str(value).strip()
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
nested_candidates = ('target', 'device', 'source', 'aircraft', 'vessel')
|
||||||
|
for key in nested_candidates:
|
||||||
|
nested = event.get(key)
|
||||||
|
if isinstance(nested, dict):
|
||||||
|
nested_id = _extract_device_id(nested)
|
||||||
|
if nested_id:
|
||||||
|
return nested_id
|
||||||
|
return None
|
||||||
|
|||||||
+166
-44
@@ -1,48 +1,170 @@
|
|||||||
"""Server-Sent Events (SSE) utilities."""
|
"""Server-Sent Events (SSE) utilities."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import queue
|
import queue
|
||||||
import time
|
import threading
|
||||||
from typing import Any, Generator
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Callable, Generator
|
||||||
def sse_stream(
|
|
||||||
data_queue: queue.Queue,
|
|
||||||
timeout: float = 1.0,
|
@dataclass
|
||||||
keepalive_interval: float = 30.0,
|
class _QueueFanoutChannel:
|
||||||
stop_check: callable = None
|
"""Internal fanout state for a source queue."""
|
||||||
) -> Generator[str, None, None]:
|
source_queue: queue.Queue
|
||||||
|
source_timeout: float
|
||||||
|
subscribers: set[queue.Queue] = field(default_factory=set)
|
||||||
|
lock: threading.Lock = field(default_factory=threading.Lock)
|
||||||
|
distributor: threading.Thread | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_fanout_channels: dict[str, _QueueFanoutChannel] = {}
|
||||||
|
_fanout_channels_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_fanout(channel: _QueueFanoutChannel) -> None:
|
||||||
|
"""Drain source queue and fan out each message to all subscribers."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = channel.source_queue.get(timeout=channel.source_timeout)
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
|
|
||||||
|
with channel.lock:
|
||||||
|
subscribers = tuple(channel.subscribers)
|
||||||
|
|
||||||
|
for subscriber in subscribers:
|
||||||
|
try:
|
||||||
|
subscriber.put_nowait(msg)
|
||||||
|
except queue.Full:
|
||||||
|
# Drop oldest frame for this subscriber and retry once.
|
||||||
|
try:
|
||||||
|
subscriber.get_nowait()
|
||||||
|
subscriber.put_nowait(msg)
|
||||||
|
except (queue.Empty, queue.Full):
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_fanout_channel(
|
||||||
|
channel_key: str,
|
||||||
|
source_queue: queue.Queue,
|
||||||
|
source_timeout: float,
|
||||||
|
) -> _QueueFanoutChannel:
|
||||||
|
"""Get/create a fanout channel and ensure distributor thread is running."""
|
||||||
|
with _fanout_channels_lock:
|
||||||
|
channel = _fanout_channels.get(channel_key)
|
||||||
|
if channel is None:
|
||||||
|
channel = _QueueFanoutChannel(source_queue=source_queue, source_timeout=source_timeout)
|
||||||
|
_fanout_channels[channel_key] = channel
|
||||||
|
|
||||||
|
if channel.distributor is None or not channel.distributor.is_alive():
|
||||||
|
channel.distributor = threading.Thread(
|
||||||
|
target=_run_fanout,
|
||||||
|
args=(channel,),
|
||||||
|
daemon=True,
|
||||||
|
name=f"sse-fanout-{channel_key}",
|
||||||
|
)
|
||||||
|
channel.distributor.start()
|
||||||
|
|
||||||
|
return channel
|
||||||
|
|
||||||
|
|
||||||
|
def subscribe_fanout_queue(
|
||||||
|
source_queue: queue.Queue,
|
||||||
|
channel_key: str,
|
||||||
|
source_timeout: float = 1.0,
|
||||||
|
subscriber_queue_size: int = 500,
|
||||||
|
) -> tuple[queue.Queue, Callable[[], None]]:
|
||||||
|
"""
|
||||||
|
Subscribe a client queue to a shared source queue fanout channel.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (subscriber_queue, unsubscribe_fn)
|
||||||
|
"""
|
||||||
|
channel = _ensure_fanout_channel(channel_key, source_queue, source_timeout)
|
||||||
|
subscriber = queue.Queue(maxsize=subscriber_queue_size)
|
||||||
|
|
||||||
|
with channel.lock:
|
||||||
|
channel.subscribers.add(subscriber)
|
||||||
|
|
||||||
|
def _unsubscribe() -> None:
|
||||||
|
with channel.lock:
|
||||||
|
channel.subscribers.discard(subscriber)
|
||||||
|
|
||||||
|
return subscriber, _unsubscribe
|
||||||
|
|
||||||
|
|
||||||
|
def sse_stream_fanout(
|
||||||
|
source_queue: queue.Queue,
|
||||||
|
channel_key: str,
|
||||||
|
timeout: float = 1.0,
|
||||||
|
keepalive_interval: float = 30.0,
|
||||||
|
stop_check: Callable[[], bool] | None = None,
|
||||||
|
on_message: Callable[[dict[str, Any]], None] | None = None,
|
||||||
|
) -> Generator[str, None, None]:
|
||||||
|
"""
|
||||||
|
Generate an SSE stream from a fanout channel backed by source_queue.
|
||||||
|
"""
|
||||||
|
subscriber, unsubscribe = subscribe_fanout_queue(
|
||||||
|
source_queue=source_queue,
|
||||||
|
channel_key=channel_key,
|
||||||
|
source_timeout=timeout,
|
||||||
|
)
|
||||||
|
last_keepalive = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if stop_check and stop_check():
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = subscriber.get(timeout=timeout)
|
||||||
|
last_keepalive = time.time()
|
||||||
|
if on_message and isinstance(msg, dict):
|
||||||
|
try:
|
||||||
|
on_message(msg)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
yield format_sse(msg)
|
||||||
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
finally:
|
||||||
|
unsubscribe()
|
||||||
|
|
||||||
|
|
||||||
|
def sse_stream(
|
||||||
|
data_queue: queue.Queue,
|
||||||
|
timeout: float = 1.0,
|
||||||
|
keepalive_interval: float = 30.0,
|
||||||
|
stop_check: Callable[[], bool] | None = None,
|
||||||
|
channel_key: str | None = None,
|
||||||
|
) -> Generator[str, None, None]:
|
||||||
"""
|
"""
|
||||||
Generate SSE stream from a queue.
|
Generate SSE stream from a queue.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data_queue: Queue to read messages from
|
data_queue: Queue to read messages from
|
||||||
timeout: Queue get timeout in seconds
|
timeout: Queue get timeout in seconds
|
||||||
keepalive_interval: Seconds between keepalive messages
|
keepalive_interval: Seconds between keepalive messages
|
||||||
stop_check: Optional callable that returns True to stop the stream
|
stop_check: Optional callable that returns True to stop the stream
|
||||||
|
channel_key: Optional fanout key; defaults to stable queue id
|
||||||
Yields:
|
|
||||||
SSE formatted strings
|
Yields:
|
||||||
"""
|
SSE formatted strings
|
||||||
last_keepalive = time.time()
|
"""
|
||||||
|
key = channel_key or f"queue:{id(data_queue)}"
|
||||||
while True:
|
yield from sse_stream_fanout(
|
||||||
# Check if we should stop
|
source_queue=data_queue,
|
||||||
if stop_check and stop_check():
|
channel_key=key,
|
||||||
break
|
timeout=timeout,
|
||||||
|
keepalive_interval=keepalive_interval,
|
||||||
try:
|
stop_check=stop_check,
|
||||||
msg = data_queue.get(timeout=timeout)
|
)
|
||||||
last_keepalive = time.time()
|
|
||||||
yield format_sse(msg)
|
|
||||||
except queue.Empty:
|
|
||||||
# Send keepalive if enough time has passed
|
|
||||||
now = time.time()
|
|
||||||
if now - last_keepalive >= keepalive_interval:
|
|
||||||
yield format_sse({'type': 'keepalive'})
|
|
||||||
last_keepalive = now
|
|
||||||
|
|
||||||
|
|
||||||
def format_sse(data: dict[str, Any] | str, event: str | None = None) -> str:
|
def format_sse(data: dict[str, Any] | str, event: str | None = None) -> str:
|
||||||
|
|||||||
@@ -59,15 +59,15 @@ VIS_CODES: dict[int, str] = {
|
|||||||
40: 'Martin2',
|
40: 'Martin2',
|
||||||
60: 'Scottie1',
|
60: 'Scottie1',
|
||||||
56: 'Scottie2',
|
56: 'Scottie2',
|
||||||
93: 'PD120',
|
95: 'PD120',
|
||||||
95: 'PD180',
|
97: 'PD180',
|
||||||
# Less common but recognized
|
# Less common but recognized
|
||||||
4: 'Robot24',
|
4: 'Robot24',
|
||||||
36: 'Martin3',
|
36: 'Martin3',
|
||||||
52: 'Scottie3',
|
52: 'Scottie3',
|
||||||
55: 'ScottieDX',
|
76: 'ScottieDX',
|
||||||
113: 'PD240',
|
96: 'PD240',
|
||||||
96: 'PD90',
|
99: 'PD90',
|
||||||
98: 'PD160',
|
98: 'PD160',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+145
-40
@@ -98,14 +98,16 @@ class SSTVImageDecoder:
|
|||||||
self._channel_data.append(
|
self._channel_data.append(
|
||||||
np.zeros((mode.height, mode.width), dtype=np.uint8))
|
np.zeros((mode.height, mode.width), dtype=np.uint8))
|
||||||
|
|
||||||
# Pre-compute candidate frequencies for batch pixel decoding (5 Hz step)
|
|
||||||
self._freq_candidates = np.arange(
|
|
||||||
FREQ_PIXEL_LOW - 100, FREQ_PIXEL_HIGH + 105, 5.0)
|
|
||||||
|
|
||||||
# Track sync position for re-synchronization
|
# Track sync position for re-synchronization
|
||||||
self._expected_line_start = 0 # Sample offset within buffer
|
self._expected_line_start = 0 # Sample offset within buffer
|
||||||
self._synced = False
|
self._synced = False
|
||||||
|
|
||||||
|
# Per-line mid-sync deviation measurements (Scottie modes only).
|
||||||
|
# Each entry is the measured offset (in samples) of the sync pulse
|
||||||
|
# relative to its expected position: negative = early, positive = late.
|
||||||
|
# Used by get_image() to apply post-processing slant correction.
|
||||||
|
self._sync_deviations: list[float | None] = []
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_complete(self) -> bool:
|
def is_complete(self) -> bool:
|
||||||
return self._complete
|
return self._complete
|
||||||
@@ -239,7 +241,51 @@ class SSTVImageDecoder:
|
|||||||
# Scottie: separator between G and B
|
# Scottie: separator between G and B
|
||||||
pos += self._porch_samples
|
pos += self._porch_samples
|
||||||
else:
|
else:
|
||||||
# Scottie: sync + porch between B and R
|
# Scottie: sync + porch between B and R.
|
||||||
|
# Measure the actual sync position for post-processing
|
||||||
|
# slant correction without touching pos or consumed —
|
||||||
|
# so a noisy/false measurement never corrupts the decode.
|
||||||
|
r_samples = self._channel_samples[-1]
|
||||||
|
# Large window to cover cumulative SDR clock drift over
|
||||||
|
# the full image. bwd=50 saturates after ~25 lines for
|
||||||
|
# a 2-sample/line drift; 800 samples covers ±200 ppm.
|
||||||
|
bwd = min(800, pos)
|
||||||
|
remaining = len(self._buffer) - pos
|
||||||
|
fwd = min(800, max(
|
||||||
|
0,
|
||||||
|
remaining - self._sync_samples
|
||||||
|
- self._porch_samples - r_samples))
|
||||||
|
deviation: float | None = None
|
||||||
|
if bwd + fwd > 0:
|
||||||
|
region_start = pos - bwd
|
||||||
|
sync_region = self._buffer[
|
||||||
|
region_start: pos + self._sync_samples + fwd]
|
||||||
|
# Full-sync-length window gives best freq resolution
|
||||||
|
# (~111 Hz at 48 kHz) to cleanly separate 1200 Hz
|
||||||
|
# sync from 1500 Hz pixel data.
|
||||||
|
win = self._sync_samples
|
||||||
|
n_raw = len(sync_region) - win + 1
|
||||||
|
if n_raw > 0:
|
||||||
|
# Step 5 samples (~0.1 ms) — enough resolution
|
||||||
|
# for pixel-level drift, keeps batch size small.
|
||||||
|
step = 5
|
||||||
|
all_windows = (
|
||||||
|
np.lib.stride_tricks.sliding_window_view(
|
||||||
|
sync_region, win))
|
||||||
|
windows = all_windows[::step]
|
||||||
|
energies = goertzel_batch(
|
||||||
|
windows,
|
||||||
|
np.array([FREQ_SYNC, FREQ_BLACK]),
|
||||||
|
self._sample_rate)
|
||||||
|
sync_e = energies[:, 0]
|
||||||
|
black_e = energies[:, 1]
|
||||||
|
valid_mask = sync_e > black_e * 2
|
||||||
|
if valid_mask.any():
|
||||||
|
fine_idx = int(
|
||||||
|
np.argmax(
|
||||||
|
np.where(valid_mask, sync_e, 0.0)))
|
||||||
|
deviation = float(fine_idx * step - bwd)
|
||||||
|
self._sync_deviations.append(deviation)
|
||||||
pos += self._sync_samples + self._porch_samples
|
pos += self._sync_samples + self._porch_samples
|
||||||
elif self._separator_samples > 0:
|
elif self._separator_samples > 0:
|
||||||
# Robot: separator + porch between channels
|
# Robot: separator + porch between channels
|
||||||
@@ -261,18 +307,16 @@ class SSTVImageDecoder:
|
|||||||
if self._current_line >= self._total_audio_lines:
|
if self._current_line >= self._total_audio_lines:
|
||||||
self._complete = True
|
self._complete = True
|
||||||
|
|
||||||
# Minimum analysis window for meaningful Goertzel frequency estimation.
|
|
||||||
# With 96 samples (2ms at 48kHz), frequency accuracy is within ~25 Hz,
|
|
||||||
# giving pixel-level accuracy of ~8/255 levels.
|
|
||||||
_MIN_ANALYSIS_WINDOW = 96
|
|
||||||
|
|
||||||
def _decode_channel_pixels(self, audio: np.ndarray) -> np.ndarray:
|
def _decode_channel_pixels(self, audio: np.ndarray) -> np.ndarray:
|
||||||
"""Decode pixel values from a channel's audio data.
|
"""Decode pixel values from a channel's audio data.
|
||||||
|
|
||||||
Uses batch Goertzel to estimate frequencies for all pixels
|
Uses the analytic signal (Hilbert transform via FFT) to compute
|
||||||
simultaneously, then maps to luminance values. When pixels have
|
the instantaneous frequency at every sample, then averages over
|
||||||
fewer samples than ``_MIN_ANALYSIS_WINDOW``, overlapping analysis
|
each pixel's duration. This is the same FM-demodulation approach
|
||||||
windows are used to maintain frequency estimation accuracy.
|
used by QSSTV and other professional SSTV decoders, and provides
|
||||||
|
far better frequency resolution than windowed Goertzel — especially
|
||||||
|
for fast modes (Martin2, Scottie2) where each pixel spans only
|
||||||
|
~11-13 audio samples.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
audio: Audio samples for one channel of one scanline.
|
audio: Audio samples for one channel of one scanline.
|
||||||
@@ -281,36 +325,48 @@ class SSTVImageDecoder:
|
|||||||
Array of pixel values (0-255), shape (width,).
|
Array of pixel values (0-255), shape (width,).
|
||||||
"""
|
"""
|
||||||
width = self._mode.width
|
width = self._mode.width
|
||||||
samples_per_pixel = max(1, len(audio) // width)
|
n = len(audio)
|
||||||
|
|
||||||
if len(audio) < width or samples_per_pixel < 2:
|
if n < width:
|
||||||
return np.zeros(width, dtype=np.uint8)
|
return np.zeros(width, dtype=np.uint8)
|
||||||
|
|
||||||
window_size = max(samples_per_pixel, self._MIN_ANALYSIS_WINDOW)
|
# --- Analytic signal via Hilbert transform (FFT method) ---
|
||||||
|
spectrum = np.fft.fft(audio)
|
||||||
|
|
||||||
if window_size > samples_per_pixel and len(audio) >= window_size:
|
# Build the analytic-signal multiplier:
|
||||||
# Use overlapping windows centered on each pixel position
|
# h[0] = 1 (DC), h[1..N/2-1] = 2 (positive freqs),
|
||||||
windows = np.lib.stride_tricks.sliding_window_view(
|
# h[N/2] = 1 (Nyquist), h[N/2+1..] = 0 (negative freqs)
|
||||||
audio, window_size)
|
h = np.zeros(n)
|
||||||
# Pixel centers, clamped to valid window indices
|
if n % 2 == 0:
|
||||||
centers = np.arange(width) * samples_per_pixel
|
h[0] = h[n // 2] = 1
|
||||||
indices = np.minimum(centers, len(windows) - 1)
|
h[1:n // 2] = 2
|
||||||
audio_matrix = np.ascontiguousarray(windows[indices])
|
|
||||||
else:
|
else:
|
||||||
# Non-overlapping: each pixel has enough samples
|
h[0] = 1
|
||||||
usable = width * samples_per_pixel
|
h[1:(n + 1) // 2] = 2
|
||||||
audio_matrix = audio[:usable].reshape(width, samples_per_pixel)
|
|
||||||
|
|
||||||
# Batch Goertzel at all candidate frequencies
|
analytic = np.fft.ifft(spectrum * h)
|
||||||
energies = goertzel_batch(
|
|
||||||
audio_matrix, self._freq_candidates, self._sample_rate)
|
|
||||||
|
|
||||||
# Find peak frequency per pixel
|
# --- Instantaneous frequency ---
|
||||||
best_idx = np.argmax(energies, axis=1)
|
phase = np.unwrap(np.angle(analytic))
|
||||||
best_freqs = self._freq_candidates[best_idx]
|
inst_freq = np.diff(phase) * (self._sample_rate / (2.0 * np.pi))
|
||||||
|
|
||||||
# Map frequencies to pixel values (1500 Hz = 0, 2300 Hz = 255)
|
# --- Average frequency per pixel ---
|
||||||
normalized = (best_freqs - FREQ_PIXEL_LOW) / (FREQ_PIXEL_HIGH - FREQ_PIXEL_LOW)
|
freq_len = len(inst_freq)
|
||||||
|
if freq_len < width:
|
||||||
|
# Fewer freq samples than pixels — index directly
|
||||||
|
indices = np.linspace(0, freq_len - 1, width).astype(int)
|
||||||
|
avg_freqs = inst_freq[indices]
|
||||||
|
else:
|
||||||
|
pixel_edges = np.linspace(0, freq_len, width + 1).astype(int)
|
||||||
|
segment_starts = pixel_edges[:-1]
|
||||||
|
segment_lengths = np.diff(pixel_edges)
|
||||||
|
segment_lengths = np.maximum(segment_lengths, 1)
|
||||||
|
sums = np.add.reduceat(inst_freq, segment_starts)
|
||||||
|
avg_freqs = sums / segment_lengths
|
||||||
|
|
||||||
|
# Map to pixel values (1500 Hz → 0, 2300 Hz → 255)
|
||||||
|
normalized = (avg_freqs - FREQ_PIXEL_LOW) / (
|
||||||
|
FREQ_PIXEL_HIGH - FREQ_PIXEL_LOW)
|
||||||
return np.clip(normalized * 255 + 0.5, 0, 255).astype(np.uint8)
|
return np.clip(normalized * 255 + 0.5, 0, 255).astype(np.uint8)
|
||||||
|
|
||||||
def get_image(self) -> Image.Image | None:
|
def get_image(self) -> Image.Image | None:
|
||||||
@@ -326,13 +382,62 @@ class SSTVImageDecoder:
|
|||||||
mode = self._mode
|
mode = self._mode
|
||||||
|
|
||||||
if mode.color_model == ColorModel.RGB:
|
if mode.color_model == ColorModel.RGB:
|
||||||
return self._assemble_rgb()
|
img = self._assemble_rgb()
|
||||||
elif mode.color_model == ColorModel.YCRCB:
|
elif mode.color_model == ColorModel.YCRCB:
|
||||||
return self._assemble_ycrcb()
|
img = self._assemble_ycrcb()
|
||||||
elif mode.color_model == ColorModel.YCRCB_DUAL:
|
elif mode.color_model == ColorModel.YCRCB_DUAL:
|
||||||
return self._assemble_ycrcb_dual()
|
img = self._assemble_ycrcb_dual()
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
return None
|
return self._apply_slant_correction(img)
|
||||||
|
|
||||||
|
def _apply_slant_correction(self, img: Image.Image) -> Image.Image:
|
||||||
|
"""Apply per-row horizontal correction based on measured sync drift.
|
||||||
|
|
||||||
|
Uses the sync deviation measurements collected during decoding to
|
||||||
|
estimate the per-line SDR clock drift rate via linear regression,
|
||||||
|
then circularly shifts each row to compensate. Noisy individual
|
||||||
|
measurements are averaged out; if fewer than 10 valid measurements
|
||||||
|
exist the image is returned unchanged.
|
||||||
|
"""
|
||||||
|
valid = [(i, d) for i, d in enumerate(self._sync_deviations)
|
||||||
|
if d is not None]
|
||||||
|
if len(valid) < 10:
|
||||||
|
return img
|
||||||
|
|
||||||
|
lines = np.array([x[0] for x in valid], dtype=np.float64)
|
||||||
|
devs = np.array([x[1] for x in valid], dtype=np.float64)
|
||||||
|
|
||||||
|
# Linear fit: deviation[i] ≈ slope × i + intercept.
|
||||||
|
# slope < 0 → fast SDR (sync arrives early, image leans left).
|
||||||
|
# slope > 0 → slow SDR (sync arrives late, image leans right).
|
||||||
|
slope, _ = np.polyfit(lines, devs, 1)
|
||||||
|
|
||||||
|
# Convert samples/line drift to pixels/line for the channel width.
|
||||||
|
pixels_per_line = slope * self._mode.width / self._channel_samples[0]
|
||||||
|
|
||||||
|
# Skip correction if drift is negligible (< 1 px over 20 lines).
|
||||||
|
if abs(pixels_per_line) < 0.05:
|
||||||
|
return img
|
||||||
|
|
||||||
|
arr = np.array(img)
|
||||||
|
height, width = arr.shape[:2]
|
||||||
|
|
||||||
|
# Reject clearly implausible estimates. Even with cheap SDR clocks,
|
||||||
|
# real SSTV slant is typically modest; extreme values are usually
|
||||||
|
# bad sync picks that would over-correct the image.
|
||||||
|
total_shift = abs((height - 1) * pixels_per_line)
|
||||||
|
if total_shift > width * 0.25:
|
||||||
|
return img
|
||||||
|
|
||||||
|
corrected = np.empty_like(arr)
|
||||||
|
|
||||||
|
for row in range(height):
|
||||||
|
shift = -int(round(row * pixels_per_line))
|
||||||
|
corrected[row] = np.roll(arr[row], shift=shift, axis=0)
|
||||||
|
|
||||||
|
return Image.fromarray(corrected, 'RGB')
|
||||||
|
|
||||||
def _assemble_rgb(self) -> Image.Image:
|
def _assemble_rgb(self) -> Image.Image:
|
||||||
"""Assemble RGB image from sequential R, G, B channel data.
|
"""Assemble RGB image from sequential R, G, B channel data.
|
||||||
|
|||||||
+79
-4
@@ -189,7 +189,7 @@ SCOTTIE_2 = SSTVMode(
|
|||||||
|
|
||||||
PD_120 = SSTVMode(
|
PD_120 = SSTVMode(
|
||||||
name='PD120',
|
name='PD120',
|
||||||
vis_code=93,
|
vis_code=95,
|
||||||
width=640,
|
width=640,
|
||||||
height=496,
|
height=496,
|
||||||
color_model=ColorModel.YCRCB_DUAL,
|
color_model=ColorModel.YCRCB_DUAL,
|
||||||
@@ -207,7 +207,7 @@ PD_120 = SSTVMode(
|
|||||||
|
|
||||||
PD_180 = SSTVMode(
|
PD_180 = SSTVMode(
|
||||||
name='PD180',
|
name='PD180',
|
||||||
vis_code=95,
|
vis_code=97,
|
||||||
width=640,
|
width=640,
|
||||||
height=496,
|
height=496,
|
||||||
color_model=ColorModel.YCRCB_DUAL,
|
color_model=ColorModel.YCRCB_DUAL,
|
||||||
@@ -223,6 +223,81 @@ PD_180 = SSTVMode(
|
|||||||
line_duration_ms=754.240,
|
line_duration_ms=754.240,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
PD_90 = SSTVMode(
|
||||||
|
name='PD90',
|
||||||
|
vis_code=99,
|
||||||
|
width=640,
|
||||||
|
height=496,
|
||||||
|
color_model=ColorModel.YCRCB_DUAL,
|
||||||
|
sync_position=SyncPosition.FRONT_PD,
|
||||||
|
sync_duration_ms=20.0,
|
||||||
|
sync_porch_ms=2.080,
|
||||||
|
channels=[
|
||||||
|
ChannelTiming(duration_ms=91.520), # Y1
|
||||||
|
ChannelTiming(duration_ms=91.520), # Cr
|
||||||
|
ChannelTiming(duration_ms=91.520), # Cb
|
||||||
|
ChannelTiming(duration_ms=91.520), # Y2
|
||||||
|
],
|
||||||
|
line_duration_ms=388.160,
|
||||||
|
)
|
||||||
|
|
||||||
|
PD_160 = SSTVMode(
|
||||||
|
name='PD160',
|
||||||
|
vis_code=98,
|
||||||
|
width=640,
|
||||||
|
height=496,
|
||||||
|
color_model=ColorModel.YCRCB_DUAL,
|
||||||
|
sync_position=SyncPosition.FRONT_PD,
|
||||||
|
sync_duration_ms=20.0,
|
||||||
|
sync_porch_ms=2.080,
|
||||||
|
channels=[
|
||||||
|
ChannelTiming(duration_ms=152.960), # Y1
|
||||||
|
ChannelTiming(duration_ms=152.960), # Cr
|
||||||
|
ChannelTiming(duration_ms=152.960), # Cb
|
||||||
|
ChannelTiming(duration_ms=152.960), # Y2
|
||||||
|
],
|
||||||
|
line_duration_ms=633.920,
|
||||||
|
)
|
||||||
|
|
||||||
|
PD_240 = SSTVMode(
|
||||||
|
name='PD240',
|
||||||
|
vis_code=96,
|
||||||
|
width=640,
|
||||||
|
height=496,
|
||||||
|
color_model=ColorModel.YCRCB_DUAL,
|
||||||
|
sync_position=SyncPosition.FRONT_PD,
|
||||||
|
sync_duration_ms=20.0,
|
||||||
|
sync_porch_ms=2.080,
|
||||||
|
channels=[
|
||||||
|
ChannelTiming(duration_ms=244.480), # Y1
|
||||||
|
ChannelTiming(duration_ms=244.480), # Cr
|
||||||
|
ChannelTiming(duration_ms=244.480), # Cb
|
||||||
|
ChannelTiming(duration_ms=244.480), # Y2
|
||||||
|
],
|
||||||
|
line_duration_ms=1000.000,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scottie DX
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SCOTTIE_DX = SSTVMode(
|
||||||
|
name='ScottieDX',
|
||||||
|
vis_code=76,
|
||||||
|
width=320,
|
||||||
|
height=256,
|
||||||
|
color_model=ColorModel.RGB,
|
||||||
|
sync_position=SyncPosition.MIDDLE,
|
||||||
|
sync_duration_ms=9.0,
|
||||||
|
sync_porch_ms=1.5,
|
||||||
|
channels=[
|
||||||
|
ChannelTiming(duration_ms=345.600), # Green
|
||||||
|
ChannelTiming(duration_ms=345.600), # Blue
|
||||||
|
ChannelTiming(duration_ms=345.600), # Red
|
||||||
|
],
|
||||||
|
line_duration_ms=1050.300,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Mode registry
|
# Mode registry
|
||||||
@@ -232,8 +307,8 @@ ALL_MODES: dict[int, SSTVMode] = {
|
|||||||
m.vis_code: m for m in [
|
m.vis_code: m for m in [
|
||||||
ROBOT_36, ROBOT_72,
|
ROBOT_36, ROBOT_72,
|
||||||
MARTIN_1, MARTIN_2,
|
MARTIN_1, MARTIN_2,
|
||||||
SCOTTIE_1, SCOTTIE_2,
|
SCOTTIE_1, SCOTTIE_2, SCOTTIE_DX,
|
||||||
PD_120, PD_180,
|
PD_90, PD_120, PD_160, PD_180, PD_240,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -464,15 +464,24 @@ class SSTVDecoder:
|
|||||||
result = vis_detector.feed(samples)
|
result = vis_detector.feed(samples)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
vis_code, mode_name = result
|
vis_code, mode_name = result
|
||||||
logger.info(f"VIS detected: code={vis_code}, mode={mode_name}")
|
# Capture samples that arrived after the VIS STOP_BIT —
|
||||||
|
# these are the start of the image and must be fed into
|
||||||
|
# the image decoder before the next chunk arrives.
|
||||||
|
remaining = vis_detector.remaining_buffer.copy()
|
||||||
|
vis_detector.reset()
|
||||||
|
logger.info(f"VIS detected: code={vis_code}, mode={mode_name}, "
|
||||||
|
f"{len(remaining)} image-start samples retained")
|
||||||
|
|
||||||
mode_spec = get_mode(vis_code)
|
mode_spec = get_mode(vis_code)
|
||||||
if mode_spec:
|
if mode_spec:
|
||||||
current_mode_name = mode_name
|
current_mode_name = mode_name
|
||||||
|
last_partial_pct = -1
|
||||||
image_decoder = SSTVImageDecoder(
|
image_decoder = SSTVImageDecoder(
|
||||||
mode_spec,
|
mode_spec,
|
||||||
sample_rate=SAMPLE_RATE,
|
sample_rate=SAMPLE_RATE,
|
||||||
)
|
)
|
||||||
|
if len(remaining) > 0:
|
||||||
|
image_decoder.feed(remaining)
|
||||||
self._emit_progress(DecodeProgress(
|
self._emit_progress(DecodeProgress(
|
||||||
status='decoding',
|
status='decoding',
|
||||||
mode=mode_name,
|
mode=mode_name,
|
||||||
@@ -481,7 +490,10 @@ class SSTVDecoder:
|
|||||||
))
|
))
|
||||||
else:
|
else:
|
||||||
logger.warning(f"No mode spec for VIS code {vis_code}")
|
logger.warning(f"No mode spec for VIS code {vis_code}")
|
||||||
vis_detector.reset()
|
self._emit_progress(DecodeProgress(
|
||||||
|
status='detecting',
|
||||||
|
message=f'Detected unknown mode (VIS {vis_code}: {mode_name}) - unsupported',
|
||||||
|
))
|
||||||
|
|
||||||
# Emit signal level metrics every ~500ms (every 5th 100ms chunk)
|
# Emit signal level metrics every ~500ms (every 5th 100ms chunk)
|
||||||
scope_tone: str | None = None
|
scope_tone: str | None = None
|
||||||
@@ -853,6 +865,8 @@ class SSTVDecoder:
|
|||||||
result = vis_detector.feed(chunk)
|
result = vis_detector.feed(chunk)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
vis_code, mode_name = result
|
vis_code, mode_name = result
|
||||||
|
remaining = vis_detector.remaining_buffer.copy()
|
||||||
|
vis_detector.reset()
|
||||||
logger.info(f"VIS detected in file: code={vis_code}, mode={mode_name}")
|
logger.info(f"VIS detected in file: code={vis_code}, mode={mode_name}")
|
||||||
|
|
||||||
mode_spec = get_mode(vis_code)
|
mode_spec = get_mode(vis_code)
|
||||||
@@ -862,8 +876,8 @@ class SSTVDecoder:
|
|||||||
mode_spec,
|
mode_spec,
|
||||||
sample_rate=SAMPLE_RATE,
|
sample_rate=SAMPLE_RATE,
|
||||||
)
|
)
|
||||||
else:
|
if len(remaining) > 0:
|
||||||
vis_detector.reset()
|
image_decoder.feed(remaining)
|
||||||
|
|
||||||
except wave.Error as e:
|
except wave.Error as e:
|
||||||
logger.error(f"Error reading WAV file: {e}")
|
logger.error(f"Error reading WAV file: {e}")
|
||||||
|
|||||||
+81
-11
@@ -128,6 +128,7 @@ class VISDetector:
|
|||||||
self._state = VISState.IDLE
|
self._state = VISState.IDLE
|
||||||
self._buffer = np.array([], dtype=np.float64)
|
self._buffer = np.array([], dtype=np.float64)
|
||||||
self._tone_counter = 0
|
self._tone_counter = 0
|
||||||
|
self._miss_counter = 0
|
||||||
self._data_bits: list[int] = []
|
self._data_bits: list[int] = []
|
||||||
self._parity_bit: int = 0
|
self._parity_bit: int = 0
|
||||||
self._bit_accumulator: list[np.ndarray] = []
|
self._bit_accumulator: list[np.ndarray] = []
|
||||||
@@ -137,6 +138,7 @@ class VISDetector:
|
|||||||
self._state = VISState.IDLE
|
self._state = VISState.IDLE
|
||||||
self._buffer = np.array([], dtype=np.float64)
|
self._buffer = np.array([], dtype=np.float64)
|
||||||
self._tone_counter = 0
|
self._tone_counter = 0
|
||||||
|
self._miss_counter = 0
|
||||||
self._data_bits = []
|
self._data_bits = []
|
||||||
self._parity_bit = 0
|
self._parity_bit = 0
|
||||||
self._bit_accumulator = []
|
self._bit_accumulator = []
|
||||||
@@ -145,6 +147,16 @@ class VISDetector:
|
|||||||
def state(self) -> VISState:
|
def state(self) -> VISState:
|
||||||
return self._state
|
return self._state
|
||||||
|
|
||||||
|
@property
|
||||||
|
def remaining_buffer(self) -> np.ndarray:
|
||||||
|
"""Unprocessed samples left after VIS detection.
|
||||||
|
|
||||||
|
Valid immediately after feed() returns a detection result and before
|
||||||
|
reset() is called. These samples are the start of the SSTV image and
|
||||||
|
must be forwarded to the image decoder.
|
||||||
|
"""
|
||||||
|
return self._buffer
|
||||||
|
|
||||||
def feed(self, samples: np.ndarray) -> tuple[int, str] | None:
|
def feed(self, samples: np.ndarray) -> tuple[int, str] | None:
|
||||||
"""Feed audio samples and attempt VIS detection.
|
"""Feed audio samples and attempt VIS detection.
|
||||||
|
|
||||||
@@ -178,10 +190,19 @@ class VISDetector:
|
|||||||
if self._state == VISState.IDLE:
|
if self._state == VISState.IDLE:
|
||||||
if tone == FREQ_LEADER:
|
if tone == FREQ_LEADER:
|
||||||
self._tone_counter += 1
|
self._tone_counter += 1
|
||||||
|
self._miss_counter = 0
|
||||||
if self._tone_counter >= self._leader_min_windows:
|
if self._tone_counter >= self._leader_min_windows:
|
||||||
self._state = VISState.LEADER_1
|
self._state = VISState.LEADER_1
|
||||||
|
elif tone is None:
|
||||||
|
# Ambiguous window (noise/fading) — tolerate up to 3
|
||||||
|
# consecutive misses before resetting the leader count.
|
||||||
|
self._miss_counter += 1
|
||||||
|
if self._miss_counter > 3:
|
||||||
|
self._tone_counter = 0
|
||||||
|
self._miss_counter = 0
|
||||||
else:
|
else:
|
||||||
self._tone_counter = 0
|
self._tone_counter = 0
|
||||||
|
self._miss_counter = 0
|
||||||
|
|
||||||
elif self._state == VISState.LEADER_1:
|
elif self._state == VISState.LEADER_1:
|
||||||
if tone == FREQ_LEADER:
|
if tone == FREQ_LEADER:
|
||||||
@@ -194,7 +215,15 @@ class VISDetector:
|
|||||||
self._tone_counter = 1
|
self._tone_counter = 1
|
||||||
self._state = VISState.BREAK
|
self._state = VISState.BREAK
|
||||||
elif tone is None:
|
elif tone is None:
|
||||||
pass # Ambiguous window at tone boundary — stay in state
|
# Mixed leader+break window? Check if 1200 Hz energy is
|
||||||
|
# significant relative to 1900 Hz — indicates the break
|
||||||
|
# pulse is straddling this analysis window.
|
||||||
|
leader_e = goertzel(window, FREQ_LEADER, self._sample_rate)
|
||||||
|
sync_e = goertzel(window, FREQ_SYNC, self._sample_rate)
|
||||||
|
if sync_e > leader_e * 0.5:
|
||||||
|
self._tone_counter = 1
|
||||||
|
self._state = VISState.BREAK
|
||||||
|
# else: noisy leader window, stay in LEADER_1
|
||||||
else:
|
else:
|
||||||
self._tone_counter = 0
|
self._tone_counter = 0
|
||||||
self._state = VISState.IDLE
|
self._state = VISState.IDLE
|
||||||
@@ -288,8 +317,31 @@ class VISDetector:
|
|||||||
|
|
||||||
if self._tone_counter >= self._bit_windows:
|
if self._tone_counter >= self._bit_windows:
|
||||||
result = self._validate_and_decode()
|
result = self._validate_and_decode()
|
||||||
self.reset()
|
if result is not None:
|
||||||
return result
|
# Do NOT call reset() here. self._buffer still holds
|
||||||
|
# samples that arrived after the STOP_BIT window — those
|
||||||
|
# are the very first samples of the image. Wiping the
|
||||||
|
# buffer here causes all of them to be lost, making the
|
||||||
|
# image decoder start mid-stream and producing
|
||||||
|
# garbled/diagonal output.
|
||||||
|
# feed() will advance past the current window, leaving
|
||||||
|
# self._buffer pointing at the image start. The caller
|
||||||
|
# must read remaining_buffer and then call reset().
|
||||||
|
self._state = VISState.DETECTED
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
# Parity failure or unknown VIS code — reset and
|
||||||
|
# continue scanning for the next VIS header.
|
||||||
|
self._tone_counter = 0
|
||||||
|
self._data_bits = []
|
||||||
|
self._parity_bit = 0
|
||||||
|
self._bit_accumulator = []
|
||||||
|
self._state = VISState.IDLE
|
||||||
|
|
||||||
|
elif self._state == VISState.DETECTED:
|
||||||
|
# Waiting for caller to call reset() after reading remaining_buffer.
|
||||||
|
# Don't process any windows in this state.
|
||||||
|
pass
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -305,24 +357,42 @@ class VISDetector:
|
|||||||
def _validate_and_decode(self) -> tuple[int, str] | None:
|
def _validate_and_decode(self) -> tuple[int, str] | None:
|
||||||
"""Validate parity and decode the VIS code.
|
"""Validate parity and decode the VIS code.
|
||||||
|
|
||||||
|
Includes single-bit error correction for HF noise resilience:
|
||||||
|
if parity fails, tries recovering by assuming either the parity
|
||||||
|
bit or exactly one data bit was corrupted.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(vis_code, mode_name) or None if validation fails.
|
(vis_code, mode_name) or None if validation fails.
|
||||||
"""
|
"""
|
||||||
if len(self._data_bits) != 8:
|
if len(self._data_bits) != 8:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# VIS uses even parity across 8 data bits + parity bit.
|
parity_ok = (sum(self._data_bits) + self._parity_bit) % 2 == 0
|
||||||
if (sum(self._data_bits) + self._parity_bit) % 2 != 0:
|
vis_code = sum(bit << i for i, bit in enumerate(self._data_bits))
|
||||||
return None
|
|
||||||
|
|
||||||
# Decode VIS code (LSB first)
|
if parity_ok:
|
||||||
vis_code = 0
|
mode_name = VIS_CODES.get(vis_code)
|
||||||
for i, bit in enumerate(self._data_bits):
|
if mode_name is not None:
|
||||||
vis_code |= bit << i
|
return vis_code, mode_name
|
||||||
|
return None # Valid parity but unknown code — not SSTV
|
||||||
|
|
||||||
# Look up mode
|
# Parity failed — try error correction
|
||||||
|
|
||||||
|
# Case 1: only the parity bit is wrong (data is correct)
|
||||||
mode_name = VIS_CODES.get(vis_code)
|
mode_name = VIS_CODES.get(vis_code)
|
||||||
if mode_name is not None:
|
if mode_name is not None:
|
||||||
return vis_code, mode_name
|
return vis_code, mode_name
|
||||||
|
|
||||||
|
# Case 2: one data bit is wrong — try flipping each
|
||||||
|
for flip in range(8):
|
||||||
|
corrected = vis_code ^ (1 << flip)
|
||||||
|
# Flipping one data bit should fix parity too
|
||||||
|
corrected_parity_ok = (
|
||||||
|
bin(corrected).count('1') + self._parity_bit
|
||||||
|
) % 2 == 0
|
||||||
|
if corrected_parity_ok:
|
||||||
|
mode_name = VIS_CODES.get(corrected)
|
||||||
|
if mode_name is not None:
|
||||||
|
return corrected, mode_name
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ from utils.weather_sat import WEATHER_SATELLITES
|
|||||||
logger = get_logger('intercept.weather_sat_predict')
|
logger = get_logger('intercept.weather_sat_predict')
|
||||||
|
|
||||||
|
|
||||||
|
def _format_utc_iso(dt: datetime.datetime) -> str:
|
||||||
|
"""Return an ISO8601 UTC timestamp with a single timezone designator."""
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=datetime.timezone.utc)
|
||||||
|
else:
|
||||||
|
dt = dt.astimezone(datetime.timezone.utc)
|
||||||
|
return dt.isoformat().replace('+00:00', 'Z')
|
||||||
|
|
||||||
|
|
||||||
def predict_passes(
|
def predict_passes(
|
||||||
lat: float,
|
lat: float,
|
||||||
lon: float,
|
lon: float,
|
||||||
@@ -38,8 +47,9 @@ def predict_passes(
|
|||||||
Raises:
|
Raises:
|
||||||
ImportError: If skyfield is not installed.
|
ImportError: If skyfield is not installed.
|
||||||
"""
|
"""
|
||||||
from skyfield.api import load, wgs84, EarthSatellite
|
|
||||||
from skyfield.almanac import find_discrete
|
from skyfield.almanac import find_discrete
|
||||||
|
from skyfield.api import EarthSatellite, load, wgs84
|
||||||
|
|
||||||
from data.satellites import TLE_SATELLITES
|
from data.satellites import TLE_SATELLITES
|
||||||
|
|
||||||
# Use live TLE cache from satellite module if available (refreshed from CelesTrak)
|
# Use live TLE cache from satellite module if available (refreshed from CelesTrak)
|
||||||
@@ -100,12 +110,14 @@ def predict_passes(
|
|||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
rise_dt = rise_time.utc_datetime()
|
||||||
|
set_dt = set_time.utc_datetime()
|
||||||
duration_seconds = (
|
duration_seconds = (
|
||||||
set_time.utc_datetime() - rise_time.utc_datetime()
|
set_dt - rise_dt
|
||||||
).total_seconds()
|
).total_seconds()
|
||||||
duration_minutes = round(duration_seconds / 60, 1)
|
duration_minutes = round(duration_seconds / 60, 1)
|
||||||
|
|
||||||
# Calculate max elevation and trajectory
|
# Calculate max elevation (always) and trajectory points (only if requested)
|
||||||
max_el = 0.0
|
max_el = 0.0
|
||||||
max_el_az = 0.0
|
max_el_az = 0.0
|
||||||
trajectory: list[dict[str, float]] = []
|
trajectory: list[dict[str, float]] = []
|
||||||
@@ -141,14 +153,14 @@ def predict_passes(
|
|||||||
_, set_az, _ = set_topo.altaz()
|
_, set_az, _ = set_topo.altaz()
|
||||||
|
|
||||||
pass_data: dict[str, Any] = {
|
pass_data: dict[str, Any] = {
|
||||||
'id': f"{sat_key}_{rise_time.utc_datetime().strftime('%Y%m%d%H%M')}",
|
'id': f"{sat_key}_{rise_dt.strftime('%Y%m%d%H%M%S')}",
|
||||||
'satellite': sat_key,
|
'satellite': sat_key,
|
||||||
'name': sat_info['name'],
|
'name': sat_info['name'],
|
||||||
'frequency': sat_info['frequency'],
|
'frequency': sat_info['frequency'],
|
||||||
'mode': sat_info['mode'],
|
'mode': sat_info['mode'],
|
||||||
'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'),
|
'startTime': rise_dt.strftime('%Y-%m-%d %H:%M UTC'),
|
||||||
'startTimeISO': rise_time.utc_datetime().isoformat(),
|
'startTimeISO': _format_utc_iso(rise_dt),
|
||||||
'endTimeISO': set_time.utc_datetime().isoformat(),
|
'endTimeISO': _format_utc_iso(set_dt),
|
||||||
'maxEl': round(max_el, 1),
|
'maxEl': round(max_el, 1),
|
||||||
'maxElAz': round(max_el_az, 1),
|
'maxElAz': round(max_el_az, 1),
|
||||||
'riseAz': round(rise_az.degrees, 1),
|
'riseAz': round(rise_az.degrees, 1),
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ Automatically captures satellite passes based on predicted pass times.
|
|||||||
Uses threading.Timer for scheduling — no external dependencies required.
|
Uses threading.Timer for scheduling — no external dependencies required.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import uuid
|
||||||
import uuid
|
from datetime import datetime, timezone, timedelta
|
||||||
from datetime import datetime, timezone, timedelta
|
from typing import Any, Callable
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.weather_sat import get_weather_sat_decoder, WEATHER_SATELLITES, CaptureProgress
|
from utils.weather_sat import get_weather_sat_decoder, WEATHER_SATELLITES, CaptureProgress
|
||||||
@@ -28,7 +27,7 @@ except ImportError:
|
|||||||
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = 30
|
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = 30
|
||||||
|
|
||||||
|
|
||||||
class ScheduledPass:
|
class ScheduledPass:
|
||||||
"""A pass scheduled for automatic capture."""
|
"""A pass scheduled for automatic capture."""
|
||||||
|
|
||||||
def __init__(self, pass_data: dict[str, Any]):
|
def __init__(self, pass_data: dict[str, Any]):
|
||||||
@@ -47,21 +46,13 @@ class ScheduledPass:
|
|||||||
self._timer: threading.Timer | None = None
|
self._timer: threading.Timer | None = None
|
||||||
self._stop_timer: threading.Timer | None = None
|
self._stop_timer: threading.Timer | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def start_dt(self) -> datetime:
|
def start_dt(self) -> datetime:
|
||||||
dt = datetime.fromisoformat(self.start_time)
|
return _parse_utc_iso(self.start_time)
|
||||||
if dt.tzinfo is None:
|
|
||||||
# Naive datetime - assume UTC
|
@property
|
||||||
dt = dt.replace(tzinfo=timezone.utc)
|
def end_dt(self) -> datetime:
|
||||||
return dt.astimezone(timezone.utc)
|
return _parse_utc_iso(self.end_time)
|
||||||
|
|
||||||
@property
|
|
||||||
def end_dt(self) -> datetime:
|
|
||||||
dt = datetime.fromisoformat(self.end_time)
|
|
||||||
if dt.tzinfo is None:
|
|
||||||
# Naive datetime - assume UTC
|
|
||||||
dt = dt.replace(tzinfo=timezone.utc)
|
|
||||||
return dt.astimezone(timezone.utc)
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -80,7 +71,7 @@ class ScheduledPass:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class WeatherSatScheduler:
|
class WeatherSatScheduler:
|
||||||
"""Auto-scheduler for weather satellite captures."""
|
"""Auto-scheduler for weather satellite captures."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -209,10 +200,10 @@ class WeatherSatScheduler:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return [p.to_dict() for p in self._passes]
|
return [p.to_dict() for p in self._passes]
|
||||||
|
|
||||||
def _refresh_passes(self) -> None:
|
def _refresh_passes(self) -> None:
|
||||||
"""Recompute passes and schedule timers."""
|
"""Recompute passes and schedule timers."""
|
||||||
if not self._enabled:
|
if not self._enabled:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from utils.weather_sat_predict import predict_passes
|
from utils.weather_sat_predict import predict_passes
|
||||||
@@ -236,30 +227,39 @@ class WeatherSatScheduler:
|
|||||||
p._stop_timer.cancel()
|
p._stop_timer.cancel()
|
||||||
|
|
||||||
# Keep completed/skipped for history, replace scheduled
|
# Keep completed/skipped for history, replace scheduled
|
||||||
history = [p for p in self._passes if p.status in ('complete', 'skipped', 'capturing')]
|
history = [p for p in self._passes if p.status in ('complete', 'skipped', 'capturing')]
|
||||||
self._passes = history
|
self._passes = history
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
buffer = WEATHER_SAT_CAPTURE_BUFFER_SECONDS
|
buffer = WEATHER_SAT_CAPTURE_BUFFER_SECONDS
|
||||||
|
|
||||||
for pass_data in passes:
|
for pass_data in passes:
|
||||||
sp = ScheduledPass(pass_data)
|
try:
|
||||||
|
sp = ScheduledPass(pass_data)
|
||||||
# Skip passes that already started
|
start_dt = sp.start_dt
|
||||||
if sp.start_dt - timedelta(seconds=buffer) <= now:
|
end_dt = sp.end_dt
|
||||||
continue
|
except Exception as e:
|
||||||
|
logger.warning(f"Skipping invalid pass data: {e}")
|
||||||
# Check if already in history
|
continue
|
||||||
if any(h.id == sp.id for h in history):
|
|
||||||
continue
|
capture_start = start_dt - timedelta(seconds=buffer)
|
||||||
|
capture_end = end_dt + timedelta(seconds=buffer)
|
||||||
# Schedule capture timer
|
|
||||||
delay = (sp.start_dt - timedelta(seconds=buffer) - now).total_seconds()
|
# Skip passes that are already over
|
||||||
if delay > 0:
|
if capture_end <= now:
|
||||||
sp._timer = threading.Timer(delay, self._execute_capture, args=[sp])
|
continue
|
||||||
sp._timer.daemon = True
|
|
||||||
sp._timer.start()
|
# Check if already in history
|
||||||
self._passes.append(sp)
|
if any(h.id == sp.id for h in history):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Schedule capture timer. If we're already inside the capture
|
||||||
|
# window, trigger immediately instead of skipping the pass.
|
||||||
|
delay = max(0.0, (capture_start - now).total_seconds())
|
||||||
|
sp._timer = threading.Timer(delay, self._execute_capture, args=[sp])
|
||||||
|
sp._timer.daemon = True
|
||||||
|
sp._timer.start()
|
||||||
|
self._passes.append(sp)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Scheduler refreshed: {sum(1 for p in self._passes if p.status == 'scheduled')} "
|
f"Scheduler refreshed: {sum(1 for p in self._passes if p.status == 'scheduled')} "
|
||||||
@@ -374,11 +374,31 @@ class WeatherSatScheduler:
|
|||||||
|
|
||||||
def _emit_event(self, event: dict[str, Any]) -> None:
|
def _emit_event(self, event: dict[str, Any]) -> None:
|
||||||
"""Emit scheduler event to callback."""
|
"""Emit scheduler event to callback."""
|
||||||
if self._event_callback:
|
if self._event_callback:
|
||||||
try:
|
try:
|
||||||
self._event_callback(event)
|
self._event_callback(event)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in scheduler event callback: {e}")
|
logger.error(f"Error in scheduler event callback: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_utc_iso(value: str) -> datetime:
|
||||||
|
"""Parse UTC ISO8601 timestamp robustly across Python versions."""
|
||||||
|
if not value:
|
||||||
|
raise ValueError("missing timestamp")
|
||||||
|
|
||||||
|
text = str(value).strip()
|
||||||
|
# Backward compatibility for malformed legacy strings.
|
||||||
|
text = text.replace('+00:00Z', 'Z')
|
||||||
|
# Python <3.11 does not accept trailing 'Z' in fromisoformat.
|
||||||
|
if text.endswith('Z'):
|
||||||
|
text = text[:-1] + '+00:00'
|
||||||
|
|
||||||
|
dt = datetime.fromisoformat(text)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
dt = dt.astimezone(timezone.utc)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
# Singleton
|
# Singleton
|
||||||
|
|||||||
Reference in New Issue
Block a user