Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d427f69dcd | |||
| cab04e6e2c | |||
| 8969fefe2e | |||
| 5e9fcc5c49 | |||
| 53b23fc2f7 | |||
| eeb3a29ecf | |||
| 4cdfa98a4e | |||
| 9fcec6cbb8 | |||
| a527ac191a | |||
| 8cd3aafd10 | |||
| 5c76a423af | |||
| c80bf99b91 | |||
| 6e5cb0a23e | |||
| ffb98425f1 | |||
| 533e92c711 | |||
| 9f32b05719 | |||
| 2a05aaa4d8 | |||
| 6529febcfa | |||
| bd87d4b4c6 | |||
| 5a0589dd69 | |||
| 5605ae0359 | |||
| 2b3f351ff0 | |||
| 126b9ba2ee | |||
| c0498ebe68 | |||
| 99d52eafe7 | |||
| 2a73318457 |
@@ -41,6 +41,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
bluez \
|
bluez \
|
||||||
bluetooth \
|
bluetooth \
|
||||||
# GPS support
|
# GPS support
|
||||||
|
gpsd \
|
||||||
gpsd-clients \
|
gpsd-clients \
|
||||||
# Utilities
|
# Utilities
|
||||||
# APRS
|
# APRS
|
||||||
@@ -94,6 +95,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libfftw3-dev \
|
libfftw3-dev \
|
||||||
liblapack-dev \
|
liblapack-dev \
|
||||||
libcodec2-dev \
|
libcodec2-dev \
|
||||||
|
libglib2.0-dev \
|
||||||
|
libxml2-dev \
|
||||||
# Build dump1090
|
# Build dump1090
|
||||||
&& cd /tmp \
|
&& cd /tmp \
|
||||||
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
|
&& git clone --depth 1 https://github.com/flightaware/dump1090.git \
|
||||||
@@ -136,10 +139,29 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
|
&& git clone --depth 1 https://github.com/TLeconte/acarsdec.git \
|
||||||
&& cd acarsdec \
|
&& cd acarsdec \
|
||||||
&& mkdir build && cd build \
|
&& mkdir build && cd build \
|
||||||
&& cmake .. -Drtl=ON \
|
&& cmake .. -Drtl=ON -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
|
||||||
&& make \
|
&& make \
|
||||||
&& cp acarsdec /usr/bin/acarsdec \
|
&& cp acarsdec /usr/bin/acarsdec \
|
||||||
&& rm -rf /tmp/acarsdec \
|
&& rm -rf /tmp/acarsdec \
|
||||||
|
# Build libacars (required by dumpvdl2)
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/szpajder/libacars.git \
|
||||||
|
&& cd libacars \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make \
|
||||||
|
&& make install \
|
||||||
|
&& ldconfig \
|
||||||
|
&& rm -rf /tmp/libacars \
|
||||||
|
# Build dumpvdl2 (VDL2 aircraft datalink decoder)
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/szpajder/dumpvdl2.git \
|
||||||
|
&& cd dumpvdl2 \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake .. \
|
||||||
|
&& make \
|
||||||
|
&& cp src/dumpvdl2 /usr/bin/dumpvdl2 \
|
||||||
|
&& rm -rf /tmp/dumpvdl2 \
|
||||||
# Build slowrx (SSTV decoder) — pinned to known-good commit
|
# Build slowrx (SSTV decoder) — pinned to known-good commit
|
||||||
&& cd /tmp \
|
&& cd /tmp \
|
||||||
&& git clone https://github.com/windytan/slowrx.git \
|
&& git clone https://github.com/windytan/slowrx.git \
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ Support the developer of this open-source project
|
|||||||
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
|
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
|
||||||
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
|
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
|
||||||
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
|
- **ACARS Messaging** - Aircraft datalink messages via acarsdec
|
||||||
|
- **VDL2** - VHF Data Link Mode 2 aircraft datalink decoding via dumpvdl2
|
||||||
- **Listening Post** - Wideband frequency scanner with real-time audio monitoring
|
- **Listening Post** - Wideband frequency scanner with real-time audio monitoring
|
||||||
- **Weather Satellites** - NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler
|
- **Weather Satellites** - NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler
|
||||||
- **WebSDR** - Remote HF/shortwave listening via KiwiSDR network
|
- **WebSDR** - Remote HF/shortwave listening via KiwiSDR network
|
||||||
@@ -244,6 +245,7 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
|||||||
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
[acarsdec](https://github.com/TLeconte/acarsdec) |
|
||||||
[direwolf](https://github.com/wb2osz/direwolf) |
|
[direwolf](https://github.com/wb2osz/direwolf) |
|
||||||
[rtl_amr](https://github.com/bemasher/rtlamr) |
|
[rtl_amr](https://github.com/bemasher/rtlamr) |
|
||||||
|
[dumpvdl2](https://github.com/szpajder/dumpvdl2) |
|
||||||
[aircrack-ng](https://www.aircrack-ng.org/) |
|
[aircrack-ng](https://www.aircrack-ng.org/) |
|
||||||
[Leaflet.js](https://leafletjs.com/) |
|
[Leaflet.js](https://leafletjs.com/) |
|
||||||
[SatDump](https://github.com/SatDump/SatDump) |
|
[SatDump](https://github.com/SatDump/SatDump) |
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from flask import Flask, render_template, jsonify, send_file, Response, request,
|
|||||||
from werkzeug.security import check_password_hash
|
from werkzeug.security import check_password_hash
|
||||||
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
from config import VERSION, CHANGELOG, SHARED_OBSERVER_LOCATION_ENABLED, DEFAULT_LATITUDE, DEFAULT_LONGITUDE
|
||||||
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
||||||
from utils.process import cleanup_stale_processes
|
from utils.process import cleanup_stale_processes, cleanup_stale_dump1090
|
||||||
from utils.sdr import SDRFactory
|
from utils.sdr import SDRFactory
|
||||||
from utils.cleanup import DataStore, cleanup_manager
|
from utils.cleanup import DataStore, cleanup_manager
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
@@ -150,6 +150,11 @@ acars_process = None
|
|||||||
acars_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
acars_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
acars_lock = threading.Lock()
|
acars_lock = threading.Lock()
|
||||||
|
|
||||||
|
# VDL2 aircraft datalink
|
||||||
|
vdl2_process = None
|
||||||
|
vdl2_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
vdl2_lock = threading.Lock()
|
||||||
|
|
||||||
# APRS amateur radio tracking
|
# APRS amateur radio tracking
|
||||||
aprs_process = None
|
aprs_process = None
|
||||||
aprs_rtl_process = None
|
aprs_rtl_process = None
|
||||||
@@ -647,27 +652,27 @@ def export_bluetooth() -> Response:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def _get_subghz_active() -> bool:
|
def _get_subghz_active() -> bool:
|
||||||
"""Check if SubGHz manager has an active process."""
|
"""Check if SubGHz manager has an active process."""
|
||||||
try:
|
try:
|
||||||
from utils.subghz import get_subghz_manager
|
from utils.subghz import get_subghz_manager
|
||||||
return get_subghz_manager().active_mode != 'idle'
|
return get_subghz_manager().active_mode != 'idle'
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _get_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')
|
@app.route('/health')
|
||||||
def health_check() -> Response:
|
def health_check() -> Response:
|
||||||
"""Health check endpoint for monitoring."""
|
"""Health check endpoint for monitoring."""
|
||||||
import time
|
import time
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -680,13 +685,14 @@ def health_check() -> Response:
|
|||||||
'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),
|
||||||
'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_process is not None and (wifi_process.poll() is None if wifi_process else False),
|
||||||
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
|
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
|
||||||
'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),
|
||||||
@@ -702,6 +708,7 @@ def health_check() -> Response:
|
|||||||
def kill_all() -> Response:
|
def kill_all() -> Response:
|
||||||
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
"""Kill all decoder, WiFi, and Bluetooth processes."""
|
||||||
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
global current_process, sensor_process, wifi_process, adsb_process, ais_process, acars_process
|
||||||
|
global vdl2_process
|
||||||
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
|
global aprs_process, aprs_rtl_process, dsc_process, dsc_rtl_process, bt_process
|
||||||
global dmr_process, dmr_rtl_process
|
global dmr_process, dmr_rtl_process
|
||||||
|
|
||||||
@@ -714,7 +721,7 @@ def kill_all() -> Response:
|
|||||||
processes_to_kill = [
|
processes_to_kill = [
|
||||||
'rtl_fm', 'multimon-ng', 'rtl_433',
|
'rtl_fm', 'multimon-ng', 'rtl_433',
|
||||||
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
||||||
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
|
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
|
||||||
'hcitool', 'bluetoothctl', 'satdump', 'dsd',
|
'hcitool', 'bluetoothctl', 'satdump', 'dsd',
|
||||||
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
|
||||||
'hackrf_transfer', 'hackrf_sweep'
|
'hackrf_transfer', 'hackrf_sweep'
|
||||||
@@ -751,6 +758,10 @@ def kill_all() -> Response:
|
|||||||
with acars_lock:
|
with acars_lock:
|
||||||
acars_process = None
|
acars_process = None
|
||||||
|
|
||||||
|
# Reset VDL2 state
|
||||||
|
with vdl2_lock:
|
||||||
|
vdl2_process = None
|
||||||
|
|
||||||
# Reset APRS state
|
# Reset APRS state
|
||||||
with aprs_lock:
|
with aprs_lock:
|
||||||
aprs_process = None
|
aprs_process = None
|
||||||
@@ -877,6 +888,7 @@ def main() -> None:
|
|||||||
|
|
||||||
# Clean up any stale processes from previous runs
|
# Clean up any stale processes from previous runs
|
||||||
cleanup_stale_processes()
|
cleanup_stale_processes()
|
||||||
|
cleanup_stale_dump1090()
|
||||||
|
|
||||||
# Initialize database for settings storage
|
# Initialize database for settings storage
|
||||||
from utils.database import init_db
|
from utils.database import init_db
|
||||||
|
|||||||
@@ -7,10 +7,45 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "2.16.0"
|
VERSION = "2.19.0"
|
||||||
|
|
||||||
# Changelog - latest release notes (shown on welcome screen)
|
# Changelog - latest release notes (shown on welcome screen)
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "2.19.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"VDL2 mode with modal message viewer, consolidated into ADS-B dashboard",
|
||||||
|
"ADS-B: trails enabled by default, radar modes removed, CSV export added",
|
||||||
|
"Bundled Roboto Condensed font for offline mode with SVG icon overhaul",
|
||||||
|
"Help modal updated with all modes and correct SVG icons",
|
||||||
|
"Setup script overhauled for reliability and macOS compatibility",
|
||||||
|
"GPS fix for preserving satellites across DOP-only SKY messages",
|
||||||
|
"Fix gpsd deadlock causing GPS connect to hang",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.18.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Bluetooth: service data inspector, appearance codes, MAC cluster tracking, and behavioral flags",
|
||||||
|
"Bluetooth: IRK badge display, distance estimation with confidence, and signal stability metrics",
|
||||||
|
"ACARS: SoapySDR device support for SDRplay, LimeSDR, Airspy, and other non-RTL backends",
|
||||||
|
"ADS-B: stale dump1090 process cleanup via PID file tracking",
|
||||||
|
"GPS: error state indicator and UI refinements",
|
||||||
|
"Proximity radar and signal card UI improvements",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.17.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"BT Locate: SAR Bluetooth device location with GPS-tagged signal trail and proximity alerts",
|
||||||
|
"IRK auto-detection: extract Identity Resolving Keys from paired devices (macOS/Linux)",
|
||||||
|
"GPS mode: real-time position tracking with live map, speed, altitude, and satellite info",
|
||||||
|
"Bluetooth scanner lifecycle fix for bleak scan timeout tracking",
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "2.16.0",
|
"version": "2.16.0",
|
||||||
"date": "February 2026",
|
"date": "February 2026",
|
||||||
|
|||||||
@@ -99,6 +99,18 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
|
|||||||
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||||
- **Message filtering** - filter by message type, flight, or registration
|
- **Message filtering** - filter by message type, flight, or registration
|
||||||
|
|
||||||
|
## VDL2 (VHF Data Link Mode 2)
|
||||||
|
|
||||||
|
- **Real-time VDL2 decoding** via dumpvdl2 on standard VDL2 frequencies
|
||||||
|
- **ACARS-over-AVLC** message capture with full frame parsing
|
||||||
|
- **Signal analysis** - frequency, signal level, noise level, SNR, burst length
|
||||||
|
- **AVLC frame details** - source/destination addresses, frame type, command/response
|
||||||
|
- **Raw JSON inspection** - expandable raw message data for each frame
|
||||||
|
- **Multi-frequency monitoring** - simultaneous reception on multiple VDL2 channels
|
||||||
|
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||||
|
- **CSV/JSON export** - export captured messages for offline analysis
|
||||||
|
- **Integrated with ADS-B dashboard** - VDL2 messages linked to aircraft tracking
|
||||||
|
|
||||||
## Listening Post
|
## Listening Post
|
||||||
|
|
||||||
- **Wideband frequency scanning** via rtl_power sweep with SNR filtering
|
- **Wideband frequency scanning** via rtl_power sweep with SNR filtering
|
||||||
@@ -122,11 +134,23 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
|
|||||||
- **Receiver discovery** with automatic caching
|
- **Receiver discovery** with automatic caching
|
||||||
- **Frequency tuning** with band presets
|
- **Frequency tuning** with band presets
|
||||||
|
|
||||||
|
## ISS SSTV
|
||||||
|
|
||||||
|
- **ISS SSTV image reception** on 145.800 MHz FM during special event transmissions
|
||||||
|
- **Real-time ISS tracking** with world map and pass predictions
|
||||||
|
- **Doppler correction** - optional lat/lon input for real-time frequency shift compensation
|
||||||
|
- **Next pass countdown** - time remaining until ISS is overhead
|
||||||
|
- **Image gallery** with timestamped decoded imagery
|
||||||
|
- **TLE updates** - fetch latest ISS orbital elements
|
||||||
|
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
|
||||||
|
|
||||||
## HF SSTV
|
## HF SSTV
|
||||||
|
|
||||||
- **Terrestrial SSTV decoding** across HF (80m-10m), VHF (6m, 2m), and UHF (70cm) bands
|
- **Terrestrial SSTV decoding** across HF (80m-10m), VHF (6m, 2m), and UHF (70cm) bands
|
||||||
- **Predefined frequency lookup** for active SSTV calling frequencies
|
- **Predefined frequency lookup** for 13 active SSTV calling frequencies
|
||||||
|
- **Auto-modulation selection** - frequency table maps to correct mode (USB, LSB, FM)
|
||||||
- **Image gallery** with decoded transmissions
|
- **Image gallery** with decoded transmissions
|
||||||
|
- **Common modes supported** - PD120, PD180, Martin1, Scottie1, Robot36
|
||||||
|
|
||||||
## APRS
|
## APRS
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,22 @@ INTERCEPT automatically detects known trackers:
|
|||||||
|
|
||||||
Common ISM band protocols including garage doors, key fobs, weather stations, and IoT devices in the 300-928 MHz range.
|
Common ISM band protocols including garage doors, key fobs, weather stations, and IoT devices in the 300-928 MHz range.
|
||||||
|
|
||||||
|
## VDL2 (Aircraft Datalink)
|
||||||
|
|
||||||
|
1. **Select Hardware** - Choose your SDR type
|
||||||
|
2. **Select Device** - Choose your SDR device
|
||||||
|
3. **Set Frequencies** - Default VDL2 frequencies are pre-configured (136.975, 136.725, 136.775 MHz etc.)
|
||||||
|
4. **Start Decoding** - Click "Start" to begin VDL2 reception via dumpvdl2
|
||||||
|
5. **View Messages** - AVLC frames appear with source/destination, signal levels, and decoded content
|
||||||
|
6. **Inspect Details** - Click a message to view full AVLC frame details and raw JSON
|
||||||
|
7. **Export** - Use CSV or JSON export buttons to save captured messages
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- VDL2 is most active near airports and along flight corridors
|
||||||
|
- Multiple frequencies can be monitored simultaneously for better coverage
|
||||||
|
- VDL2 data is also accessible from the ADS-B dashboard
|
||||||
|
|
||||||
## Listening Post
|
## Listening Post
|
||||||
|
|
||||||
1. **Select Hardware** - Choose your SDR type
|
1. **Select Hardware** - Choose your SDR type
|
||||||
@@ -110,6 +126,23 @@ The system highlights aircraft transmitting emergency squawks:
|
|||||||
- **7600** - Radio failure
|
- **7600** - Radio failure
|
||||||
- **7700** - General emergency
|
- **7700** - General emergency
|
||||||
|
|
||||||
|
## ACARS Messaging
|
||||||
|
|
||||||
|
1. **Select Hardware** - Choose your SDR type
|
||||||
|
2. **Select Device** - Choose your SDR device
|
||||||
|
3. **Select Region** - Choose North America, Europe, or Asia-Pacific to auto-populate frequencies
|
||||||
|
4. **Select Frequencies** - Check one or more ACARS frequencies (131.550 MHz primary worldwide, 130.025 MHz secondary USA/Canada, etc.)
|
||||||
|
5. **Adjust Gain** - Set gain (0 for auto, or 0-50 dB)
|
||||||
|
6. **Start Decoding** - Click "Start" to begin ACARS reception via acarsdec
|
||||||
|
7. **View Messages** - Aircraft messages appear in real-time with flight ID, registration, and content
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- A vertical polarization antenna works best for ACARS
|
||||||
|
- Quarter-wave dipole: 57 cm per element at 130 MHz
|
||||||
|
- Stock SDR antenna may work at close range near airports
|
||||||
|
- Outdoor placement with clear sky view significantly improves reception
|
||||||
|
|
||||||
## ADS-B History (Optional)
|
## ADS-B History (Optional)
|
||||||
|
|
||||||
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
|
The history dashboard persists aircraft messages and per-aircraft snapshots to Postgres for long-running tracking and reporting.
|
||||||
@@ -221,6 +254,61 @@ Digital Selective Calling monitoring runs alongside AIS:
|
|||||||
- Distress positions plotted with pulsing alert markers
|
- Distress positions plotted with pulsing alert markers
|
||||||
- Audio alerts for critical messages
|
- Audio alerts for critical messages
|
||||||
|
|
||||||
|
## WebSDR
|
||||||
|
|
||||||
|
1. **Set Frequency** - Enter a frequency in kHz (e.g., 6500 for 6.5 MHz)
|
||||||
|
2. **Select Mode** - Choose demodulation mode (USB, LSB, AM, CW)
|
||||||
|
3. **Find Receivers** - Click "Find Receivers" to discover available KiwiSDR nodes worldwide
|
||||||
|
4. **Select Receiver** - Click a receiver from the list to connect
|
||||||
|
5. **Listen** - Audio streams in real-time via WebSocket
|
||||||
|
6. **Adjust Volume** - Use the volume slider and monitor the S-meter
|
||||||
|
7. **Spy Station Presets** - Use the quick-tune buttons to jump to known number station frequencies
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- Requires an internet connection to access the KiwiSDR network
|
||||||
|
- Receiver list is cached for 1 hour to reduce API load
|
||||||
|
- Receivers are sorted by distance from your location
|
||||||
|
- Integrated spy station presets allow quick tuning to SIGINT targets
|
||||||
|
|
||||||
|
## ISS SSTV
|
||||||
|
|
||||||
|
1. **Select Hardware** - Choose your SDR type
|
||||||
|
2. **Select Device** - Choose your SDR device
|
||||||
|
3. **Set Frequency** - Default is 145.800 MHz (ISS downlink)
|
||||||
|
4. **Set Location** - Enter lat/lon for Doppler correction and pass prediction
|
||||||
|
5. **Update TLE** - Click "Update TLE" to fetch latest ISS orbital elements
|
||||||
|
6. **Wait for Pass** - The next pass countdown shows when ISS will be overhead
|
||||||
|
7. **Start Decoding** - Click "Start" to begin SSTV reception
|
||||||
|
8. **View Images** - Decoded SSTV images appear in the gallery with timestamps
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- A V-dipole or better antenna is required (stock antenna will not work)
|
||||||
|
- V-dipole construction: 51 cm per element at 145.8 MHz, 120-degree angle between elements
|
||||||
|
- ISS SSTV events occur during special anniversaries and missions — check ARISS for schedules
|
||||||
|
- Best passes have elevation > 30 degrees above horizon
|
||||||
|
- Doppler shift tracking dramatically improves reception quality
|
||||||
|
- Common SSTV modes: PD120, PD180, Martin1, Scottie1
|
||||||
|
- Outdoor antenna placement with clear sky view is essential
|
||||||
|
|
||||||
|
## HF SSTV
|
||||||
|
|
||||||
|
1. **Select Hardware** - Choose your SDR type
|
||||||
|
2. **Select Device** - Choose your SDR device
|
||||||
|
3. **Select Frequency** - Choose from 13 preset frequencies or enter a custom one
|
||||||
|
4. **Modulation** - Auto-selected based on frequency (USB for HF, FM for VHF/UHF)
|
||||||
|
5. **Start Decoding** - Click "Start" to begin SSTV reception
|
||||||
|
6. **View Images** - Decoded amateur radio images appear in the gallery
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- HF frequencies (3-30 MHz) require an upconverter with RTL-SDR
|
||||||
|
- VHF/UHF frequencies (145 MHz, 433 MHz) work directly with RTL-SDR
|
||||||
|
- Most popular frequency: 14.230 MHz USB (20m band) with regular activity
|
||||||
|
- Weekend activity peaks on most HF bands
|
||||||
|
- Amateur license is not required to receive (listen-only)
|
||||||
|
|
||||||
## APRS
|
## APRS
|
||||||
|
|
||||||
1. **Select Hardware** - Choose your SDR type
|
1. **Select Hardware** - Choose your SDR type
|
||||||
@@ -283,6 +371,46 @@ Digital Selective Calling monitoring runs alongside AIS:
|
|||||||
- GPS fix may take 30-60 seconds after cold start
|
- GPS fix may take 30-60 seconds after cold start
|
||||||
- Accuracy improves with more satellites in view
|
- Accuracy improves with more satellites in view
|
||||||
|
|
||||||
|
## TSCM (Counter-Surveillance)
|
||||||
|
|
||||||
|
1. **Select Sweep Type** - Choose from Quick Scan (2 min), Standard (5 min), Full Sweep (15 min), or presets for Wireless Cameras, Body-Worn Devices, or GPS Trackers
|
||||||
|
2. **Select Scan Sources** - Toggle WiFi, Bluetooth, and/or RF/SDR scanning and select the appropriate interfaces
|
||||||
|
3. **Select Baseline** - Optionally choose a previously recorded baseline to compare against
|
||||||
|
4. **Start Sweep** - Click "Start Sweep" to begin scanning
|
||||||
|
5. **Review Results** - Detected devices are classified and scored by threat level
|
||||||
|
6. **Record Baseline** - In a known clean environment, record a baseline for future comparison
|
||||||
|
7. **Export Report** - Generate PDF report, JSON annex, or CSV data
|
||||||
|
|
||||||
|
### Threat Levels
|
||||||
|
|
||||||
|
- **Informational (0-2)** - Known or expected devices
|
||||||
|
- **Needs Review (3-5)** - Unusual devices requiring assessment
|
||||||
|
- **High Interest (6+)** - Multiple indicators warrant investigation
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- Record a baseline in a known clean environment before conducting sweeps
|
||||||
|
- Use the meeting window feature to flag new RF signatures during sensitive periods
|
||||||
|
- Full functionality requires WiFi adapter, Bluetooth adapter, and SDR hardware
|
||||||
|
- Threat detection uses a database of 47K+ known tracker fingerprints
|
||||||
|
|
||||||
|
## Spy Stations
|
||||||
|
|
||||||
|
1. **Browse Database** - View the full list of documented number stations and diplomatic networks
|
||||||
|
2. **Filter by Type** - Toggle between Number Stations and Diplomatic Networks
|
||||||
|
3. **Filter by Country** - Select specific countries (Russia, Cuba, Israel, Poland, etc.)
|
||||||
|
4. **Filter by Mode** - Filter by demodulation mode (USB, AM, CW, OFDM)
|
||||||
|
5. **View Details** - Click "Details" on a station card for full information
|
||||||
|
6. **Tune In** - Click "Tune In" to route the station frequency to the Listening Post or WebSDR
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- Data sourced from priyom.org (non-profit monitoring community)
|
||||||
|
- Most activity is on HF bands (3-30 MHz) — propagation varies by time of day
|
||||||
|
- Notable stations: UVB-76 "The Buzzer" (4625 kHz), E06 English Man, HM01 Cuban Numbers
|
||||||
|
- Legal to monitor in most countries (check local regulations)
|
||||||
|
- No decryption or content decoding is included — this is a reference database
|
||||||
|
|
||||||
## Meshtastic
|
## Meshtastic
|
||||||
|
|
||||||
1. **Connect Device** - Plug in a Meshtastic device via USB or connect via TCP
|
1. **Connect Device** - Plug in a Meshtastic device via USB or connect via TCP
|
||||||
@@ -291,6 +419,22 @@ Digital Selective Calling monitoring runs alongside AIS:
|
|||||||
4. **View Nodes** - Connected nodes displayed with signal metrics (RSSI, SNR)
|
4. **View Nodes** - Connected nodes displayed with signal metrics (RSSI, SNR)
|
||||||
5. **Send Messages** - Type messages to broadcast on the mesh
|
5. **Send Messages** - Type messages to broadcast on the mesh
|
||||||
|
|
||||||
|
## Offline Mode
|
||||||
|
|
||||||
|
1. **Open Settings** - Click the gear icon in the navigation bar
|
||||||
|
2. **Offline Tab** - Toggle "Offline Mode" to enable local assets
|
||||||
|
3. **Configure Sources** - Switch assets and fonts from CDN to local
|
||||||
|
4. **Set Tile Provider** - Choose a map tile provider or enter a custom tile server URL
|
||||||
|
5. **Check Assets** - Click "Check Assets" to verify all local files are present
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- Download required assets: Leaflet JS/CSS, Chart.js, Inter and JetBrains Mono fonts
|
||||||
|
- Assets are stored in the `static/vendor/` directory
|
||||||
|
- For maps, you need a local tile server (e.g., self-hosted OpenStreetMap tiles)
|
||||||
|
- Missing assets fail gracefully with console warnings
|
||||||
|
- Useful for air-gapped environments, field deployments, or reducing latency
|
||||||
|
|
||||||
## Remote Agents (Distributed SIGINT)
|
## Remote Agents (Distributed SIGINT)
|
||||||
|
|
||||||
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
|
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 694 KiB After Width: | Height: | Size: 790 KiB |
|
After Width: | Height: | Size: 514 KiB |
|
After Width: | Height: | Size: 853 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 876 KiB |
|
After Width: | Height: | Size: 455 KiB |
|
After Width: | Height: | Size: 886 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
@@ -11,6 +11,7 @@
|
|||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<canvas id="bg-canvas"></canvas>
|
||||||
<nav class="navbar">
|
<nav class="navbar">
|
||||||
<div class="nav-container">
|
<div class="nav-container">
|
||||||
<a href="#" class="nav-logo">iNTERCEPT</a>
|
<a href="#" class="nav-logo">iNTERCEPT</a>
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="hero-stats">
|
<div class="hero-stats">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<span class="stat-value">20+</span>
|
<span class="stat-value">25+</span>
|
||||||
<span class="stat-label">Modes</span>
|
<span class="stat-label">Modes</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
@@ -58,151 +59,143 @@
|
|||||||
<h2>Capabilities</h2>
|
<h2>Capabilities</h2>
|
||||||
<p class="section-subtitle">Everything you need for signal intelligence in one interface</p>
|
<p class="section-subtitle">Everything you need for signal intelligence in one interface</p>
|
||||||
|
|
||||||
<div class="features-grid">
|
<div class="carousel-filters">
|
||||||
<div class="feature-card">
|
<button class="filter-btn active" data-filter="all">All</button>
|
||||||
<div class="feature-icon">📟</div>
|
<button class="filter-btn" data-filter="sdr">SDR / RF</button>
|
||||||
<h3>Pager Decoding</h3>
|
<button class="filter-btn" data-filter="aviation">Aviation & Maritime</button>
|
||||||
<p>Decode POCSAG and FLEX pager messages using rtl_fm and multimon-ng. Monitor emergency services and legacy paging systems.</p>
|
<button class="filter-btn" data-filter="space">Space & Satellite</button>
|
||||||
</div>
|
<button class="filter-btn" data-filter="wireless">Wireless & Security</button>
|
||||||
|
<button class="filter-btn" data-filter="platform">Platform</button>
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">✈️</div>
|
|
||||||
<h3>Aircraft Tracking</h3>
|
|
||||||
<p>Real-time ADS-B tracking with interactive maps, aircraft photos, emergency squawk detection, and range visualization.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">📡</div>
|
|
||||||
<h3>433MHz Sensors</h3>
|
|
||||||
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">📻</div>
|
|
||||||
<h3>Sub-GHz Analyzer</h3>
|
|
||||||
<p>HackRF-based signal capture and protocol decoding for 300-928 MHz ISM bands with spectrum analysis and replay.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">📻</div>
|
|
||||||
<h3>Listening Post</h3>
|
|
||||||
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">🛰️</div>
|
|
||||||
<h3>Satellite Tracking</h3>
|
|
||||||
<p>Track satellites with TLE data, sky plots, ground track visualization, and pass predictions for your location.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">📶</div>
|
|
||||||
<h3>WiFi Scanning</h3>
|
|
||||||
<p>Monitor mode reconnaissance via aircrack-ng. Network discovery, client tracking, and handshake capture.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">🔵</div>
|
|
||||||
<h3>Bluetooth Scanning</h3>
|
|
||||||
<p>Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">📍</div>
|
|
||||||
<h3>BT Locate</h3>
|
|
||||||
<p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">🛰️</div>
|
|
||||||
<h3>GPS Tracking</h3>
|
|
||||||
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">🛡️</div>
|
|
||||||
<h3>TSCM</h3>
|
|
||||||
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">⚡</div>
|
|
||||||
<h3>Meter Reading</h3>
|
|
||||||
<p>Intercept smart utility meters via rtl_amr. Monitor electricity, gas, and water meter transmissions.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">🚢</div>
|
|
||||||
<h3>Vessel Tracking</h3>
|
|
||||||
<p>Real-time AIS ship tracking via AIS-catcher. Monitor maritime traffic with vessel details, course, speed, and destination.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">🔢</div>
|
|
||||||
<h3>Spy Stations</h3>
|
|
||||||
<p>Number stations and diplomatic HF network database. Frequencies, schedules, and background info from priyom.org.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">🌐</div>
|
|
||||||
<h3>Remote Agents</h3>
|
|
||||||
<p>Distributed signal intelligence with remote sensor nodes. Deploy agents across multiple locations and aggregate data to a central controller.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">📴</div>
|
|
||||||
<h3>Offline Mode</h3>
|
|
||||||
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">📡</div>
|
|
||||||
<h3>Meshtastic</h3>
|
|
||||||
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">🌧️</div>
|
|
||||||
<h3>Weather Satellites</h3>
|
|
||||||
<p>NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler, polar plot, and ground track map.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">🖼️</div>
|
|
||||||
<h3>ISS SSTV</h3>
|
|
||||||
<p>Receive Slow Scan Television from the ISS. Real-time tracking globe, pass predictions, and image decoding.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">🖼️</div>
|
|
||||||
<h3>HF SSTV</h3>
|
|
||||||
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">✈️</div>
|
|
||||||
<h3>ACARS</h3>
|
|
||||||
<p>Aircraft datalink messages via acarsdec. Decode operational, weather, and position reports from commercial flights.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">📍</div>
|
|
||||||
<h3>APRS</h3>
|
|
||||||
<p>Amateur packet radio position reports and telemetry via direwolf. Track amateur radio operators on an interactive map.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">🌐</div>
|
|
||||||
<h3>WebSDR</h3>
|
|
||||||
<p>Remote HF/shortwave listening via the KiwiSDR network. Access receivers worldwide with real-time audio streaming.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="feature-card">
|
|
||||||
<div class="feature-icon">⚡</div>
|
|
||||||
<h3>Utility Meters</h3>
|
|
||||||
<p>Smart meter monitoring via rtl_amr. Receive electric, gas, and water meter broadcasts in real time.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="carousel-wrapper">
|
||||||
|
<button class="carousel-arrow carousel-arrow-left" aria-label="Scroll left">‹</button>
|
||||||
|
<div class="carousel-track">
|
||||||
|
<div class="feature-card" data-category="sdr">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="6" y1="9" x2="6" y2="15"/><line x1="10" y1="9" x2="10" y2="15"/><line x1="14" y1="11" x2="18" y2="11"/><line x1="14" y1="13" x2="18" y2="13"/></svg></div>
|
||||||
|
<h3>Pager Decoding</h3>
|
||||||
|
<p>Decode POCSAG and FLEX pager messages using rtl_fm and multimon-ng. Monitor emergency services and legacy paging systems.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="sdr">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="M12 18v4"/><circle cx="12" cy="12" r="4"/><path d="M4.93 4.93l2.83 2.83"/><path d="M16.24 16.24l2.83 2.83"/><path d="M2 12h4"/><path d="M18 12h4"/><path d="M4.93 19.07l2.83-2.83"/><path d="M16.24 7.76l2.83-2.83"/></svg></div>
|
||||||
|
<h3>433MHz Sensors</h3>
|
||||||
|
<p>Decode 200+ protocols including weather stations, TPMS, smart home devices, and IoT sensors via rtl_433.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="sdr">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-9 6 18 3-9h4"/></svg></div>
|
||||||
|
<h3>Sub-GHz Analyzer</h3>
|
||||||
|
<p>HackRF-based signal capture and protocol decoding for 300-928 MHz ISM bands with spectrum analysis and replay.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="sdr">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 18.5a6.5 6.5 0 1 1 0-13"/><path d="M17 12h5"/><path d="M12 7V2"/><circle cx="12" cy="12" r="2"/><path d="M8.5 8.5L5 5"/></svg></div>
|
||||||
|
<h3>Listening Post</h3>
|
||||||
|
<p>Frequency scanner with real-time audio monitoring, fine-tuning controls, and customizable frequency presets.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="sdr">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
|
||||||
|
<h3>WebSDR</h3>
|
||||||
|
<p>Remote HF/shortwave listening via the KiwiSDR network. Access receivers worldwide with real-time audio streaming.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="sdr">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M8 8h2v2H8z"/><path d="M14 8h2v2h-2z"/><path d="M8 14h2v2H8z"/><path d="M14 14h2v2h-2z"/><path d="M11 8h2v2h-2z"/><path d="M11 11h2v2h-2z"/></svg></div>
|
||||||
|
<h3>Spy Stations</h3>
|
||||||
|
<p>Number stations and diplomatic HF network database. Frequencies, schedules, and background info from priyom.org.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="sdr">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></div>
|
||||||
|
<h3>APRS</h3>
|
||||||
|
<p>Amateur packet radio position reports and telemetry via direwolf. Track amateur radio operators on an interactive map.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="sdr">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></div>
|
||||||
|
<h3>Utility Meters</h3>
|
||||||
|
<p>Smart meter monitoring via rtl_amr. Receive electric, gas, and water meter broadcasts in real time.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="aviation">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.8 19.2L16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></svg></div>
|
||||||
|
<h3>Aircraft Tracking</h3>
|
||||||
|
<p>Real-time ADS-B tracking with interactive maps, aircraft photos, emergency squawk detection, and range visualization.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="aviation">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 8h10"/><path d="M7 12h6"/><path d="M7 16h8"/></svg></div>
|
||||||
|
<h3>ACARS</h3>
|
||||||
|
<p>Aircraft datalink messages via acarsdec. Decode operational, weather, and position reports from commercial flights.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="aviation">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/><circle cx="12" cy="12" r="9"/><path d="M3.5 9h17"/><path d="M3.5 15h17"/></svg></div>
|
||||||
|
<h3>VDL2</h3>
|
||||||
|
<p>VHF Data Link Mode 2 aircraft datalink decoding via dumpvdl2. Real-time ACARS-over-AVLC message capture with signal analysis.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="aviation">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20l4-4h3l4-7 2 4h2l5-9"/><path d="M22 20H2"/><path d="M6 16v4"/></svg></div>
|
||||||
|
<h3>Vessel Tracking</h3>
|
||||||
|
<p>Real-time AIS ship tracking via AIS-catcher. Monitor maritime traffic with vessel details, course, speed, and destination.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="space">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v4"/><path d="M12 19v4"/><path d="M5 5l2 2"/><path d="M17 17l2 2"/><path d="M1 12h4"/><path d="M19 12h4"/><path d="M5 19l2-2"/><path d="M17 7l2-2"/><ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(45 12 12)"/></svg></div>
|
||||||
|
<h3>Satellite Tracking</h3>
|
||||||
|
<p>Track satellites with TLE data, sky plots, ground track visualization, and pass predictions for your location.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="space">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/><circle cx="12" cy="12" r="4"/><path d="M16 12a4 4 0 0 0-4-4"/></svg></div>
|
||||||
|
<h3>Weather Satellites</h3>
|
||||||
|
<p>NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler, polar plot, and ground track map.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="space">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg></div>
|
||||||
|
<h3>ISS SSTV</h3>
|
||||||
|
<p>Receive Slow Scan Television from the ISS. Real-time tracking globe, pass predictions, and image decoding.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="space">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 3v18"/></svg></div>
|
||||||
|
<h3>HF SSTV</h3>
|
||||||
|
<p>Terrestrial SSTV on shortwave frequencies. Decode amateur radio image transmissions across HF, VHF, and UHF bands.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="space">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/><path d="M12 8l4 4-4 4"/></svg></div>
|
||||||
|
<h3>GPS Tracking</h3>
|
||||||
|
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="wireless">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/></svg></div>
|
||||||
|
<h3>WiFi Scanning</h3>
|
||||||
|
<p>Monitor mode reconnaissance via aircrack-ng. Network discovery, client tracking, and handshake capture.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="wireless">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="6"/><path d="M12 16v5"/><path d="M8 21h8"/><path d="M9.5 7.5L12 10l2.5-2.5"/></svg></div>
|
||||||
|
<h3>Bluetooth Scanning</h3>
|
||||||
|
<p>Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="wireless">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="7" stroke-dasharray="4 2"/><circle cx="12" cy="12" r="11" stroke-dasharray="2 3"/><line x1="12" y1="1" x2="12" y2="3"/></svg></div>
|
||||||
|
<h3>BT Locate</h3>
|
||||||
|
<p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="wireless">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s-8-4.5-8-11.8A8 8 0 0 1 12 2a8 8 0 0 1 8 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/><path d="M12 2v3"/><path d="M4.93 4.93l2.12 2.12"/><path d="M20 12h-3"/></svg></div>
|
||||||
|
<h3>TSCM</h3>
|
||||||
|
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="wireless">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></div>
|
||||||
|
<h3>Meshtastic</h3>
|
||||||
|
<p>LoRa mesh network integration. Connect to Meshtastic devices for decentralized, long-range communication monitoring.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="platform">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/><circle cx="9" cy="10" r="1.5"/><circle cx="15" cy="10" r="1.5"/><path d="M5 10h2"/><path d="M17 10h2"/></svg></div>
|
||||||
|
<h3>Remote Agents</h3>
|
||||||
|
<p>Distributed signal intelligence with remote sensor nodes. Deploy agents across multiple locations and aggregate data to a central controller.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="platform">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18.36 6.64A9 9 0 0 1 20.77 15"/><path d="M6.16 6.16a9 9 0 0 0-2.57 8.84"/><path d="M12 2v4"/><path d="M2 12h4"/><line x1="2" y1="2" x2="22" y2="22"/><circle cx="12" cy="12" r="3"/></svg></div>
|
||||||
|
<h3>Offline Mode</h3>
|
||||||
|
<p>Run without internet using bundled assets. Choose from multiple map tile providers or use your own local tile server.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="carousel-arrow carousel-arrow-right" aria-label="Scroll right">›</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="carousel-indicators" id="carousel-indicators"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -252,6 +245,34 @@
|
|||||||
<img src="images/bt-locate.png" alt="BT Locate SAR Tracker">
|
<img src="images/bt-locate.png" alt="BT Locate SAR Tracker">
|
||||||
<span class="screenshot-label">BT Locate — SAR Tracker</span>
|
<span class="screenshot-label">BT Locate — SAR Tracker</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/spy-stations.png" alt="Spy Stations Database">
|
||||||
|
<span class="screenshot-label">Spy Stations</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/gps.png" alt="GPS Receiver">
|
||||||
|
<span class="screenshot-label">GPS Receiver</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/websdr.png" alt="WebSDR Remote Listening">
|
||||||
|
<span class="screenshot-label">WebSDR</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/vdl2.png" alt="VDL2 Aircraft Datalink">
|
||||||
|
<span class="screenshot-label">VDL2 Aircraft Datalink</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/weather-satellite.png" alt="Weather Satellite Decoder">
|
||||||
|
<span class="screenshot-label">Weather Satellite</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/satellite-tracker.png" alt="Satellite Tracker">
|
||||||
|
<span class="screenshot-label">Satellite Tracker</span>
|
||||||
|
</div>
|
||||||
|
<div class="screenshot-item">
|
||||||
|
<img src="images/iss-sstv.png" alt="ISS SSTV Decoder">
|
||||||
|
<span class="screenshot-label">ISS SSTV</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -336,6 +357,36 @@ docker compose up -d</code></pre>
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="support">
|
||||||
|
<div class="container">
|
||||||
|
<h2>Support & Contact</h2>
|
||||||
|
<p class="section-subtitle">Help keep iNTERCEPT alive or get in touch</p>
|
||||||
|
|
||||||
|
<div class="support-grid">
|
||||||
|
<a href="https://www.buymeacoffee.com/smittix" target="_blank" class="support-card support-coffee">
|
||||||
|
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 8h1a4 4 0 0 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V8z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/></svg></div>
|
||||||
|
<h3>Buy Me a Coffee</h3>
|
||||||
|
<p>Support development with a one-time donation</p>
|
||||||
|
</a>
|
||||||
|
<a href="#" id="email-card" class="support-card" onclick="return false;">
|
||||||
|
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M22 4L12 13 2 4"/></svg></div>
|
||||||
|
<h3>Email</h3>
|
||||||
|
<p id="email-text">Click to reveal</p>
|
||||||
|
</a>
|
||||||
|
<a href="https://discord.gg/EyeksEJmWE" target="_blank" class="support-card">
|
||||||
|
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><circle cx="12" cy="12" r="10"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></div>
|
||||||
|
<h3>Discord</h3>
|
||||||
|
<p>Join the community for help and discussion</p>
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/smittix/intercept/issues" target="_blank" class="support-card">
|
||||||
|
<div class="support-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
|
||||||
|
<h3>Report an Issue</h3>
|
||||||
|
<p>Bug reports and feature requests on GitHub</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="footer-content">
|
<div class="footer-content">
|
||||||
@@ -346,6 +397,8 @@ docker compose up -d</code></pre>
|
|||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<a href="https://github.com/smittix/intercept" target="_blank">GitHub</a>
|
<a href="https://github.com/smittix/intercept" target="_blank">GitHub</a>
|
||||||
<a href="https://discord.gg/EyeksEJmWE" target="_blank">Discord</a>
|
<a href="https://discord.gg/EyeksEJmWE" target="_blank">Discord</a>
|
||||||
|
<a href="#" id="footer-email">Email</a>
|
||||||
|
<a href="https://www.buymeacoffee.com/smittix" target="_blank">Donate</a>
|
||||||
<a href="https://github.com/smittix/intercept/blob/main/docs/USAGE.md">Documentation</a>
|
<a href="https://github.com/smittix/intercept/blob/main/docs/USAGE.md">Documentation</a>
|
||||||
<a href="https://github.com/smittix/intercept/blob/main/docs/DISTRIBUTED_AGENTS.md">Remote Agents</a>
|
<a href="https://github.com/smittix/intercept/blob/main/docs/DISTRIBUTED_AGENTS.md">Remote Agents</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -394,6 +447,334 @@ docker compose up -d</code></pre>
|
|||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') closeLightbox();
|
if (e.key === 'Escape') closeLightbox();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Carousel functionality
|
||||||
|
(function() {
|
||||||
|
const track = document.querySelector('.carousel-track');
|
||||||
|
const cards = Array.from(track.querySelectorAll('.feature-card'));
|
||||||
|
const leftArrow = document.querySelector('.carousel-arrow-left');
|
||||||
|
const rightArrow = document.querySelector('.carousel-arrow-right');
|
||||||
|
const filterBtns = document.querySelectorAll('.filter-btn');
|
||||||
|
const indicatorContainer = document.getElementById('carousel-indicators');
|
||||||
|
|
||||||
|
const SCROLL_AMOUNT = 300;
|
||||||
|
|
||||||
|
function updateArrows() {
|
||||||
|
leftArrow.disabled = track.scrollLeft <= 0;
|
||||||
|
rightArrow.disabled = track.scrollLeft + track.clientWidth >= track.scrollWidth - 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIndicators() {
|
||||||
|
const visible = cards.filter(c => !c.classList.contains('hidden'));
|
||||||
|
const totalWidth = visible.length * 300;
|
||||||
|
const pages = Math.max(1, Math.ceil(totalWidth / track.clientWidth));
|
||||||
|
indicatorContainer.innerHTML = '';
|
||||||
|
for (let i = 0; i < pages; i++) {
|
||||||
|
const dot = document.createElement('button');
|
||||||
|
dot.className = 'carousel-dot' + (i === 0 ? ' active' : '');
|
||||||
|
dot.addEventListener('click', () => {
|
||||||
|
track.scrollTo({ left: (track.scrollWidth / pages) * i, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
indicatorContainer.appendChild(dot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateIndicators() {
|
||||||
|
const dots = indicatorContainer.querySelectorAll('.carousel-dot');
|
||||||
|
if (!dots.length) return;
|
||||||
|
const ratio = track.scrollLeft / Math.max(1, track.scrollWidth - track.clientWidth);
|
||||||
|
const idx = Math.round(ratio * (dots.length - 1));
|
||||||
|
dots.forEach((d, i) => d.classList.toggle('active', i === idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
leftArrow.addEventListener('click', () => {
|
||||||
|
track.scrollBy({ left: -SCROLL_AMOUNT, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
rightArrow.addEventListener('click', () => {
|
||||||
|
track.scrollBy({ left: SCROLL_AMOUNT, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
track.addEventListener('scroll', () => {
|
||||||
|
updateArrows();
|
||||||
|
updateIndicators();
|
||||||
|
});
|
||||||
|
|
||||||
|
filterBtns.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
filterBtns.forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
const filter = btn.dataset.filter;
|
||||||
|
|
||||||
|
cards.forEach(card => {
|
||||||
|
if (filter === 'all' || card.dataset.category === filter) {
|
||||||
|
card.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
card.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
track.scrollTo({ left: 0 });
|
||||||
|
buildIndicators();
|
||||||
|
updateArrows();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
buildIndicators();
|
||||||
|
updateArrows();
|
||||||
|
window.addEventListener('resize', () => { buildIndicators(); updateArrows(); });
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Obfuscated email - assembled at runtime to defeat scrapers
|
||||||
|
(function() {
|
||||||
|
const p = ['smittix', 'outlook', 'com'];
|
||||||
|
const addr = p[0] + '@' + p[1] + '.' + p[2];
|
||||||
|
const card = document.getElementById('email-card');
|
||||||
|
const text = document.getElementById('email-text');
|
||||||
|
const footerLink = document.getElementById('footer-email');
|
||||||
|
let revealed = false;
|
||||||
|
|
||||||
|
card.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!revealed) {
|
||||||
|
text.textContent = addr;
|
||||||
|
revealed = true;
|
||||||
|
} else {
|
||||||
|
window.location.href = 'mail' + 'to:' + addr;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
footerLink.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.location.href = 'mail' + 'to:' + addr;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Animated satellite & signal background
|
||||||
|
(function() {
|
||||||
|
const canvas = document.getElementById('bg-canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
let w, h, dpr;
|
||||||
|
let orbits = [];
|
||||||
|
let pulses = [];
|
||||||
|
let particles = [];
|
||||||
|
let mouse = { x: -1000, y: -1000 };
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
w = window.innerWidth;
|
||||||
|
h = document.documentElement.scrollHeight;
|
||||||
|
canvas.width = w * dpr;
|
||||||
|
canvas.height = h * dpr;
|
||||||
|
canvas.style.width = w + 'px';
|
||||||
|
canvas.style.height = h + 'px';
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orbital paths with satellites
|
||||||
|
function createOrbits() {
|
||||||
|
orbits = [];
|
||||||
|
const count = Math.max(4, Math.floor(w / 300));
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const cx = Math.random() * w;
|
||||||
|
const cy = Math.random() * h;
|
||||||
|
const rx = 120 + Math.random() * 280;
|
||||||
|
const ry = 40 + Math.random() * 100;
|
||||||
|
const tilt = (Math.random() - 0.5) * 1.2;
|
||||||
|
const speed = (0.0002 + Math.random() * 0.0004) * (Math.random() > 0.5 ? 1 : -1);
|
||||||
|
const sats = [];
|
||||||
|
const satCount = 1 + Math.floor(Math.random() * 2);
|
||||||
|
for (let j = 0; j < satCount; j++) {
|
||||||
|
sats.push({ angle: Math.random() * Math.PI * 2, pulseTimer: 0 });
|
||||||
|
}
|
||||||
|
orbits.push({ cx, cy, rx, ry, tilt, speed, sats });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating signal particles (tiny dots drifting upward)
|
||||||
|
function createParticles() {
|
||||||
|
particles = [];
|
||||||
|
const count = Math.max(30, Math.floor((w * h) / 25000));
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
particles.push({
|
||||||
|
x: Math.random() * w,
|
||||||
|
y: Math.random() * h,
|
||||||
|
vy: -(0.08 + Math.random() * 0.15),
|
||||||
|
vx: (Math.random() - 0.5) * 0.1,
|
||||||
|
size: 0.5 + Math.random() * 1.2,
|
||||||
|
alpha: 0.1 + Math.random() * 0.25,
|
||||||
|
flicker: Math.random() * Math.PI * 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnPulse(x, y) {
|
||||||
|
pulses.push({ x, y, r: 2, maxR: 50 + Math.random() * 40, alpha: 0.35 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawOrbitPath(orbit) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(orbit.cx, orbit.cy);
|
||||||
|
ctx.rotate(orbit.tilt);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(0, 0, orbit.rx, orbit.ry, 0, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = 'rgba(0, 212, 170, 0.04)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSatellite(orbit, sat, dt) {
|
||||||
|
sat.angle += orbit.speed * dt;
|
||||||
|
const cos = Math.cos(orbit.tilt);
|
||||||
|
const sin = Math.sin(orbit.tilt);
|
||||||
|
const ex = orbit.rx * Math.cos(sat.angle);
|
||||||
|
const ey = orbit.ry * Math.sin(sat.angle);
|
||||||
|
const sx = orbit.cx + ex * cos - ey * sin;
|
||||||
|
const sy = orbit.cy + ex * sin + ey * cos;
|
||||||
|
|
||||||
|
// Satellite dot
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(sx, sy, 2, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = 'rgba(0, 212, 170, 0.7)';
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Faint glow
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(sx, sy, 6, 0, Math.PI * 2);
|
||||||
|
const g = ctx.createRadialGradient(sx, sy, 0, sx, sy, 6);
|
||||||
|
g.addColorStop(0, 'rgba(0, 212, 170, 0.15)');
|
||||||
|
g.addColorStop(1, 'rgba(0, 212, 170, 0)');
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Periodic signal pulse
|
||||||
|
sat.pulseTimer += dt;
|
||||||
|
if (sat.pulseTimer > 3000 + Math.random() * 500) {
|
||||||
|
sat.pulseTimer = 0;
|
||||||
|
spawnPulse(sx, sy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPulses(dt) {
|
||||||
|
for (let i = pulses.length - 1; i >= 0; i--) {
|
||||||
|
const p = pulses[i];
|
||||||
|
p.r += dt * 0.025;
|
||||||
|
p.alpha = 0.35 * (1 - p.r / p.maxR);
|
||||||
|
if (p.r >= p.maxR) { pulses.splice(i, 1); continue; }
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = `rgba(0, 212, 170, ${p.alpha})`;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Second ring
|
||||||
|
if (p.r > 12) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.r * 0.6, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = `rgba(0, 136, 255, ${p.alpha * 0.5})`;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawParticles(dt, time) {
|
||||||
|
for (const p of particles) {
|
||||||
|
p.y += p.vy * dt * 0.06;
|
||||||
|
p.x += p.vx * dt * 0.06;
|
||||||
|
p.flicker += dt * 0.002;
|
||||||
|
|
||||||
|
if (p.y < -10) { p.y = h + 10; p.x = Math.random() * w; }
|
||||||
|
if (p.x < -10) p.x = w + 10;
|
||||||
|
if (p.x > w + 10) p.x = -10;
|
||||||
|
|
||||||
|
const flick = p.alpha * (0.6 + 0.4 * Math.sin(p.flicker));
|
||||||
|
|
||||||
|
// Mouse interaction - subtle brighten
|
||||||
|
const dx = p.x - mouse.x;
|
||||||
|
const dy = p.y - mouse.y;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const boost = dist < 150 ? 0.3 * (1 - dist / 150) : 0;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = `rgba(0, 212, 170, ${Math.min(flick + boost, 0.6)})`;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Faint grid lines (signal grid)
|
||||||
|
function drawGrid(time) {
|
||||||
|
ctx.strokeStyle = 'rgba(0, 212, 170, 0.015)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
const spacing = 120;
|
||||||
|
const offset = (time * 0.005) % spacing;
|
||||||
|
|
||||||
|
for (let x = -spacing + offset; x < w + spacing; x += spacing) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x, h);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let y = -spacing + offset * 0.7; y < h + spacing; y += spacing) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(w, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let last = 0;
|
||||||
|
function animate(now) {
|
||||||
|
const dt = last ? Math.min(now - last, 50) : 16;
|
||||||
|
last = now;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
drawGrid(now);
|
||||||
|
|
||||||
|
for (const orbit of orbits) {
|
||||||
|
drawOrbitPath(orbit);
|
||||||
|
for (const sat of orbit.sats) {
|
||||||
|
drawSatellite(orbit, sat, dt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawPulses(dt);
|
||||||
|
drawParticles(dt, now);
|
||||||
|
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track mouse for particle interaction
|
||||||
|
document.addEventListener('mousemove', (e) => {
|
||||||
|
mouse.x = e.clientX;
|
||||||
|
mouse.y = e.clientY + window.scrollY;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resize handling
|
||||||
|
let resizeTimer;
|
||||||
|
function handleResize() {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(() => {
|
||||||
|
resize();
|
||||||
|
createOrbits();
|
||||||
|
createParticles();
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep canvas height synced with document
|
||||||
|
const ro = new ResizeObserver(() => { handleResize(); });
|
||||||
|
ro.observe(document.documentElement);
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
resize();
|
||||||
|
createOrbits();
|
||||||
|
createParticles();
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -17,6 +17,22 @@
|
|||||||
--gradient-end: #0088ff;
|
--gradient-end: #0088ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Animated background canvas */
|
||||||
|
#bg-canvas {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body > *:not(#bg-canvas) {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -245,18 +261,74 @@ section h2 {
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.features-grid {
|
/* Category filter tabs */
|
||||||
display: grid;
|
.carousel-filters {
|
||||||
grid-template-columns: repeat(4, 1fr);
|
display: flex;
|
||||||
gap: 24px;
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Carousel */
|
||||||
|
.carousel-wrapper {
|
||||||
|
position: relative;
|
||||||
|
padding: 0 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-track {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
padding: 8px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-track::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card {
|
.feature-card {
|
||||||
|
flex: 0 0 280px;
|
||||||
|
scroll-snap-align: start;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 32px 24px;
|
padding: 32px 24px;
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card.hidden {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card:hover {
|
.feature-card:hover {
|
||||||
@@ -266,8 +338,15 @@ section h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.feature-icon {
|
.feature-icon {
|
||||||
font-size: 2rem;
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-card h3 {
|
.feature-card h3 {
|
||||||
@@ -283,6 +362,81 @@ section h2 {
|
|||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Carousel arrows */
|
||||||
|
.carousel-arrow {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-arrow:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-arrow:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-arrow:disabled:hover {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-arrow-left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-arrow-right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Carousel indicators */
|
||||||
|
.carousel-indicators {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--border);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-dot.active {
|
||||||
|
background: var(--accent);
|
||||||
|
width: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-dot:hover {
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
/* Screenshots */
|
/* Screenshots */
|
||||||
.screenshot-gallery {
|
.screenshot-gallery {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -550,6 +704,72 @@ section h2 {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Support & Contact */
|
||||||
|
.support {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px 24px;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.3s;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-card:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-card.support-coffee {
|
||||||
|
border-color: rgba(255, 193, 59, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-card.support-coffee:hover {
|
||||||
|
border-color: #ffc13b;
|
||||||
|
box-shadow: 0 8px 30px rgba(255, 193, 59, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-card.support-coffee .support-icon {
|
||||||
|
color: #ffc13b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-card h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-card p {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
/* Footer */
|
/* Footer */
|
||||||
.footer {
|
.footer {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
@@ -641,14 +861,22 @@ section h2 {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.features-grid {
|
.carousel-wrapper {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
padding: 0 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
flex: 0 0 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenshot-gallery {
|
.screenshot-gallery {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.support-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.install-options {
|
.install-options {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -669,14 +897,35 @@ section h2 {
|
|||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.features-grid {
|
.carousel-wrapper {
|
||||||
grid-template-columns: 1fr;
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-arrow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
flex: 0 0 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-filters {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 6px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screenshot-gallery {
|
.screenshot-gallery {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.support-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-links {
|
.nav-links {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "intercept"
|
name = "intercept"
|
||||||
version = "2.16.0"
|
version = "2.19.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"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ def register_blueprints(app):
|
|||||||
from .ais import ais_bp
|
from .ais import ais_bp
|
||||||
from .dsc import dsc_bp
|
from .dsc import dsc_bp
|
||||||
from .acars import acars_bp
|
from .acars import acars_bp
|
||||||
|
from .vdl2 import vdl2_bp
|
||||||
from .aprs import aprs_bp
|
from .aprs import aprs_bp
|
||||||
from .satellite import satellite_bp
|
from .satellite import satellite_bp
|
||||||
from .gps import gps_bp
|
from .gps import gps_bp
|
||||||
@@ -46,6 +47,7 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(ais_bp)
|
app.register_blueprint(ais_bp)
|
||||||
app.register_blueprint(dsc_bp) # VHF DSC maritime distress
|
app.register_blueprint(dsc_bp) # VHF DSC maritime distress
|
||||||
app.register_blueprint(acars_bp)
|
app.register_blueprint(acars_bp)
|
||||||
|
app.register_blueprint(vdl2_bp)
|
||||||
app.register_blueprint(aprs_bp)
|
app.register_blueprint(aprs_bp)
|
||||||
app.register_blueprint(satellite_bp)
|
app.register_blueprint(satellite_bp)
|
||||||
app.register_blueprint(gps_bp)
|
app.register_blueprint(gps_bp)
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ 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.sdr import SDRFactory, SDRType
|
||||||
from utils.event_pipeline import process_event
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
@@ -250,12 +251,22 @@ def start_acars() -> Response:
|
|||||||
acars_message_count = 0
|
acars_message_count = 0
|
||||||
acars_last_message_time = None
|
acars_last_message_time = None
|
||||||
|
|
||||||
|
# Resolve SDR type for device selection
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
try:
|
||||||
|
sdr_type = SDRType(sdr_type_str)
|
||||||
|
except ValueError:
|
||||||
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
|
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
|
||||||
|
|
||||||
# Build acarsdec command
|
# Build acarsdec command
|
||||||
# Different forks have different syntax:
|
# Different forks have different syntax:
|
||||||
# - TLeconte v4+: acarsdec -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
# - TLeconte v4+: acarsdec -j -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||||
# - TLeconte v3: acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
# - TLeconte v3: acarsdec -o 4 -g <gain> -p <ppm> -r <device> <freq1> <freq2> ...
|
||||||
# - f00b4r0 (DragonOS): acarsdec --output json:file:- -g <gain> -p <ppm> -r <device> <freq1> ...
|
# - f00b4r0 (DragonOS): acarsdec --output json:file:- -g <gain> -p <ppm> -r <device> <freq1> ...
|
||||||
# Note: gain/ppm must come BEFORE -r
|
# SoapySDR devices: TLeconte uses -d <device_string>, f00b4r0 uses --soapysdr <device_string>
|
||||||
|
# Note: gain/ppm must come BEFORE -r/-d
|
||||||
json_flag = get_acarsdec_json_flag(acarsdec_path)
|
json_flag = get_acarsdec_json_flag(acarsdec_path)
|
||||||
cmd = [acarsdec_path]
|
cmd = [acarsdec_path]
|
||||||
if json_flag == '--output':
|
if json_flag == '--output':
|
||||||
@@ -266,21 +277,33 @@ def start_acars() -> Response:
|
|||||||
else:
|
else:
|
||||||
cmd.extend(['-o', '4']) # JSON output (TLeconte v3.x)
|
cmd.extend(['-o', '4']) # JSON output (TLeconte v3.x)
|
||||||
|
|
||||||
# Add gain if not auto (must be before -r)
|
# Add gain if not auto (must be before -r/-d)
|
||||||
if gain and str(gain) != '0':
|
if gain and str(gain) != '0':
|
||||||
cmd.extend(['-g', str(gain)])
|
cmd.extend(['-g', str(gain)])
|
||||||
|
|
||||||
# Add PPM correction if specified (must be before -r)
|
# Add PPM correction if specified (must be before -r/-d)
|
||||||
if ppm and str(ppm) != '0':
|
if ppm and str(ppm) != '0':
|
||||||
cmd.extend(['-p', str(ppm)])
|
cmd.extend(['-p', str(ppm)])
|
||||||
|
|
||||||
# Add device and frequencies
|
# Add device and frequencies
|
||||||
# f00b4r0 uses --rtlsdr <device>, TLeconte uses -r <device>
|
if is_soapy:
|
||||||
if json_flag == '--output':
|
# SoapySDR device (SDRplay, LimeSDR, Airspy, etc.)
|
||||||
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_int)
|
||||||
|
# Build SoapySDR driver string (e.g., "driver=sdrplay,serial=...")
|
||||||
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
|
device_str = builder._build_device_string(sdr_device)
|
||||||
|
if json_flag == '--output':
|
||||||
|
cmd.extend(['-m', '256'])
|
||||||
|
cmd.extend(['--soapysdr', device_str])
|
||||||
|
else:
|
||||||
|
cmd.extend(['-d', device_str])
|
||||||
|
elif json_flag == '--output':
|
||||||
|
# f00b4r0 fork RTL-SDR: --rtlsdr <device>
|
||||||
# Use 3.2 MS/s sample rate for wider bandwidth (handles NA frequency span)
|
# Use 3.2 MS/s sample rate for wider bandwidth (handles NA frequency span)
|
||||||
cmd.extend(['-m', '256'])
|
cmd.extend(['-m', '256'])
|
||||||
cmd.extend(['--rtlsdr', str(device)])
|
cmd.extend(['--rtlsdr', str(device)])
|
||||||
else:
|
else:
|
||||||
|
# TLeconte fork RTL-SDR: -r <device>
|
||||||
cmd.extend(['-r', str(device)])
|
cmd.extend(['-r', str(device)])
|
||||||
cmd.extend(frequencies)
|
cmd.extend(frequencies)
|
||||||
|
|
||||||
@@ -392,13 +415,13 @@ def stream_acars() -> Response:
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
msg = app_module.acars_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
try:
|
try:
|
||||||
process_event('acars', msg, msg.get('type'))
|
process_event('acars', msg, msg.get('type'))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from config import (
|
|||||||
SHARED_OBSERVER_LOCATION_ENABLED,
|
SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
)
|
)
|
||||||
from utils.logging import adsb_logger as logger
|
from utils.logging import adsb_logger as logger
|
||||||
|
from utils.process import write_dump1090_pid, clear_dump1090_pid, cleanup_stale_dump1090
|
||||||
from utils.validation import (
|
from utils.validation import (
|
||||||
validate_device_index, validate_gain,
|
validate_device_index, validate_gain,
|
||||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||||
@@ -633,6 +634,9 @@ def start_adsb():
|
|||||||
'session': session
|
'session': session
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Kill any stale app-spawned dump1090 from a previous run before checking the port
|
||||||
|
cleanup_stale_dump1090()
|
||||||
|
|
||||||
# Check if dump1090 is already running externally (e.g., user started it manually)
|
# Check if dump1090 is already running externally (e.g., user started it manually)
|
||||||
existing_service = check_dump1090_service()
|
existing_service = check_dump1090_service()
|
||||||
if existing_service:
|
if existing_service:
|
||||||
@@ -685,6 +689,7 @@ def start_adsb():
|
|||||||
except (ProcessLookupError, OSError):
|
except (ProcessLookupError, OSError):
|
||||||
pass
|
pass
|
||||||
app_module.adsb_process = None
|
app_module.adsb_process = None
|
||||||
|
clear_dump1090_pid()
|
||||||
logger.info("Killed stale ADS-B process")
|
logger.info("Killed stale ADS-B process")
|
||||||
|
|
||||||
# Check if device is available before starting local dump1090
|
# Check if device is available before starting local dump1090
|
||||||
@@ -721,6 +726,7 @@ def start_adsb():
|
|||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
start_new_session=True # Create new process group for clean shutdown
|
start_new_session=True # Create new process group for clean shutdown
|
||||||
)
|
)
|
||||||
|
write_dump1090_pid(app_module.adsb_process.pid)
|
||||||
|
|
||||||
time.sleep(DUMP1090_START_WAIT)
|
time.sleep(DUMP1090_START_WAIT)
|
||||||
|
|
||||||
@@ -819,6 +825,7 @@ def stop_adsb():
|
|||||||
except (ProcessLookupError, OSError):
|
except (ProcessLookupError, OSError):
|
||||||
pass
|
pass
|
||||||
app_module.adsb_process = None
|
app_module.adsb_process = None
|
||||||
|
clear_dump1090_pid()
|
||||||
logger.info("ADS-B process stopped")
|
logger.info("ADS-B process stopped")
|
||||||
|
|
||||||
# Release device from registry
|
# Release device from registry
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ def start_scan():
|
|||||||
rssi_threshold = data.get('rssi_threshold', -100)
|
rssi_threshold = data.get('rssi_threshold', -100)
|
||||||
|
|
||||||
# Validate mode
|
# Validate mode
|
||||||
valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl')
|
valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'ubertooth')
|
||||||
if mode not in valid_modes:
|
if mode not in valid_modes:
|
||||||
return jsonify({'error': f'Invalid mode. Must be one of: {valid_modes}'}), 400
|
return jsonify({'error': f'Invalid mode. Must be one of: {valid_modes}'}), 400
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,14 @@ from flask import Blueprint, Response, jsonify
|
|||||||
from utils.gps import (
|
from utils.gps import (
|
||||||
GPSPosition,
|
GPSPosition,
|
||||||
GPSSkyData,
|
GPSSkyData,
|
||||||
|
detect_gps_devices,
|
||||||
get_current_position,
|
get_current_position,
|
||||||
get_gps_reader,
|
get_gps_reader,
|
||||||
|
is_gpsd_running,
|
||||||
start_gpsd,
|
start_gpsd,
|
||||||
|
start_gpsd_daemon,
|
||||||
stop_gps,
|
stop_gps,
|
||||||
|
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 format_sse
|
||||||
@@ -58,10 +62,9 @@ def auto_connect_gps():
|
|||||||
Automatically connect to gpsd if available.
|
Automatically connect to gpsd if available.
|
||||||
|
|
||||||
Called on page load to seamlessly enable GPS if gpsd is running.
|
Called on page load to seamlessly enable GPS if gpsd is running.
|
||||||
|
If gpsd is not running, attempts to detect GPS devices and start gpsd.
|
||||||
Returns current status if already connected.
|
Returns current status if already connected.
|
||||||
"""
|
"""
|
||||||
import socket
|
|
||||||
|
|
||||||
# Check if already running
|
# Check if already running
|
||||||
reader = get_gps_reader()
|
reader = get_gps_reader()
|
||||||
if reader and reader.is_running:
|
if reader and reader.is_running:
|
||||||
@@ -75,21 +78,28 @@ def auto_connect_gps():
|
|||||||
'sky': sky.to_dict() if sky else None,
|
'sky': sky.to_dict() if sky else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Try to connect to gpsd on localhost:2947
|
|
||||||
host = 'localhost'
|
host = 'localhost'
|
||||||
port = 2947
|
port = 2947
|
||||||
|
|
||||||
# First check if gpsd is reachable
|
# If gpsd isn't running, try to detect a device and start it
|
||||||
try:
|
if not is_gpsd_running(host, port):
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
devices = detect_gps_devices()
|
||||||
sock.settimeout(1.0)
|
if not devices:
|
||||||
sock.connect((host, port))
|
return jsonify({
|
||||||
sock.close()
|
'status': 'unavailable',
|
||||||
except Exception:
|
'message': 'No GPS device detected'
|
||||||
return jsonify({
|
})
|
||||||
'status': 'unavailable',
|
|
||||||
'message': 'gpsd not running'
|
# Try to start gpsd with the first detected device
|
||||||
})
|
device_path = devices[0]['path']
|
||||||
|
success, msg = start_gpsd_daemon(device_path, host, port)
|
||||||
|
if not success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'unavailable',
|
||||||
|
'message': msg,
|
||||||
|
'devices': devices,
|
||||||
|
})
|
||||||
|
logger.info(f"Auto-started gpsd on {device_path}")
|
||||||
|
|
||||||
# Clear the queue
|
# Clear the queue
|
||||||
while not _gps_queue.empty():
|
while not _gps_queue.empty():
|
||||||
@@ -118,15 +128,26 @@ def auto_connect_gps():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@gps_bp.route('/devices')
|
||||||
|
def list_gps_devices():
|
||||||
|
"""List detected GPS serial devices."""
|
||||||
|
devices = detect_gps_devices()
|
||||||
|
return jsonify({
|
||||||
|
'devices': devices,
|
||||||
|
'gpsd_running': is_gpsd_running(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@gps_bp.route('/stop', methods=['POST'])
|
@gps_bp.route('/stop', methods=['POST'])
|
||||||
def stop_gps_reader():
|
def stop_gps_reader():
|
||||||
"""Stop GPS client."""
|
"""Stop GPS client and gpsd daemon if we started it."""
|
||||||
reader = get_gps_reader()
|
reader = get_gps_reader()
|
||||||
if reader:
|
if reader:
|
||||||
reader.remove_callback(_position_callback)
|
reader.remove_callback(_position_callback)
|
||||||
reader.remove_sky_callback(_sky_callback)
|
reader.remove_sky_callback(_sky_callback)
|
||||||
|
|
||||||
stop_gps()
|
stop_gps()
|
||||||
|
stop_gpsd_daemon()
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|||||||
@@ -199,10 +199,16 @@ def start_sensor() -> Response:
|
|||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
# Monitor stderr
|
# Monitor stderr
|
||||||
|
# Filter noisy rtl_433 diagnostics that aren't useful to display
|
||||||
|
_stderr_noise = (
|
||||||
|
'bitbuffer_add_bit',
|
||||||
|
'row count limit',
|
||||||
|
)
|
||||||
|
|
||||||
def monitor_stderr():
|
def monitor_stderr():
|
||||||
for line in app_module.sensor_process.stderr:
|
for line in app_module.sensor_process.stderr:
|
||||||
err = line.decode('utf-8', errors='replace').strip()
|
err = line.decode('utf-8', errors='replace').strip()
|
||||||
if err:
|
if err and not any(noise in err for noise in _stderr_noise):
|
||||||
logger.debug(f"[rtl_433] {err}")
|
logger.debug(f"[rtl_433] {err}")
|
||||||
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
"""VDL2 aircraft datalink routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import queue
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
import app as app_module
|
||||||
|
from utils.logging import sensor_logger as logger
|
||||||
|
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.sse import format_sse
|
||||||
|
from utils.event_pipeline import process_event
|
||||||
|
from utils.constants import (
|
||||||
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
PROCESS_START_WAIT,
|
||||||
|
)
|
||||||
|
from utils.process import register_process, unregister_process
|
||||||
|
|
||||||
|
vdl2_bp = Blueprint('vdl2', __name__, url_prefix='/vdl2')
|
||||||
|
|
||||||
|
# Default VDL2 frequencies (MHz) - common worldwide
|
||||||
|
DEFAULT_VDL2_FREQUENCIES = [
|
||||||
|
'136975000', # Primary worldwide
|
||||||
|
'136725000', # Europe
|
||||||
|
'136775000', # Europe
|
||||||
|
'136800000', # Multi-region
|
||||||
|
'136875000', # Multi-region
|
||||||
|
]
|
||||||
|
|
||||||
|
# Message counter for statistics
|
||||||
|
vdl2_message_count = 0
|
||||||
|
vdl2_last_message_time = None
|
||||||
|
|
||||||
|
# Track which device is being used
|
||||||
|
vdl2_active_device: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def find_dumpvdl2():
|
||||||
|
"""Find dumpvdl2 binary."""
|
||||||
|
return shutil.which('dumpvdl2')
|
||||||
|
|
||||||
|
|
||||||
|
def stream_vdl2_output(process: subprocess.Popen) -> None:
|
||||||
|
"""Stream dumpvdl2 JSON output to queue."""
|
||||||
|
global vdl2_message_count, vdl2_last_message_time
|
||||||
|
|
||||||
|
try:
|
||||||
|
app_module.vdl2_queue.put({'type': 'status', 'status': 'started'})
|
||||||
|
|
||||||
|
for line in iter(process.stdout.readline, b''):
|
||||||
|
line = line.decode('utf-8', errors='replace').strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(line)
|
||||||
|
|
||||||
|
# Add our metadata
|
||||||
|
data['type'] = 'vdl2'
|
||||||
|
data['timestamp'] = datetime.utcnow().isoformat() + 'Z'
|
||||||
|
|
||||||
|
# Update stats
|
||||||
|
vdl2_message_count += 1
|
||||||
|
vdl2_last_message_time = time.time()
|
||||||
|
|
||||||
|
app_module.vdl2_queue.put(data)
|
||||||
|
|
||||||
|
# Log if enabled
|
||||||
|
if app_module.logging_enabled:
|
||||||
|
try:
|
||||||
|
with open(app_module.log_file_path, 'a') as f:
|
||||||
|
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
f.write(f"{ts} | VDL2 | {json.dumps(data)}\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Not JSON - could be status message
|
||||||
|
if line:
|
||||||
|
logger.debug(f"dumpvdl2 non-JSON: {line[:100]}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"VDL2 stream error: {e}")
|
||||||
|
app_module.vdl2_queue.put({'type': 'error', 'message': str(e)})
|
||||||
|
finally:
|
||||||
|
global vdl2_active_device
|
||||||
|
# Ensure process is terminated
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
process.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
unregister_process(process)
|
||||||
|
app_module.vdl2_queue.put({'type': 'status', 'status': 'stopped'})
|
||||||
|
with app_module.vdl2_lock:
|
||||||
|
app_module.vdl2_process = None
|
||||||
|
# Release SDR device
|
||||||
|
if vdl2_active_device is not None:
|
||||||
|
app_module.release_sdr_device(vdl2_active_device)
|
||||||
|
vdl2_active_device = None
|
||||||
|
|
||||||
|
|
||||||
|
@vdl2_bp.route('/tools')
|
||||||
|
def check_vdl2_tools() -> Response:
|
||||||
|
"""Check for VDL2 decoding tools."""
|
||||||
|
has_dumpvdl2 = find_dumpvdl2() is not None
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'dumpvdl2': has_dumpvdl2,
|
||||||
|
'ready': has_dumpvdl2
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@vdl2_bp.route('/status')
|
||||||
|
def vdl2_status() -> Response:
|
||||||
|
"""Get VDL2 decoder status."""
|
||||||
|
running = False
|
||||||
|
if app_module.vdl2_process:
|
||||||
|
running = app_module.vdl2_process.poll() is None
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'running': running,
|
||||||
|
'message_count': vdl2_message_count,
|
||||||
|
'last_message_time': vdl2_last_message_time,
|
||||||
|
'queue_size': app_module.vdl2_queue.qsize()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@vdl2_bp.route('/start', methods=['POST'])
|
||||||
|
def start_vdl2() -> Response:
|
||||||
|
"""Start VDL2 decoder."""
|
||||||
|
global vdl2_message_count, vdl2_last_message_time, vdl2_active_device
|
||||||
|
|
||||||
|
with app_module.vdl2_lock:
|
||||||
|
if app_module.vdl2_process and app_module.vdl2_process.poll() is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'VDL2 decoder already running'
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
# Check for dumpvdl2
|
||||||
|
dumpvdl2_path = find_dumpvdl2()
|
||||||
|
if not dumpvdl2_path:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'dumpvdl2 not found. Install from: https://github.com/szpajder/dumpvdl2'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
# Validate inputs
|
||||||
|
try:
|
||||||
|
device = validate_device_index(data.get('device', '0'))
|
||||||
|
gain = validate_gain(data.get('gain', '40'))
|
||||||
|
ppm = validate_ppm(data.get('ppm', '0'))
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
|
# Check if device is available
|
||||||
|
device_int = int(device)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'vdl2')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
vdl2_active_device = device_int
|
||||||
|
|
||||||
|
# Get frequencies - use provided or defaults
|
||||||
|
# dumpvdl2 expects frequencies in Hz (integers)
|
||||||
|
frequencies = data.get('frequencies', DEFAULT_VDL2_FREQUENCIES)
|
||||||
|
if isinstance(frequencies, str):
|
||||||
|
frequencies = [f.strip() for f in frequencies.split(',')]
|
||||||
|
|
||||||
|
# Clear queue
|
||||||
|
while not app_module.vdl2_queue.empty():
|
||||||
|
try:
|
||||||
|
app_module.vdl2_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Reset stats
|
||||||
|
vdl2_message_count = 0
|
||||||
|
vdl2_last_message_time = None
|
||||||
|
|
||||||
|
# Resolve SDR type for device selection
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
try:
|
||||||
|
sdr_type = SDRType(sdr_type_str)
|
||||||
|
except ValueError:
|
||||||
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
|
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
|
||||||
|
|
||||||
|
# Build dumpvdl2 command
|
||||||
|
# dumpvdl2 --output decoded:json --rtlsdr <device> --gain <gain> --correction <ppm> <freq1> <freq2> ...
|
||||||
|
cmd = [dumpvdl2_path]
|
||||||
|
cmd.extend(['--output', 'decoded:json:file:path=-'])
|
||||||
|
|
||||||
|
if is_soapy:
|
||||||
|
# SoapySDR device
|
||||||
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_int)
|
||||||
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
|
device_str = builder._build_device_string(sdr_device)
|
||||||
|
cmd.extend(['--soapysdr', device_str])
|
||||||
|
else:
|
||||||
|
cmd.extend(['--rtlsdr', str(device)])
|
||||||
|
|
||||||
|
# Add gain
|
||||||
|
if gain and str(gain) != '0':
|
||||||
|
cmd.extend(['--gain', str(gain)])
|
||||||
|
|
||||||
|
# Add PPM correction if specified
|
||||||
|
if ppm and str(ppm) != '0':
|
||||||
|
cmd.extend(['--correction', str(ppm)])
|
||||||
|
|
||||||
|
# Add frequencies (dumpvdl2 takes them as positional args in Hz)
|
||||||
|
cmd.extend(frequencies)
|
||||||
|
|
||||||
|
logger.info(f"Starting VDL2 decoder: {' '.join(cmd)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
start_new_session=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait briefly to check if process started
|
||||||
|
time.sleep(PROCESS_START_WAIT)
|
||||||
|
|
||||||
|
if process.poll() is not None:
|
||||||
|
# Process died - release device
|
||||||
|
if vdl2_active_device is not None:
|
||||||
|
app_module.release_sdr_device(vdl2_active_device)
|
||||||
|
vdl2_active_device = None
|
||||||
|
stderr = ''
|
||||||
|
if process.stderr:
|
||||||
|
stderr = process.stderr.read().decode('utf-8', errors='replace')
|
||||||
|
error_msg = 'dumpvdl2 failed to start'
|
||||||
|
if stderr:
|
||||||
|
error_msg += f': {stderr[:200]}'
|
||||||
|
logger.error(error_msg)
|
||||||
|
return jsonify({'status': 'error', 'message': error_msg}), 500
|
||||||
|
|
||||||
|
app_module.vdl2_process = process
|
||||||
|
register_process(process)
|
||||||
|
|
||||||
|
# Start output streaming thread
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=stream_vdl2_output,
|
||||||
|
args=(process,),
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'frequencies': frequencies,
|
||||||
|
'device': device,
|
||||||
|
'gain': gain
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Release device on failure
|
||||||
|
if vdl2_active_device is not None:
|
||||||
|
app_module.release_sdr_device(vdl2_active_device)
|
||||||
|
vdl2_active_device = None
|
||||||
|
logger.error(f"Failed to start VDL2 decoder: {e}")
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@vdl2_bp.route('/stop', methods=['POST'])
|
||||||
|
def stop_vdl2() -> Response:
|
||||||
|
"""Stop VDL2 decoder."""
|
||||||
|
global vdl2_active_device
|
||||||
|
|
||||||
|
with app_module.vdl2_lock:
|
||||||
|
if not app_module.vdl2_process:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'VDL2 decoder not running'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
app_module.vdl2_process.terminate()
|
||||||
|
app_module.vdl2_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
app_module.vdl2_process.kill()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error stopping VDL2: {e}")
|
||||||
|
|
||||||
|
app_module.vdl2_process = None
|
||||||
|
|
||||||
|
# Release device from registry
|
||||||
|
if vdl2_active_device is not None:
|
||||||
|
app_module.release_sdr_device(vdl2_active_device)
|
||||||
|
vdl2_active_device = None
|
||||||
|
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@vdl2_bp.route('/stream')
|
||||||
|
def stream_vdl2() -> Response:
|
||||||
|
"""SSE stream for VDL2 messages."""
|
||||||
|
def generate() -> Generator[str, None, None]:
|
||||||
|
last_keepalive = time.time()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = app_module.vdl2_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
|
last_keepalive = time.time()
|
||||||
|
try:
|
||||||
|
process_event('vdl2', msg, msg.get('type'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
yield format_sse(msg)
|
||||||
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@vdl2_bp.route('/frequencies')
|
||||||
|
def get_frequencies() -> Response:
|
||||||
|
"""Get default VDL2 frequencies."""
|
||||||
|
return jsonify({
|
||||||
|
'default': DEFAULT_VDL2_FREQUENCIES,
|
||||||
|
'regions': {
|
||||||
|
'north_america': ['136975000', '136100000', '136650000', '136700000', '136800000'],
|
||||||
|
'europe': ['136975000', '136675000', '136725000', '136775000', '136825000'],
|
||||||
|
'asia_pacific': ['136975000', '136900000'],
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -137,6 +137,14 @@ need_sudo() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Refresh sudo credential cache so long-running builds don't trigger
|
||||||
|
# mid-compilation password prompts (which can fail due to TTY issues
|
||||||
|
# inside subshells). Safe to call multiple times.
|
||||||
|
refresh_sudo() {
|
||||||
|
[[ -z "${SUDO:-}" ]] && return 0
|
||||||
|
sudo -v 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
detect_os() {
|
detect_os() {
|
||||||
if [[ "${OSTYPE:-}" == "darwin"* ]]; then
|
if [[ "${OSTYPE:-}" == "darwin"* ]]; then
|
||||||
OS="macos"
|
OS="macos"
|
||||||
@@ -218,6 +226,7 @@ check_tools() {
|
|||||||
check_optional "hackrf_sweep" "HackRF spectrum analyzer" hackrf_sweep
|
check_optional "hackrf_sweep" "HackRF spectrum analyzer" hackrf_sweep
|
||||||
check_required "dump1090" "ADS-B decoder" dump1090
|
check_required "dump1090" "ADS-B decoder" dump1090
|
||||||
check_required "acarsdec" "ACARS decoder" acarsdec
|
check_required "acarsdec" "ACARS decoder" acarsdec
|
||||||
|
check_optional "dumpvdl2" "VDL2 decoder" dumpvdl2
|
||||||
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
||||||
check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
|
check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
|
||||||
echo
|
echo
|
||||||
@@ -304,28 +313,41 @@ install_python_deps() {
|
|||||||
|
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
|
local PIP="venv/bin/python -m pip"
|
||||||
|
local PY="venv/bin/python"
|
||||||
|
|
||||||
python -m pip install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
|
$PIP install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
|
||||||
ok "Upgraded pip tooling"
|
ok "Upgraded pip tooling"
|
||||||
|
|
||||||
progress "Installing Python dependencies"
|
progress "Installing Python dependencies"
|
||||||
# Try pip install, but don't fail if apt packages already satisfied deps
|
|
||||||
if ! python -m pip install -r requirements.txt 2>/dev/null; then
|
|
||||||
warn "Some pip packages failed - checking if apt packages cover them..."
|
|
||||||
# Verify critical packages are available
|
|
||||||
python -c "import flask; import requests; from flask_limiter import Limiter" 2>/dev/null || {
|
|
||||||
fail "Critical Python packages (flask, requests, flask-limiter) not installed"
|
|
||||||
echo "Try: pip install flask requests flask-limiter"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
ok "Core Python dependencies available"
|
|
||||||
else
|
|
||||||
ok "Python dependencies installed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Ensure Flask 3.0+ is installed (required for Werkzeug 3.x compatibility)
|
# Install critical packages first to avoid all-or-nothing failures
|
||||||
# System apt packages may have older Flask 2.x which is incompatible
|
# (C extension packages like scipy/numpy can fail on newer Python versions
|
||||||
python -m pip install --upgrade "flask>=3.0.0" >/dev/null 2>&1 || true
|
# and cause pip to roll back pure-Python packages like flask)
|
||||||
|
info "Installing core packages..."
|
||||||
|
$PIP install "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 \
|
||||||
|
| tail -5 || true
|
||||||
|
|
||||||
|
# Verify critical packages
|
||||||
|
$PY -c "import flask; import requests; from flask_limiter import Limiter" 2>/dev/null || {
|
||||||
|
fail "Critical Python packages (flask, requests, flask-limiter) not installed"
|
||||||
|
echo "Try: venv/bin/pip install flask requests flask-limiter"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
ok "Core Python packages installed"
|
||||||
|
|
||||||
|
# Install optional packages individually (some may fail on newer Python)
|
||||||
|
info "Installing optional packages..."
|
||||||
|
for pkg in "numpy>=1.24.0" "scipy>=1.10.0" "Pillow>=9.0.0" "skyfield>=1.45" \
|
||||||
|
"bleak>=0.21.0" "psycopg2-binary>=2.9.9" "meshtastic>=2.0.0" \
|
||||||
|
"scapy>=2.4.5" "qrcode[pil]>=7.4" "cryptography>=41.0.0"; do
|
||||||
|
pkg_name="${pkg%%>=*}"
|
||||||
|
if ! $PIP install "$pkg" 2>/dev/null; then
|
||||||
|
warn "${pkg_name} failed to install (optional - related features may be unavailable)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
ok "Optional packages processed"
|
||||||
echo
|
echo
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +410,7 @@ install_rtlamr_from_source() {
|
|||||||
if [[ -w /usr/local/bin ]]; then
|
if [[ -w /usr/local/bin ]]; then
|
||||||
ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||||
else
|
else
|
||||||
sudo ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
$SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
$SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
$SUDO ln -sf "$GOPATH/bin/rtlamr" /usr/local/bin/rtlamr
|
||||||
@@ -430,7 +452,8 @@ install_multimon_ng_from_source_macos() {
|
|||||||
if [[ -w /usr/local/bin ]]; then
|
if [[ -w /usr/local/bin ]]; then
|
||||||
install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
||||||
else
|
else
|
||||||
sudo install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
refresh_sudo
|
||||||
|
$SUDO install -m 0755 multimon-ng /usr/local/bin/multimon-ng
|
||||||
fi
|
fi
|
||||||
ok "multimon-ng installed successfully from source"
|
ok "multimon-ng installed successfully from source"
|
||||||
)
|
)
|
||||||
@@ -471,7 +494,8 @@ install_dsd_from_source() {
|
|||||||
if [[ -w /usr/local/lib ]]; then
|
if [[ -w /usr/local/lib ]]; then
|
||||||
make install >/dev/null 2>&1
|
make install >/dev/null 2>&1
|
||||||
else
|
else
|
||||||
sudo make install >/dev/null 2>&1
|
refresh_sudo
|
||||||
|
$SUDO make install >/dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
$SUDO make install >/dev/null 2>&1
|
$SUDO make install >/dev/null 2>&1
|
||||||
@@ -507,7 +531,8 @@ install_dsd_from_source() {
|
|||||||
if [[ -w /usr/local/bin ]]; then
|
if [[ -w /usr/local/bin ]]; then
|
||||||
install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
|
install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
|
||||||
else
|
else
|
||||||
sudo install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || sudo install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
|
refresh_sudo
|
||||||
|
$SUDO install -m 0755 dsd-fme /usr/local/bin/dsd 2>/dev/null || $SUDO install -m 0755 dsd /usr/local/bin/dsd 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
$SUDO make install >/dev/null 2>&1 \
|
$SUDO make install >/dev/null 2>&1 \
|
||||||
@@ -545,7 +570,8 @@ install_dump1090_from_source_macos() {
|
|||||||
if [[ -w /usr/local/bin ]]; then
|
if [[ -w /usr/local/bin ]]; then
|
||||||
install -m 0755 dump1090 /usr/local/bin/dump1090
|
install -m 0755 dump1090 /usr/local/bin/dump1090
|
||||||
else
|
else
|
||||||
sudo install -m 0755 dump1090 /usr/local/bin/dump1090
|
refresh_sudo
|
||||||
|
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
||||||
fi
|
fi
|
||||||
ok "dump1090 installed successfully from source"
|
ok "dump1090 installed successfully from source"
|
||||||
else
|
else
|
||||||
@@ -604,6 +630,7 @@ install_acarsdec_from_source_macos() {
|
|||||||
info "Compiling acarsdec..."
|
info "Compiling acarsdec..."
|
||||||
build_log="$tmp_dir/acarsdec-build.log"
|
build_log="$tmp_dir/acarsdec-build.log"
|
||||||
if cmake .. -Drtl=ON \
|
if cmake .. -Drtl=ON \
|
||||||
|
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
|
||||||
-DCMAKE_C_FLAGS="-I${HOMEBREW_PREFIX}/include" \
|
-DCMAKE_C_FLAGS="-I${HOMEBREW_PREFIX}/include" \
|
||||||
-DCMAKE_EXE_LINKER_FLAGS="-L${HOMEBREW_PREFIX}/lib" \
|
-DCMAKE_EXE_LINKER_FLAGS="-L${HOMEBREW_PREFIX}/lib" \
|
||||||
>"$build_log" 2>&1 \
|
>"$build_log" 2>&1 \
|
||||||
@@ -611,7 +638,8 @@ install_acarsdec_from_source_macos() {
|
|||||||
if [[ -w /usr/local/bin ]]; then
|
if [[ -w /usr/local/bin ]]; then
|
||||||
install -m 0755 acarsdec /usr/local/bin/acarsdec
|
install -m 0755 acarsdec /usr/local/bin/acarsdec
|
||||||
else
|
else
|
||||||
sudo install -m 0755 acarsdec /usr/local/bin/acarsdec
|
refresh_sudo
|
||||||
|
$SUDO install -m 0755 acarsdec /usr/local/bin/acarsdec
|
||||||
fi
|
fi
|
||||||
ok "acarsdec installed successfully from source"
|
ok "acarsdec installed successfully from source"
|
||||||
else
|
else
|
||||||
@@ -622,6 +650,80 @@ install_acarsdec_from_source_macos() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install_dumpvdl2_from_source_macos() {
|
||||||
|
info "Building dumpvdl2 from source (with libacars dependency)..."
|
||||||
|
|
||||||
|
brew_install cmake
|
||||||
|
brew_install librtlsdr
|
||||||
|
brew_install pkg-config
|
||||||
|
brew_install glib
|
||||||
|
|
||||||
|
(
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
HOMEBREW_PREFIX="$(brew --prefix)"
|
||||||
|
export PKG_CONFIG_PATH="${HOMEBREW_PREFIX}/lib/pkgconfig:${PKG_CONFIG_PATH:-}"
|
||||||
|
export CMAKE_PREFIX_PATH="${HOMEBREW_PREFIX}"
|
||||||
|
|
||||||
|
# Build libacars first
|
||||||
|
info "Cloning libacars..."
|
||||||
|
git clone --depth 1 https://github.com/szpajder/libacars.git "$tmp_dir/libacars" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone libacars"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/libacars"
|
||||||
|
mkdir -p build && cd build
|
||||||
|
|
||||||
|
info "Compiling libacars..."
|
||||||
|
build_log="$tmp_dir/libacars-build.log"
|
||||||
|
if cmake .. \
|
||||||
|
-DCMAKE_C_FLAGS="-I${HOMEBREW_PREFIX}/include" \
|
||||||
|
-DCMAKE_EXE_LINKER_FLAGS="-L${HOMEBREW_PREFIX}/lib" \
|
||||||
|
>"$build_log" 2>&1 \
|
||||||
|
&& make >>"$build_log" 2>&1; then
|
||||||
|
if [[ -w /usr/local/lib ]]; then
|
||||||
|
make install >>"$build_log" 2>&1
|
||||||
|
else
|
||||||
|
refresh_sudo
|
||||||
|
$SUDO make install >>"$build_log" 2>&1
|
||||||
|
fi
|
||||||
|
ok "libacars installed"
|
||||||
|
else
|
||||||
|
warn "Failed to build libacars."
|
||||||
|
tail -20 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build dumpvdl2
|
||||||
|
info "Cloning dumpvdl2..."
|
||||||
|
git clone --depth 1 https://github.com/szpajder/dumpvdl2.git "$tmp_dir/dumpvdl2" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone dumpvdl2"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/dumpvdl2"
|
||||||
|
mkdir -p build && cd build
|
||||||
|
|
||||||
|
info "Compiling dumpvdl2..."
|
||||||
|
build_log="$tmp_dir/dumpvdl2-build.log"
|
||||||
|
if cmake .. \
|
||||||
|
-DCMAKE_C_FLAGS="-I${HOMEBREW_PREFIX}/include" \
|
||||||
|
-DCMAKE_EXE_LINKER_FLAGS="-L${HOMEBREW_PREFIX}/lib" \
|
||||||
|
>"$build_log" 2>&1 \
|
||||||
|
&& make >>"$build_log" 2>&1; then
|
||||||
|
if [[ -w /usr/local/bin ]]; then
|
||||||
|
install -m 0755 src/dumpvdl2 /usr/local/bin/dumpvdl2
|
||||||
|
else
|
||||||
|
refresh_sudo
|
||||||
|
$SUDO install -m 0755 src/dumpvdl2 /usr/local/bin/dumpvdl2
|
||||||
|
fi
|
||||||
|
ok "dumpvdl2 installed successfully from source"
|
||||||
|
else
|
||||||
|
warn "Failed to build dumpvdl2. VDL2 decoding will not be available."
|
||||||
|
warn "Build log (last 30 lines):"
|
||||||
|
tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
install_aiscatcher_from_source_macos() {
|
install_aiscatcher_from_source_macos() {
|
||||||
info "AIS-catcher not available via Homebrew. Building from source..."
|
info "AIS-catcher not available via Homebrew. Building from source..."
|
||||||
|
|
||||||
@@ -646,7 +748,8 @@ install_aiscatcher_from_source_macos() {
|
|||||||
if [[ -w /usr/local/bin ]]; then
|
if [[ -w /usr/local/bin ]]; then
|
||||||
install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
|
install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
|
||||||
else
|
else
|
||||||
sudo install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
|
refresh_sudo
|
||||||
|
$SUDO install -m 0755 AIS-catcher /usr/local/bin/AIS-catcher
|
||||||
fi
|
fi
|
||||||
ok "AIS-catcher installed successfully from source"
|
ok "AIS-catcher installed successfully from source"
|
||||||
else
|
else
|
||||||
@@ -673,6 +776,21 @@ install_satdump_from_source_debian() {
|
|||||||
|| { warn "Failed to clone SatDump"; exit 1; }
|
|| { warn "Failed to clone SatDump"; exit 1; }
|
||||||
|
|
||||||
cd "$tmp_dir/SatDump"
|
cd "$tmp_dir/SatDump"
|
||||||
|
|
||||||
|
# Patch: fix deprecated std::allocator usage for newer compilers
|
||||||
|
# GCC 13+ errors on deprecated allocator members in sol2.
|
||||||
|
# Pragmas must go in lua_utils.cpp (the instantiation site), not sol.hpp (definition site).
|
||||||
|
lua_utils="src-core/common/lua/lua_utils.cpp"
|
||||||
|
if [ -f "$lua_utils" ]; then
|
||||||
|
{
|
||||||
|
echo '#pragma GCC diagnostic push'
|
||||||
|
echo '#pragma GCC diagnostic ignored "-Wdeprecated"'
|
||||||
|
echo '#pragma GCC diagnostic ignored "-Wdeprecated-declarations"'
|
||||||
|
cat "$lua_utils"
|
||||||
|
echo '#pragma GCC diagnostic pop'
|
||||||
|
} > "${lua_utils}.patched" && mv "${lua_utils}.patched" "$lua_utils"
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p build && cd build
|
mkdir -p build && cd build
|
||||||
|
|
||||||
info "Compiling SatDump (this is a large C++ project and may take 10-30 minutes)..."
|
info "Compiling SatDump (this is a large C++ project and may take 10-30 minutes)..."
|
||||||
@@ -717,67 +835,78 @@ install_satdump_from_source_debian() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
install_satdump_from_source_macos() {
|
install_satdump_macos() {
|
||||||
info "Building SatDump v1.2.2 from source (weather satellite decoder)..."
|
info "Installing SatDump v1.2.2 from pre-built release (weather satellite decoder)..."
|
||||||
|
|
||||||
brew_install cmake
|
# Determine architecture
|
||||||
brew_install libpng
|
local arch
|
||||||
brew_install libtiff
|
arch="$(uname -m)"
|
||||||
brew_install jemalloc
|
local dmg_name
|
||||||
brew_install libvolk
|
if [ "$arch" = "arm64" ]; then
|
||||||
brew_install nng
|
dmg_name="SatDump-macOS-Silicon.dmg"
|
||||||
brew_install zstd
|
else
|
||||||
brew_install soapysdr
|
dmg_name="SatDump-macOS-Intel.dmg"
|
||||||
brew_install hackrf
|
fi
|
||||||
brew_install fftw
|
|
||||||
|
local dmg_url="https://github.com/SatDump/SatDump/releases/download/1.2.2/${dmg_name}"
|
||||||
|
local install_dir="/usr/local/lib/satdump"
|
||||||
|
|
||||||
# Run in subshell to isolate EXIT trap
|
# Run in subshell to isolate EXIT trap
|
||||||
(
|
(
|
||||||
tmp_dir="$(mktemp -d)"
|
tmp_dir="$(mktemp -d)"
|
||||||
trap 'rm -rf "$tmp_dir"' EXIT
|
trap 'hdiutil detach "$tmp_dir/mnt" -quiet 2>/dev/null || true; rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
info "Cloning SatDump v1.2.2..."
|
info "Downloading ${dmg_name}..."
|
||||||
git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git "$tmp_dir/SatDump" >/dev/null 2>&1 \
|
if ! curl -sL -o "$tmp_dir/satdump.dmg" "$dmg_url"; then
|
||||||
|| { warn "Failed to clone SatDump"; exit 1; }
|
warn "Failed to download SatDump. Weather satellite decoding will not be available."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
cd "$tmp_dir/SatDump"
|
info "Installing SatDump..."
|
||||||
mkdir -p build && cd build
|
# Mount the DMG
|
||||||
|
hdiutil attach "$tmp_dir/satdump.dmg" -nobrowse -quiet -mountpoint "$tmp_dir/mnt" \
|
||||||
|
|| { warn "Failed to mount SatDump DMG"; exit 1; }
|
||||||
|
|
||||||
info "Compiling SatDump (this is a large C++ project and may take 10-30 minutes)..."
|
local app_dir="$tmp_dir/mnt/SatDump.app"
|
||||||
build_log="$tmp_dir/satdump-build.log"
|
if [ ! -d "$app_dir" ]; then
|
||||||
|
warn "SatDump.app not found in DMG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Show periodic progress while building so the user knows it's not hung
|
# Install: copy app contents to /usr/local/lib/satdump
|
||||||
(
|
refresh_sudo
|
||||||
while true; do
|
$SUDO mkdir -p "$install_dir"
|
||||||
sleep 30
|
$SUDO cp -R "$app_dir/Contents/MacOS/"* "$install_dir/"
|
||||||
if [ -f "$build_log" ]; then
|
$SUDO cp -R "$app_dir/Contents/Resources/"* "$install_dir/"
|
||||||
local_lines=$(wc -l < "$build_log" 2>/dev/null || echo 0)
|
|
||||||
printf " [*] Still compiling SatDump... (%s lines of build output so far)\n" "$local_lines"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
) &
|
|
||||||
progress_pid=$!
|
|
||||||
|
|
||||||
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF .. >"$build_log" 2>&1 \
|
# Create wrapper script so satdump can find its resources via @executable_path
|
||||||
&& make -j "$(sysctl -n hw.ncpu)" >>"$build_log" 2>&1; then
|
$SUDO tee /usr/local/bin/satdump >/dev/null <<'WRAPPER'
|
||||||
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
|
#!/bin/sh
|
||||||
if [[ -w /usr/local/bin ]]; then
|
exec /usr/local/lib/satdump/satdump "$@"
|
||||||
make install >/dev/null 2>&1
|
WRAPPER
|
||||||
else
|
$SUDO chmod +x /usr/local/bin/satdump
|
||||||
sudo make install >/dev/null 2>&1
|
|
||||||
fi
|
hdiutil detach "$tmp_dir/mnt" -quiet 2>/dev/null
|
||||||
ok "SatDump installed successfully."
|
|
||||||
|
# Verify installation
|
||||||
|
if /usr/local/lib/satdump/satdump 2>&1 | grep -q "Usage"; then
|
||||||
|
ok "SatDump v1.2.2 installed successfully."
|
||||||
else
|
else
|
||||||
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
|
warn "SatDump installed but may not work correctly."
|
||||||
warn "Failed to build SatDump from source. Weather satellite decoding will not be available."
|
|
||||||
warn "Build log (last 30 lines):"
|
|
||||||
tail -30 "$build_log" | while IFS= read -r line; do warn " $line"; done
|
|
||||||
fi
|
fi
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
install_macos_packages() {
|
install_macos_packages() {
|
||||||
TOTAL_STEPS=19
|
need_sudo
|
||||||
|
|
||||||
|
# Prime sudo credentials upfront so builds don't prompt mid-compilation
|
||||||
|
if [[ -n "${SUDO:-}" ]]; then
|
||||||
|
info "Some tools require sudo to install. You may be prompted for your password."
|
||||||
|
sudo -v || { fail "sudo authentication failed"; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
TOTAL_STEPS=22
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Checking Homebrew"
|
progress "Checking Homebrew"
|
||||||
@@ -850,6 +979,13 @@ install_macos_packages() {
|
|||||||
ok "acarsdec already installed"
|
ok "acarsdec already installed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
progress "Installing dumpvdl2"
|
||||||
|
if ! cmd_exists dumpvdl2; then
|
||||||
|
install_dumpvdl2_from_source_macos || warn "dumpvdl2 not available. VDL2 decoding will not be available."
|
||||||
|
else
|
||||||
|
ok "dumpvdl2 already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
progress "Installing AIS-catcher"
|
progress "Installing AIS-catcher"
|
||||||
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
|
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
|
||||||
(brew_install aiscatcher) || install_aiscatcher_from_source_macos || warn "AIS-catcher not available"
|
(brew_install aiscatcher) || install_aiscatcher_from_source_macos || warn "AIS-catcher not available"
|
||||||
@@ -862,7 +998,7 @@ install_macos_packages() {
|
|||||||
echo
|
echo
|
||||||
info "SatDump is used for weather satellite imagery (NOAA APT & Meteor LRPT)."
|
info "SatDump is used for weather satellite imagery (NOAA APT & Meteor LRPT)."
|
||||||
if ask_yes_no "Do you want to install SatDump?"; then
|
if ask_yes_no "Do you want to install SatDump?"; then
|
||||||
install_satdump_from_source_macos || warn "SatDump build failed. Weather satellite decoding will not be available."
|
install_satdump_macos || warn "SatDump installation failed. Weather satellite decoding will not be available."
|
||||||
else
|
else
|
||||||
warn "Skipping SatDump installation. You can install it later if needed."
|
warn "Skipping SatDump installation. You can install it later if needed."
|
||||||
fi
|
fi
|
||||||
@@ -954,7 +1090,7 @@ 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 || sed -i '' 's/-Werror//g' Makefile
|
sed -i 's/-Werror//g' Makefile 2>/dev/null || true
|
||||||
info "Compiling FlightAware dump1090..."
|
info "Compiling FlightAware dump1090..."
|
||||||
if make BLADERF=no RTLSDR=yes >/dev/null 2>&1; then
|
if make BLADERF=no RTLSDR=yes >/dev/null 2>&1; then
|
||||||
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
||||||
@@ -995,7 +1131,7 @@ install_acarsdec_from_source_debian() {
|
|||||||
mkdir -p build && cd build
|
mkdir -p build && cd build
|
||||||
|
|
||||||
info "Compiling acarsdec..."
|
info "Compiling acarsdec..."
|
||||||
if cmake .. -Drtl=ON >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
if cmake .. -Drtl=ON -DCMAKE_POLICY_VERSION_MINIMUM=3.5 >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||||
$SUDO install -m 0755 acarsdec /usr/local/bin/acarsdec
|
$SUDO install -m 0755 acarsdec /usr/local/bin/acarsdec
|
||||||
ok "acarsdec installed successfully."
|
ok "acarsdec installed successfully."
|
||||||
else
|
else
|
||||||
@@ -1004,6 +1140,52 @@ install_acarsdec_from_source_debian() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install_dumpvdl2_from_source_debian() {
|
||||||
|
info "Building dumpvdl2 from source (with libacars dependency)..."
|
||||||
|
|
||||||
|
apt_install build-essential git cmake \
|
||||||
|
librtlsdr-dev libusb-1.0-0-dev libglib2.0-dev libxml2-dev
|
||||||
|
|
||||||
|
(
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
||||||
|
# Build libacars first
|
||||||
|
info "Cloning libacars..."
|
||||||
|
git clone --depth 1 https://github.com/szpajder/libacars.git "$tmp_dir/libacars" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone libacars"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/libacars"
|
||||||
|
mkdir -p build && cd build
|
||||||
|
|
||||||
|
info "Compiling libacars..."
|
||||||
|
if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||||
|
$SUDO make install >/dev/null 2>&1
|
||||||
|
$SUDO ldconfig
|
||||||
|
ok "libacars installed"
|
||||||
|
else
|
||||||
|
warn "Failed to build libacars."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build dumpvdl2
|
||||||
|
info "Cloning dumpvdl2..."
|
||||||
|
git clone --depth 1 https://github.com/szpajder/dumpvdl2.git "$tmp_dir/dumpvdl2" >/dev/null 2>&1 \
|
||||||
|
|| { warn "Failed to clone dumpvdl2"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/dumpvdl2"
|
||||||
|
mkdir -p build && cd build
|
||||||
|
|
||||||
|
info "Compiling dumpvdl2..."
|
||||||
|
if cmake .. >/dev/null 2>&1 && make >/dev/null 2>&1; then
|
||||||
|
$SUDO install -m 0755 src/dumpvdl2 /usr/local/bin/dumpvdl2
|
||||||
|
ok "dumpvdl2 installed successfully."
|
||||||
|
else
|
||||||
|
warn "Failed to build dumpvdl2 from source. VDL2 decoding will not be available."
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
install_aiscatcher_from_source_debian() {
|
install_aiscatcher_from_source_debian() {
|
||||||
info "AIS-catcher not available via APT. Building from source..."
|
info "AIS-catcher not available via APT. Building from source..."
|
||||||
|
|
||||||
@@ -1167,7 +1349,7 @@ install_debian_packages() {
|
|||||||
export NEEDRESTART_MODE=a
|
export NEEDRESTART_MODE=a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TOTAL_STEPS=26
|
TOTAL_STEPS=28
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Updating APT package lists"
|
progress "Updating APT package lists"
|
||||||
@@ -1309,7 +1491,7 @@ install_debian_packages() {
|
|||||||
fi
|
fi
|
||||||
if ! cmd_exists dump1090; then
|
if ! cmd_exists dump1090; then
|
||||||
if cmd_exists dump1090-mutability; then
|
if cmd_exists dump1090-mutability; then
|
||||||
$SUDO ln -s $(which dump1090-mutability) /usr/local/sbin/dump1090
|
$SUDO ln -s "$(which dump1090-mutability)" /usr/local/sbin/dump1090
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
cmd_exists dump1090 || install_dump1090_from_source_debian
|
cmd_exists dump1090 || install_dump1090_from_source_debian
|
||||||
@@ -1320,6 +1502,13 @@ install_debian_packages() {
|
|||||||
fi
|
fi
|
||||||
cmd_exists acarsdec || install_acarsdec_from_source_debian
|
cmd_exists acarsdec || install_acarsdec_from_source_debian
|
||||||
|
|
||||||
|
progress "Installing dumpvdl2"
|
||||||
|
if ! cmd_exists dumpvdl2; then
|
||||||
|
install_dumpvdl2_from_source_debian || warn "dumpvdl2 not available. VDL2 decoding will not be available."
|
||||||
|
else
|
||||||
|
ok "dumpvdl2 already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
progress "Installing AIS-catcher"
|
progress "Installing AIS-catcher"
|
||||||
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
|
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
|
||||||
install_aiscatcher_from_source_debian
|
install_aiscatcher_from_source_debian
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
--font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
--bg-dark: #0b1118;
|
--bg-dark: #0b1118;
|
||||||
--bg-panel: #101823;
|
--bg-panel: #101823;
|
||||||
--bg-card: #151f2b;
|
--bg-card: #151f2b;
|
||||||
@@ -31,8 +31,11 @@ body {
|
|||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
height: 100dvh;
|
||||||
overflow-x: hidden;
|
height: 100vh; /* Fallback */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animated radar sweep background */
|
/* Animated radar sweep background */
|
||||||
@@ -227,16 +230,14 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Main dashboard grid - Mobile first */
|
/* Main dashboard grid - Mobile first */
|
||||||
/* Header ~52px + Nav 44px + Stats strip ~55px = ~151px, using 160px for safety */
|
|
||||||
.dashboard {
|
.dashboard {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
height: calc(100dvh - 160px);
|
flex: 1;
|
||||||
height: calc(100vh - 160px); /* Fallback */
|
min-height: 0;
|
||||||
min-height: 400px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tablet: Two-column layout */
|
/* Tablet: Two-column layout */
|
||||||
@@ -249,13 +250,29 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop: Full layout with ACARS */
|
/* Desktop: Full layout with ACARS/VDL2 + map + sidebar */
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.dashboard {
|
.dashboard {
|
||||||
grid-template-columns: auto 1fr 300px;
|
grid-template-columns: auto 1fr 300px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Left sidebars wrapper (ACARS + VDL2) */
|
||||||
|
.left-sidebars {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.left-sidebars {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ACARS sidebar (left of map) - Collapsible */
|
/* ACARS sidebar (left of map) - Collapsible */
|
||||||
.acars-sidebar {
|
.acars-sidebar {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -267,12 +284,10 @@ body {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Show ACARS sidebar on desktop */
|
/* Show ACARS sidebar inside wrapper */
|
||||||
@media (min-width: 1024px) {
|
.left-sidebars .acars-sidebar {
|
||||||
.acars-sidebar {
|
display: flex;
|
||||||
display: flex;
|
height: 100%;
|
||||||
max-height: calc(100dvh - 160px);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.acars-collapse-btn {
|
.acars-collapse-btn {
|
||||||
@@ -419,6 +434,335 @@ body {
|
|||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* VDL2 sidebar (left of map, after ACARS) - Collapsible */
|
||||||
|
.vdl2-sidebar {
|
||||||
|
display: none;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
flex-direction: row;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show VDL2 sidebar inside wrapper */
|
||||||
|
.left-sidebars .vdl2-sidebar {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-collapse-btn {
|
||||||
|
width: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: none;
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 0;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-collapse-btn:hover {
|
||||||
|
background: rgba(74, 158, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-collapse-label {
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
text-orientation: mixed;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar.collapsed .vdl2-collapse-label {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar:not(.collapsed) .vdl2-collapse-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#vdl2CollapseIcon {
|
||||||
|
font-size: 10px;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar.collapsed #vdl2CollapseIcon {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar-content {
|
||||||
|
width: 300px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: width 0.3s ease, opacity 0.2s ease;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar.collapsed .vdl2-sidebar-content {
|
||||||
|
width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar .panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar .panel::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar .panel-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar #vdl2PanelContent {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar .vdl2-info,
|
||||||
|
.vdl2-sidebar .vdl2-controls {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar .vdl2-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar .vdl2-btn {
|
||||||
|
background: var(--accent-green);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar .vdl2-btn:hover {
|
||||||
|
background: #1db954;
|
||||||
|
box-shadow: 0 0 10px rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar .vdl2-btn.active {
|
||||||
|
background: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-sidebar .vdl2-btn.active:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-message-item {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 10px;
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-message-item:hover {
|
||||||
|
background: rgba(74, 158, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* VDL2 Message Modal */
|
||||||
|
.vdl2-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: vdl2ModalFadeIn 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vdl2ModalFadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal {
|
||||||
|
background: var(--bg-panel, #1a1a2e);
|
||||||
|
border: 1px solid var(--accent-cyan, #4a9eff);
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 520px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 1px var(--accent-cyan, #4a9eff);
|
||||||
|
animation: vdl2ModalSlideIn 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vdl2ModalSlideIn {
|
||||||
|
from { opacity: 0; transform: scale(0.95) translateY(10px); }
|
||||||
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-close {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-muted);
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.15s;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-close:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border-color: var(--accent-red, #ef4444);
|
||||||
|
color: var(--accent-red, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-body {
|
||||||
|
padding: 16px 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-section {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-section-title {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 6px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-field-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-field-value {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-msg-body {
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-height: 250px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-raw-toggle {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-raw-toggle:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vdl2-modal-raw-json {
|
||||||
|
display: none;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
/* Panels */
|
/* Panels */
|
||||||
.panel {
|
.panel {
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
@@ -495,6 +839,8 @@ body {
|
|||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
@@ -526,42 +872,6 @@ body {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
#radarOverlayCanvas {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 500;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#radarOverlayCanvas.active {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
#radarScope {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: none;
|
|
||||||
background: var(--radar-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
#radarScope.active {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#radarCanvas {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Right sidebar - Mobile first */
|
/* Right sidebar - Mobile first */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -588,51 +898,21 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* View toggle */
|
|
||||||
.view-toggle {
|
|
||||||
display: flex;
|
|
||||||
padding: 10px;
|
|
||||||
gap: 8px;
|
|
||||||
background: var(--bg-panel);
|
|
||||||
border-bottom: 1px solid rgba(74, 158, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-btn {
|
|
||||||
flex: 1;
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-family: 'Orbitron', monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-btn:hover {
|
|
||||||
border-color: var(--accent-cyan);
|
|
||||||
color: var(--accent-cyan);
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-btn.active {
|
|
||||||
background: var(--accent-cyan);
|
|
||||||
border-color: var(--accent-cyan);
|
|
||||||
color: var(--bg-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Selected aircraft panel */
|
/* Selected aircraft panel */
|
||||||
.selected-aircraft {
|
.selected-aircraft {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
max-height: 480px;
|
max-height: 280px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-height: 900px) {
|
||||||
|
.selected-aircraft {
|
||||||
|
max-height: 340px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.selected-info {
|
.selected-info {
|
||||||
padding: 12px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#aircraftPhotoContainer {
|
#aircraftPhotoContainer {
|
||||||
@@ -640,7 +920,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#aircraftPhotoContainer img {
|
#aircraftPhotoContainer img {
|
||||||
max-height: 140px;
|
max-height: 100px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -649,24 +929,24 @@ body {
|
|||||||
|
|
||||||
.selected-callsign {
|
.selected-callsign {
|
||||||
font-family: 'Orbitron', monospace;
|
font-family: 'Orbitron', monospace;
|
||||||
font-size: 20px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
text-shadow: 0 0 15px var(--accent-cyan);
|
text-shadow: 0 0 15px var(--accent-cyan);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.telemetry-grid {
|
.telemetry-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.telemetry-item {
|
.telemetry-item {
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 8px;
|
padding: 5px 8px;
|
||||||
border-left: 2px solid var(--accent-cyan);
|
border-left: 2px solid var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -776,9 +1056,10 @@ body {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 15px;
|
padding: 8px 15px;
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
border-top: 1px solid rgba(74, 158, 255, 0.3);
|
border-top: none;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
overflow: hidden;
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-bar > .control-group {
|
.controls-bar > .control-group {
|
||||||
@@ -907,6 +1188,15 @@ body {
|
|||||||
.control-group.airband-group {
|
.control-group.airband-group {
|
||||||
background: rgba(245, 158, 11, 0.05);
|
background: rgba(245, 158, 11, 0.05);
|
||||||
border-color: rgba(245, 158, 11, 0.2);
|
border-color: rgba(245, 158, 11, 0.2);
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group.airband-group > .control-group-items {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-group.airband-group .control-group-label {
|
.control-group.airband-group .control-group-label {
|
||||||
@@ -1010,6 +1300,7 @@ body {
|
|||||||
/* Custom scrollbar */
|
/* Custom scrollbar */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
@@ -1021,6 +1312,15 @@ body {
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar on controls bar */
|
||||||
|
.controls-bar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-bar {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* No aircraft message */
|
/* No aircraft message */
|
||||||
.no-aircraft {
|
.no-aircraft {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -1289,7 +1589,7 @@ body {
|
|||||||
display: flex !important;
|
display: flex !important;
|
||||||
flex-direction: column !important;
|
flex-direction: column !important;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
min-height: calc(100dvh - 160px);
|
min-height: 400px;
|
||||||
overflow-y: auto !important;
|
overflow-y: auto !important;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
@@ -1489,6 +1789,10 @@ body {
|
|||||||
margin-top: 1px;
|
margin-top: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.strip-stat.source-stat .strip-value {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.strip-stat.session-stat {
|
.strip-stat.session-stat {
|
||||||
background: rgba(34, 197, 94, 0.05);
|
background: rgba(34, 197, 94, 0.05);
|
||||||
border-color: rgba(34, 197, 94, 0.2);
|
border-color: rgba(34, 197, 94, 0.2);
|
||||||
@@ -1779,6 +2083,9 @@ body {
|
|||||||
.strip-btn {
|
.strip-btn {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
background: rgba(74, 158, 255, 0.1);
|
background: rgba(74, 158, 255, 0.1);
|
||||||
border: 1px solid rgba(74, 158, 255, 0.2);
|
border: 1px solid rgba(74, 158, 255, 0.2);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -1789,6 +2096,12 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-btn svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.strip-btn:hover:not(:disabled) {
|
.strip-btn:hover:not(:disabled) {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
--font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
--bg-dark: #0b1118;
|
--bg-dark: #0b1118;
|
||||||
--bg-panel: #101823;
|
--bg-panel: #101823;
|
||||||
--bg-card: #151f2b;
|
--bg-card: #151f2b;
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
--font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
--bg-dark: #0b1118;
|
--bg-dark: #0b1118;
|
||||||
--bg-panel: #101823;
|
--bg-panel: #101823;
|
||||||
--bg-card: #151f2b;
|
--bg-card: #151f2b;
|
||||||
@@ -496,7 +496,7 @@ body {
|
|||||||
padding: 10px 15px;
|
padding: 10px 15px;
|
||||||
background: rgba(74, 158, 255, 0.05);
|
background: rgba(74, 158, 255, 0.05);
|
||||||
border-bottom: 1px solid rgba(74, 158, 255, 0.1);
|
border-bottom: 1px solid rgba(74, 158, 255, 0.1);
|
||||||
font-family: 'Orbitron', 'Space Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
@@ -568,7 +568,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vessel-name {
|
.vessel-name {
|
||||||
font-family: 'Orbitron', 'Space Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -662,7 +662,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vessel-item-name {
|
.vessel-item-name {
|
||||||
font-family: 'Orbitron', 'Space Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -1223,7 +1223,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dsc-distress-alert .dsc-alert-header {
|
.dsc-distress-alert .dsc-alert-header {
|
||||||
font-family: 'Orbitron', 'Space Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-red);
|
color: var(--accent-red);
|
||||||
|
|||||||
@@ -78,8 +78,8 @@
|
|||||||
/* ============================================
|
/* ============================================
|
||||||
TYPOGRAPHY
|
TYPOGRAPHY
|
||||||
============================================ */
|
============================================ */
|
||||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
--font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
|
||||||
/* Font sizes */
|
/* Font sizes */
|
||||||
--text-xs: 10px;
|
--text-xs: 10px;
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
/* Local font declarations for offline mode */
|
/* Local font declarations for offline mode */
|
||||||
|
/* Roboto Condensed - variable font, one file covers all weights */
|
||||||
|
|
||||||
/* Space Mono - Console font */
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Space Mono';
|
font-family: 'Roboto Condensed';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 300 700;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('/static/vendor/fonts/SpaceMono-Regular.woff2') format('woff2');
|
src: url('/static/vendor/fonts/RobotoCondensed-Latin.woff2') format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Space Mono';
|
font-family: 'Roboto Condensed';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 700;
|
font-weight: 300 700;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url('/static/vendor/fonts/SpaceMono-Bold.woff2') format('woff2');
|
src: url('/static/vendor/fonts/RobotoCondensed-LatinExt.woff2') format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
border-bottom: 1px solid var(--border-color, #202833);
|
border-bottom: 1px solid var(--border-color, #202833);
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 100;
|
z-index: 1100;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,6 +434,6 @@ a.nav-dashboard-btn:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-dashboard-btn .nav-label {
|
.nav-dashboard-btn .nav-label {
|
||||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif);
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 40px 20px;
|
padding: 40px 20px;
|
||||||
|
font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif);
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-modal.active {
|
.help-modal.active {
|
||||||
@@ -26,37 +27,41 @@
|
|||||||
background: var(--bg-card, var(--bg-secondary, #0f1218));
|
background: var(--bg-card, var(--bg-secondary, #0f1218));
|
||||||
border: 1px solid var(--border-color, #1f2937);
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 30px;
|
padding: 24px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-content h2 {
|
.help-content h2 {
|
||||||
color: var(--accent-cyan, #4a9eff);
|
color: var(--accent-cyan, #4a9eff);
|
||||||
margin-bottom: 20px;
|
margin-bottom: 16px;
|
||||||
font-size: 24px;
|
font-size: 15px;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-content h3 {
|
.help-content h3 {
|
||||||
color: var(--text-primary, #e8eaed);
|
color: var(--text-primary, #e8eaed);
|
||||||
margin: 25px 0 15px 0;
|
margin: 20px 0 10px 0;
|
||||||
font-size: 14px;
|
font-size: 11px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
border-bottom: 1px solid var(--border-color, #1f2937);
|
border-bottom: 1px solid var(--border-color, #1f2937);
|
||||||
padding-bottom: 8px;
|
padding-bottom: 6px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-close {
|
.help-close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 15px;
|
top: 12px;
|
||||||
right: 15px;
|
right: 12px;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-dim, #4b5563);
|
color: var(--text-dim, #4b5563);
|
||||||
font-size: 24px;
|
font-size: 20px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-close:hover {
|
.help-close:hover {
|
||||||
@@ -66,43 +71,54 @@
|
|||||||
.help-modal .icon-grid {
|
.help-modal .icon-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
margin: 15px 0;
|
margin: 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-modal .icon-item {
|
.help-modal .icon-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
padding: 10px;
|
padding: 6px 8px;
|
||||||
background: var(--bg-primary, #0a0c10);
|
background: var(--bg-primary, #0a0c10);
|
||||||
border: 1px solid var(--border-color, #1f2937);
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-modal .icon-item .icon {
|
.help-modal .icon-item .icon {
|
||||||
font-size: 18px;
|
width: 20px;
|
||||||
width: 30px;
|
height: 20px;
|
||||||
text-align: center;
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-modal .icon-item .icon svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-modal .icon-item .desc {
|
.help-modal .icon-item .desc {
|
||||||
color: var(--text-secondary, #9ca3af);
|
color: var(--text-secondary, #9ca3af);
|
||||||
|
font-size: 10.5px;
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-modal .tip-list {
|
.help-modal .tip-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 15px 0;
|
margin: 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-modal .tip-list li {
|
.help-modal .tip-list li {
|
||||||
padding: 8px 0;
|
padding: 5px 0;
|
||||||
padding-left: 20px;
|
padding-left: 16px;
|
||||||
position: relative;
|
position: relative;
|
||||||
color: var(--text-secondary, #9ca3af);
|
color: var(--text-secondary, #9ca3af);
|
||||||
font-size: 13px;
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
border-bottom: 1px solid var(--border-color, #1f2937);
|
border-bottom: 1px solid var(--border-color, #1f2937);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,10 +134,15 @@
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.help-modal .tip-list li strong {
|
||||||
|
color: var(--text-primary, #e8eaed);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.help-tabs {
|
.help-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 16px;
|
||||||
border: 1px solid var(--border-color, #1f2937);
|
border: 1px solid var(--border-color, #1f2937);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -129,12 +150,13 @@
|
|||||||
|
|
||||||
.help-tab {
|
.help-tab {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 10px;
|
padding: 8px;
|
||||||
background: var(--bg-primary, #0a0c10);
|
background: var(--bg-primary, #0a0c10);
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-secondary, #9ca3af);
|
color: var(--text-secondary, #9ca3af);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 11px;
|
font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif);
|
||||||
|
font-size: 10px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
@@ -176,9 +198,9 @@
|
|||||||
/* Ensure code tags are styled */
|
/* Ensure code tags are styled */
|
||||||
.help-modal code {
|
.help-modal code {
|
||||||
background: var(--bg-tertiary, #151a23);
|
background: var(--bg-tertiary, #151a23);
|
||||||
padding: 2px 6px;
|
padding: 1px 5px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif);
|
||||||
font-size: 11px;
|
font-size: 10.5px;
|
||||||
color: var(--accent-cyan, #4a9eff);
|
color: var(--accent-cyan, #4a9eff);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
--font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
/* Tactical dark palette */
|
/* Tactical dark palette */
|
||||||
--bg-primary: #0b1118;
|
--bg-primary: #0b1118;
|
||||||
--bg-secondary: #101823;
|
--bg-secondary: #101823;
|
||||||
@@ -706,6 +706,8 @@ header h1 {
|
|||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1100;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
@@ -4119,7 +4121,7 @@ header h1 .tagline {
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
height: 140px;
|
max-height: 340px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4140,7 +4142,9 @@ header h1 .tagline {
|
|||||||
|
|
||||||
.bt-detail-body {
|
.bt-detail-body {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
height: calc(100% - 30px);
|
height: auto;
|
||||||
|
max-height: calc(100% - 30px);
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-detail-placeholder {
|
.bt-detail-placeholder {
|
||||||
@@ -4319,6 +4323,110 @@ header h1 .tagline {
|
|||||||
color: #9fffd1;
|
color: #9fffd1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Service Data Inspector */
|
||||||
|
.bt-detail-service-inspector {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-inspector-toggle {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 3px 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-inspector-toggle:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-inspector-arrow {
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-inspector-arrow.open {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-inspector-content {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
max-height: 100px;
|
||||||
|
overflow-y: auto;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-inspector-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 2px 0;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-inspector-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-inspector-label {
|
||||||
|
color: var(--text-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-inspector-value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: right;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MAC Cluster Badge */
|
||||||
|
.bt-mac-cluster-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba(245, 158, 11, 0.2);
|
||||||
|
color: #f59e0b;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-left: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Behavioral Flag Badges */
|
||||||
|
.bt-flag-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-left: 3px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-flag-badge.persistent {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-flag-badge.beacon-like {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bt-flag-badge.strong-stable {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
/* Selected device highlight */
|
/* Selected device highlight */
|
||||||
.bt-device-row.selected {
|
.bt-device-row.selected {
|
||||||
background: rgba(0, 212, 255, 0.1);
|
background: rgba(0, 212, 255, 0.1);
|
||||||
@@ -4469,14 +4577,15 @@ header h1 .tagline {
|
|||||||
.bt-row-main {
|
.bt-row-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-row-left {
|
.bt-row-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
gap: 8px;
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
@@ -4521,13 +4630,25 @@ header h1 .tagline {
|
|||||||
color: #22c55e;
|
color: #22c55e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bt-irk-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
background: rgba(168, 85, 247, 0.15);
|
||||||
|
color: #a855f7;
|
||||||
|
border: 1px solid rgba(168, 85, 247, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
.bt-device-name {
|
.bt-device-name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
white-space: nowrap;
|
overflow-wrap: break-word;
|
||||||
overflow: hidden;
|
word-break: break-word;
|
||||||
text-overflow: ellipsis;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-rssi-container {
|
.bt-rssi-container {
|
||||||
@@ -6251,7 +6372,7 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.module-header {
|
.module-header {
|
||||||
font-family: 'Orbitron', 'Space Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
@@ -6429,7 +6550,7 @@ body::before {
|
|||||||
/* Listening Mode Selector Buttons */
|
/* Listening Mode Selector Buttons */
|
||||||
.radio-mode-btn {
|
.radio-mode-btn {
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
font-family: 'Orbitron', 'Space Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -59,6 +59,11 @@
|
|||||||
box-shadow: 0 0 6px rgba(255, 170, 0, 0.4);
|
box-shadow: 0 0 6px rgba(255, 170, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gps-status-dot.error {
|
||||||
|
background: #ff4444;
|
||||||
|
box-shadow: 0 0 6px rgba(255, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
.gps-status-text {
|
.gps-status-text {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
background: var(--bg-tertiary, #1a1f2e);
|
background: var(--bg-tertiary, #1a1f2e);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--bg-tertiary, #1a1f2e);
|
background: var(--bg-tertiary, #1a1f2e);
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, border-color 0.15s;
|
transition: background 0.15s, border-color 0.15s;
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -153,7 +153,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary, #999);
|
color: var(--text-secondary, #999);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-trigger-grid label {
|
.subghz-trigger-grid label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -182,7 +182,7 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--bg-primary, #0d1117);
|
background: var(--bg-primary, #0d1117);
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
|
|
||||||
.subghz-trigger-help {
|
.subghz-trigger-help {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
@@ -207,7 +207,7 @@
|
|||||||
background: var(--bg-tertiary, #1a1f2e);
|
background: var(--bg-tertiary, #1a1f2e);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +264,7 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, border-color 0.15s;
|
transition: background 0.15s, border-color 0.15s;
|
||||||
@@ -369,7 +369,7 @@
|
|||||||
background: var(--bg-tertiary, #1a1f2e);
|
background: var(--bg-tertiary, #1a1f2e);
|
||||||
border: 1px solid var(--border-color, #2a3040);
|
border: 1px solid var(--border-color, #2a3040);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
|
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
|
||||||
@@ -416,7 +416,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
@@ -446,7 +446,7 @@
|
|||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid var(--border-color, #2a3040);
|
border: 1px solid var(--border-color, #2a3040);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
letter-spacing: 0.35px;
|
letter-spacing: 0.35px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
@@ -512,7 +512,7 @@
|
|||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-capture-actions button:hover {
|
.subghz-capture-actions button:hover {
|
||||||
@@ -554,7 +554,7 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-red, #ff4444);
|
color: var(--accent-red, #ff4444);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
@@ -591,7 +591,7 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
@@ -695,12 +695,12 @@
|
|||||||
.subghz-tx-modal .tx-freq {
|
.subghz-tx-modal .tx-freq {
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-tx-modal .tx-duration {
|
.subghz-tx-modal .tx-duration {
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-tx-segment-box {
|
.subghz-tx-segment-box {
|
||||||
@@ -742,7 +742,7 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--bg-primary, #0d1117);
|
background: var(--bg-primary, #0d1117);
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -755,7 +755,7 @@
|
|||||||
margin-bottom: 0 !important;
|
margin-bottom: 0 !important;
|
||||||
font-size: 11px !important;
|
font-size: 11px !important;
|
||||||
color: var(--accent-cyan, #00d4ff) !important;
|
color: var(--accent-cyan, #00d4ff) !important;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-tx-burst-assist {
|
.subghz-tx-burst-assist {
|
||||||
@@ -768,7 +768,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-tx-burst-title {
|
.subghz-tx-burst-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -805,7 +805,7 @@
|
|||||||
|
|
||||||
.subghz-tx-burst-range {
|
.subghz-tx-burst-range {
|
||||||
margin: 0 0 8px 0;
|
margin: 0 0 8px 0;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
}
|
}
|
||||||
@@ -839,7 +839,7 @@
|
|||||||
padding: 6px;
|
padding: 6px;
|
||||||
border: 1px dashed var(--border-color, #2a3040);
|
border: 1px dashed var(--border-color, #2a3040);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
@@ -854,7 +854,7 @@
|
|||||||
border: 1px solid var(--border-color, #2a3040);
|
border: 1px solid var(--border-color, #2a3040);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: rgba(0, 0, 0, 0.15);
|
background: rgba(0, 0, 0, 0.15);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary, #999);
|
color: var(--text-secondary, #999);
|
||||||
}
|
}
|
||||||
@@ -865,7 +865,7 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #00d4ff;
|
color: #00d4ff;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -884,7 +884,7 @@
|
|||||||
.subghz-tx-modal-actions button {
|
.subghz-tx-modal-actions button {
|
||||||
padding: 8px 20px;
|
padding: 8px 20px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 1px solid;
|
border: 1px solid;
|
||||||
@@ -926,7 +926,7 @@
|
|||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 24px 12px;
|
padding: 24px 12px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-captures-list-main .subghz-empty {
|
.subghz-captures-list-main .subghz-empty {
|
||||||
@@ -943,7 +943,7 @@
|
|||||||
border: 1px solid #2a3040;
|
border: 1px solid #2a3040;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 5px 9px;
|
padding: 5px 9px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
display: none;
|
display: none;
|
||||||
@@ -970,7 +970,7 @@
|
|||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.6);
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.6);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1029,7 +1029,7 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||||
@@ -1068,7 +1068,7 @@
|
|||||||
content: 'No peaks detected';
|
content: 'No peaks detected';
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -1082,7 +1082,7 @@
|
|||||||
border: 1px solid var(--border-color, #2a3040);
|
border: 1px solid var(--border-color, #2a3040);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
transition: border-color 0.12s;
|
transition: border-color 0.12s;
|
||||||
}
|
}
|
||||||
@@ -1108,7 +1108,7 @@
|
|||||||
border: 1px solid var(--border-color, #2a3040);
|
border: 1px solid var(--border-color, #2a3040);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1192,7 +1192,7 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #22c55e;
|
color: #22c55e;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1211,7 +1211,7 @@
|
|||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border: 1px solid var(--border-color, #2a3040);
|
border: 1px solid var(--border-color, #2a3040);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-secondary, #999);
|
color: var(--text-secondary, #999);
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
@@ -1263,7 +1263,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1300,7 +1300,7 @@
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
background: rgba(0, 0, 0, 0.15);
|
background: rgba(0, 0, 0, 0.15);
|
||||||
@@ -1365,7 +1365,7 @@
|
|||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
max-height: 114px;
|
max-height: 114px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
@@ -1402,7 +1402,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-hub-header-title {
|
.subghz-hub-header-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
@@ -1410,7 +1410,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-hub-header-sub {
|
.subghz-hub-header-sub {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
@@ -1472,14 +1472,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-hub-title {
|
.subghz-hub-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-hub-desc {
|
.subghz-hub-desc {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
}
|
}
|
||||||
@@ -1526,7 +1526,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-saved-selection-count {
|
.subghz-saved-selection-count {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
@@ -1538,7 +1538,7 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-secondary, #999);
|
color: var(--text-secondary, #999);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s, color 0.15s;
|
transition: border-color 0.15s, color 0.15s;
|
||||||
@@ -1550,7 +1550,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-op-panel-title {
|
.subghz-op-panel-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1620,7 +1620,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-red, #ff4444);
|
color: var(--accent-red, #ff4444);
|
||||||
@@ -1654,14 +1654,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-rx-info-label {
|
.subghz-rx-info-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-rx-info-value {
|
.subghz-rx-info-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
@@ -1688,7 +1688,7 @@
|
|||||||
border: 1px solid var(--border-color, #2a3040);
|
border: 1px solid var(--border-color, #2a3040);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: rgba(0, 0, 0, 0.22);
|
background: rgba(0, 0, 0, 0.22);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-rx-hint-label {
|
.subghz-rx-hint-label {
|
||||||
@@ -1722,7 +1722,7 @@
|
|||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border: 1px solid var(--border-color, #2a3040);
|
border: 1px solid var(--border-color, #2a3040);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
background: rgba(0, 0, 0, 0.2);
|
background: rgba(0, 0, 0, 0.2);
|
||||||
@@ -1741,7 +1741,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-rx-level-label {
|
.subghz-rx-level-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
@@ -1772,7 +1772,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-rx-scope-label {
|
.subghz-rx-scope-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
@@ -1832,7 +1832,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
letter-spacing: 0.4px;
|
letter-spacing: 0.4px;
|
||||||
@@ -1854,7 +1854,7 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-secondary, #999);
|
color: var(--text-secondary, #999);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||||
@@ -1938,7 +1938,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-tx-label {
|
.subghz-tx-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-red, #ff4444);
|
color: var(--accent-red, #ff4444);
|
||||||
@@ -1958,14 +1958,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-tx-info-label {
|
.subghz-tx-info-label {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subghz-tx-info-value {
|
.subghz-tx-info-value {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
@@ -1998,7 +1998,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.subghz-sweep-peaks-title {
|
.subghz-sweep-peaks-title {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/* VDL2 Mode Styles */
|
||||||
|
|
||||||
|
/* VDL2 Status Indicator */
|
||||||
|
.vdl2-status-dot.listening {
|
||||||
|
background: var(--accent-cyan) !important;
|
||||||
|
animation: vdl2-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.vdl2-status-dot.receiving {
|
||||||
|
background: var(--accent-green) !important;
|
||||||
|
}
|
||||||
|
.vdl2-status-dot.error {
|
||||||
|
background: var(--accent-red) !important;
|
||||||
|
}
|
||||||
|
@keyframes vdl2-pulse {
|
||||||
|
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(74, 158, 255, 0.7); }
|
||||||
|
50% { opacity: 0.6; box-shadow: 0 0 6px 3px rgba(74, 158, 255, 0.3); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* VDL2 message animation */
|
||||||
|
.vdl2-msg {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
animation: vdl2FadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
.vdl2-msg:hover {
|
||||||
|
background: rgba(74, 158, 255, 0.05);
|
||||||
|
}
|
||||||
|
@keyframes vdl2FadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(-3px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
.wxsat-strip-status-text {
|
.wxsat-strip-status-text {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary, #999);
|
color: var(--text-secondary, #999);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-strip-btn {
|
.wxsat-strip-btn {
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
|
|
||||||
.wxsat-strip-value {
|
.wxsat-strip-value {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-loc-input:focus {
|
.wxsat-loc-input:focus {
|
||||||
@@ -225,7 +225,7 @@
|
|||||||
.wxsat-cd-value {
|
.wxsat-cd-value {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
@@ -248,13 +248,13 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-countdown-detail {
|
.wxsat-countdown-detail {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Timeline ===== */
|
/* ===== Timeline ===== */
|
||||||
@@ -314,7 +314,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Pass Predictions Panel ===== */
|
/* ===== Pass Predictions Panel ===== */
|
||||||
@@ -349,7 +349,7 @@
|
|||||||
.wxsat-passes-count {
|
.wxsat-passes-count {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-passes-list {
|
.wxsat-passes-list {
|
||||||
@@ -387,7 +387,7 @@
|
|||||||
background: rgba(255, 187, 0, 0.15);
|
background: rgba(255, 187, 0, 0.15);
|
||||||
color: #ffbb00;
|
color: #ffbb00;
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
@@ -409,7 +409,7 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-pass-mode.apt {
|
.wxsat-pass-mode.apt {
|
||||||
@@ -428,7 +428,7 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-pass-detail-label {
|
.wxsat-pass-detail-label {
|
||||||
@@ -499,7 +499,7 @@
|
|||||||
.wxsat-panel-subtitle {
|
.wxsat-panel-subtitle {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
#wxsatPolarCanvas {
|
#wxsatPolarCanvas {
|
||||||
@@ -547,7 +547,7 @@
|
|||||||
.wxsat-gallery-count {
|
.wxsat-gallery-count {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-gallery-grid {
|
.wxsat-gallery-grid {
|
||||||
@@ -636,7 +636,7 @@
|
|||||||
.wxsat-image-product {
|
.wxsat-image-product {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent-cyan, #00d4ff);
|
color: var(--accent-cyan, #00d4ff);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-image-timestamp {
|
.wxsat-image-timestamp {
|
||||||
@@ -649,7 +649,7 @@
|
|||||||
.wxsat-date-header {
|
.wxsat-date-header {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
@@ -708,7 +708,7 @@
|
|||||||
.wxsat-capture-message {
|
.wxsat-capture-message {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-secondary, #999);
|
color: var(--text-secondary, #999);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -719,7 +719,7 @@
|
|||||||
.wxsat-capture-elapsed {
|
.wxsat-capture-elapsed {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -785,7 +785,7 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--text-secondary, #999);
|
color: var(--text-secondary, #999);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -941,7 +941,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-phase-step {
|
.wxsat-phase-step {
|
||||||
@@ -1012,7 +1012,7 @@
|
|||||||
max-height: 160px;
|
max-height: 160px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
background: var(--bg-primary, #0d1117);
|
background: var(--bg-primary, #0d1117);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
--font-mono: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
|
--font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
--bg-dark: #0b1118;
|
--bg-dark: #0b1118;
|
||||||
--bg-panel: #101823;
|
--bg-panel: #101823;
|
||||||
--bg-card: #151f2b;
|
--bg-card: #151f2b;
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const ProximityRadar = (function() {
|
|||||||
let isHovered = false;
|
let isHovered = false;
|
||||||
let renderPending = false;
|
let renderPending = false;
|
||||||
let renderTimer = null;
|
let renderTimer = null;
|
||||||
|
let interactionLockUntil = 0; // timestamp: suppress renders briefly after click
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the radar component
|
* Initialize the radar component
|
||||||
@@ -119,6 +120,36 @@ const ProximityRadar = (function() {
|
|||||||
|
|
||||||
svg = container.querySelector('svg');
|
svg = container.querySelector('svg');
|
||||||
|
|
||||||
|
// Event delegation on the devices group (survives innerHTML rebuilds)
|
||||||
|
const devicesGroup = svg.querySelector('.radar-devices');
|
||||||
|
|
||||||
|
devicesGroup.addEventListener('click', (e) => {
|
||||||
|
const deviceEl = e.target.closest('.radar-device');
|
||||||
|
if (!deviceEl) return;
|
||||||
|
const deviceKey = deviceEl.getAttribute('data-device-key');
|
||||||
|
if (onDeviceClick && deviceKey) {
|
||||||
|
// Lock out re-renders briefly so the DOM stays stable after click
|
||||||
|
interactionLockUntil = Date.now() + 500;
|
||||||
|
onDeviceClick(deviceKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
devicesGroup.addEventListener('mouseenter', (e) => {
|
||||||
|
if (e.target.closest('.radar-device')) {
|
||||||
|
isHovered = true;
|
||||||
|
}
|
||||||
|
}, true); // capture phase so we catch enter on child elements
|
||||||
|
|
||||||
|
devicesGroup.addEventListener('mouseleave', (e) => {
|
||||||
|
if (e.target.closest('.radar-device')) {
|
||||||
|
isHovered = false;
|
||||||
|
if (renderPending) {
|
||||||
|
renderPending = false;
|
||||||
|
renderDevices();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
// Add sweep animation
|
// Add sweep animation
|
||||||
animateSweep();
|
animateSweep();
|
||||||
}
|
}
|
||||||
@@ -165,8 +196,8 @@ const ProximityRadar = (function() {
|
|||||||
devices.set(device.device_key, device);
|
devices.set(device.device_key, device);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Defer render while user is hovering to prevent DOM rebuild flicker
|
// Defer render while user is hovering or interacting to prevent DOM rebuild flicker
|
||||||
if (isHovered) {
|
if (isHovered || Date.now() < interactionLockUntil) {
|
||||||
renderPending = true;
|
renderPending = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -229,7 +260,7 @@ const ProximityRadar = (function() {
|
|||||||
style="cursor: pointer;">
|
style="cursor: pointer;">
|
||||||
<!-- Invisible hit area to prevent hover flicker -->
|
<!-- Invisible hit area to prevent hover flicker -->
|
||||||
<circle class="radar-device-hitarea" r="${hitAreaSize}" fill="transparent" />
|
<circle class="radar-device-hitarea" r="${hitAreaSize}" fill="transparent" />
|
||||||
${isSelected ? `<circle r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
|
${isSelected ? `<circle class="radar-select-ring" r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
|
||||||
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
|
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
|
||||||
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
|
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
|
||||||
</circle>` : ''}
|
</circle>` : ''}
|
||||||
@@ -244,24 +275,6 @@ const ProximityRadar = (function() {
|
|||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
devicesGroup.innerHTML = dots;
|
devicesGroup.innerHTML = dots;
|
||||||
|
|
||||||
// Attach event handlers
|
|
||||||
devicesGroup.querySelectorAll('.radar-device').forEach(el => {
|
|
||||||
el.addEventListener('click', (e) => {
|
|
||||||
const deviceKey = el.getAttribute('data-device-key');
|
|
||||||
if (onDeviceClick && deviceKey) {
|
|
||||||
onDeviceClick(deviceKey);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
el.addEventListener('mouseenter', () => { isHovered = true; });
|
|
||||||
el.addEventListener('mouseleave', () => {
|
|
||||||
isHovered = false;
|
|
||||||
if (renderPending) {
|
|
||||||
renderPending = false;
|
|
||||||
renderDevices();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -345,19 +358,125 @@ const ProximityRadar = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Highlight a specific device on the radar
|
* Highlight a specific device on the radar (in-place update, no full re-render)
|
||||||
*/
|
*/
|
||||||
function highlightDevice(deviceKey) {
|
function highlightDevice(deviceKey) {
|
||||||
|
const prev = selectedDeviceKey;
|
||||||
selectedDeviceKey = deviceKey;
|
selectedDeviceKey = deviceKey;
|
||||||
renderDevices();
|
|
||||||
|
if (!svg) { return; }
|
||||||
|
const devicesGroup = svg.querySelector('.radar-devices');
|
||||||
|
if (!devicesGroup) { return; }
|
||||||
|
|
||||||
|
// Remove highlight from previously selected node
|
||||||
|
if (prev && prev !== deviceKey) {
|
||||||
|
const oldEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(prev)}"]`);
|
||||||
|
if (oldEl) {
|
||||||
|
oldEl.classList.remove('selected');
|
||||||
|
// Remove animated selection ring
|
||||||
|
const ring = oldEl.querySelector('.radar-select-ring');
|
||||||
|
if (ring) ring.remove();
|
||||||
|
// Restore dot opacity
|
||||||
|
const dot = oldEl.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
|
||||||
|
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
|
||||||
|
const device = devices.get(prev);
|
||||||
|
const confidence = device ? (device.distance_confidence || 0.5) : 0.5;
|
||||||
|
dot.setAttribute('fill-opacity', 0.4 + confidence * 0.5);
|
||||||
|
dot.setAttribute('stroke', dot.getAttribute('fill'));
|
||||||
|
dot.setAttribute('stroke-width', '1');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add highlight to newly selected node
|
||||||
|
if (deviceKey) {
|
||||||
|
const newEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(deviceKey)}"]`);
|
||||||
|
if (newEl) {
|
||||||
|
applySelectionToElement(newEl, deviceKey);
|
||||||
|
} else {
|
||||||
|
// Node not in DOM yet; full render needed on next cycle
|
||||||
|
renderDevices();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear device highlighting
|
* Apply selection styling to a radar device element in-place
|
||||||
|
*/
|
||||||
|
function applySelectionToElement(el, deviceKey) {
|
||||||
|
el.classList.add('selected');
|
||||||
|
const device = devices.get(deviceKey);
|
||||||
|
const confidence = device ? (device.distance_confidence || 0.5) : 0.5;
|
||||||
|
const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence;
|
||||||
|
|
||||||
|
// Update dot styling
|
||||||
|
const dot = el.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
|
||||||
|
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
|
||||||
|
dot.setAttribute('fill-opacity', '1');
|
||||||
|
dot.setAttribute('stroke', '#00d4ff');
|
||||||
|
dot.setAttribute('stroke-width', '2');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add animated selection ring if not already present
|
||||||
|
if (!el.querySelector('.radar-select-ring')) {
|
||||||
|
const ns = 'http://www.w3.org/2000/svg';
|
||||||
|
const ring = document.createElementNS(ns, 'circle');
|
||||||
|
ring.classList.add('radar-select-ring');
|
||||||
|
ring.setAttribute('r', dotSize + 8);
|
||||||
|
ring.setAttribute('fill', 'none');
|
||||||
|
ring.setAttribute('stroke', '#00d4ff');
|
||||||
|
ring.setAttribute('stroke-width', '2');
|
||||||
|
ring.setAttribute('stroke-opacity', '0.8');
|
||||||
|
|
||||||
|
const animR = document.createElementNS(ns, 'animate');
|
||||||
|
animR.setAttribute('attributeName', 'r');
|
||||||
|
animR.setAttribute('values', `${dotSize + 6};${dotSize + 10};${dotSize + 6}`);
|
||||||
|
animR.setAttribute('dur', '1.5s');
|
||||||
|
animR.setAttribute('repeatCount', 'indefinite');
|
||||||
|
ring.appendChild(animR);
|
||||||
|
|
||||||
|
const animO = document.createElementNS(ns, 'animate');
|
||||||
|
animO.setAttribute('attributeName', 'stroke-opacity');
|
||||||
|
animO.setAttribute('values', '0.8;0.4;0.8');
|
||||||
|
animO.setAttribute('dur', '1.5s');
|
||||||
|
animO.setAttribute('repeatCount', 'indefinite');
|
||||||
|
ring.appendChild(animO);
|
||||||
|
|
||||||
|
// Insert after the hit area
|
||||||
|
const hitArea = el.querySelector('.radar-device-hitarea');
|
||||||
|
if (hitArea && hitArea.nextSibling) {
|
||||||
|
el.insertBefore(ring, hitArea.nextSibling);
|
||||||
|
} else {
|
||||||
|
el.insertBefore(ring, el.firstChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear device highlighting (in-place update, no full re-render)
|
||||||
*/
|
*/
|
||||||
function clearHighlight() {
|
function clearHighlight() {
|
||||||
|
const prev = selectedDeviceKey;
|
||||||
selectedDeviceKey = null;
|
selectedDeviceKey = null;
|
||||||
renderDevices();
|
|
||||||
|
if (!svg || !prev) { return; }
|
||||||
|
const devicesGroup = svg.querySelector('.radar-devices');
|
||||||
|
if (!devicesGroup) { return; }
|
||||||
|
|
||||||
|
const oldEl = devicesGroup.querySelector(`.radar-device[data-device-key="${CSS.escape(prev)}"]`);
|
||||||
|
if (oldEl) {
|
||||||
|
oldEl.classList.remove('selected');
|
||||||
|
const ring = oldEl.querySelector('.radar-select-ring');
|
||||||
|
if (ring) ring.remove();
|
||||||
|
const dot = oldEl.querySelector('circle:not(.radar-device-hitarea):not(.radar-select-ring)');
|
||||||
|
if (dot && dot.getAttribute('fill') !== 'none' && dot.getAttribute('fill') !== 'transparent') {
|
||||||
|
const device = devices.get(prev);
|
||||||
|
const confidence = device ? (device.distance_confidence || 0.5) : 0.5;
|
||||||
|
dot.setAttribute('fill-opacity', 0.4 + confidence * 0.5);
|
||||||
|
dot.setAttribute('stroke', dot.getAttribute('fill'));
|
||||||
|
dot.setAttribute('stroke-width', '1');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -302,7 +302,13 @@ const SignalCards = (function() {
|
|||||||
*/
|
*/
|
||||||
function formatRelativeTime(timestamp) {
|
function formatRelativeTime(timestamp) {
|
||||||
if (!timestamp) return '';
|
if (!timestamp) return '';
|
||||||
const date = new Date(timestamp);
|
let date = new Date(timestamp);
|
||||||
|
// Handle time-only strings like "HH:MM:SS" (from pager/sensor backends)
|
||||||
|
if (isNaN(date.getTime()) && /^\d{1,2}:\d{2}(:\d{2})?$/.test(timestamp)) {
|
||||||
|
const today = new Date();
|
||||||
|
date = new Date(today.toDateString() + ' ' + timestamp);
|
||||||
|
}
|
||||||
|
if (isNaN(date.getTime())) return timestamp;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = Math.floor((now - date) / 1000);
|
const diff = Math.floor((now - date) / 1000);
|
||||||
|
|
||||||
|
|||||||
@@ -423,7 +423,7 @@ async function syncAgentModeStates(agentId) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Also check modes that might need to be marked as stopped
|
// Also check modes that might need to be marked as stopped
|
||||||
const allModes = ['sensor', 'pager', 'adsb', 'wifi', 'bluetooth', 'ais', 'dsc', 'acars', 'aprs', 'rtlamr', 'tscm', 'satellite', 'listening_post'];
|
const allModes = ['sensor', 'pager', 'adsb', 'wifi', 'bluetooth', 'ais', 'dsc', 'acars', 'vdl2', 'aprs', 'rtlamr', 'tscm', 'satellite', 'listening_post'];
|
||||||
allModes.forEach(mode => {
|
allModes.forEach(mode => {
|
||||||
if (!agentRunningModes.includes(mode)) {
|
if (!agentRunningModes.includes(mode)) {
|
||||||
syncModeUI(mode, false, agentId);
|
syncModeUI(mode, false, agentId);
|
||||||
@@ -704,6 +704,7 @@ function syncModeUI(mode, isRunning, agentId = null) {
|
|||||||
'wifi': 'setWiFiRunning',
|
'wifi': 'setWiFiRunning',
|
||||||
'bluetooth': 'setBluetoothRunning',
|
'bluetooth': 'setBluetoothRunning',
|
||||||
'acars': 'setAcarsRunning',
|
'acars': 'setAcarsRunning',
|
||||||
|
'vdl2': 'setVdl2Running',
|
||||||
'listening_post': 'setListeningPostRunning'
|
'listening_post': 'setListeningPostRunning'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -865,12 +866,12 @@ function connectAgentStream(mode, onMessage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let streamUrl;
|
let streamUrl;
|
||||||
if (currentAgent === 'local') {
|
if (currentAgent === 'local') {
|
||||||
streamUrl = `/${mode}/stream`;
|
streamUrl = `/${mode}/stream`;
|
||||||
} else {
|
} else {
|
||||||
// For remote agents, proxy SSE through controller
|
// For remote agents, proxy SSE through controller
|
||||||
streamUrl = `/controller/agents/${currentAgent}/${mode}/stream`;
|
streamUrl = `/controller/agents/${currentAgent}/${mode}/stream`;
|
||||||
}
|
}
|
||||||
|
|
||||||
agentEventSource = new EventSource(streamUrl);
|
agentEventSource = new EventSource(streamUrl);
|
||||||
|
|
||||||
@@ -878,7 +879,7 @@ function connectAgentStream(mode, onMessage) {
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
onMessage(data);
|
onMessage(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error parsing SSE message:', e);
|
console.error('Error parsing SSE message:', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -373,7 +373,7 @@ function showInfo(text) {
|
|||||||
|
|
||||||
const infoEl = document.createElement('div');
|
const infoEl = document.createElement('div');
|
||||||
infoEl.className = 'info-msg';
|
infoEl.className = 'info-msg';
|
||||||
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "Space Mono", monospace; font-size: 11px; color: #888; word-break: break-all;';
|
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "Roboto Condensed", "Arial Narrow", sans-serif; font-size: 11px; color: #888; word-break: break-all;';
|
||||||
infoEl.textContent = text;
|
infoEl.textContent = text;
|
||||||
output.insertBefore(infoEl, output.firstChild);
|
output.insertBefore(infoEl, output.firstChild);
|
||||||
}
|
}
|
||||||
@@ -387,7 +387,7 @@ function showError(text) {
|
|||||||
|
|
||||||
const errorEl = document.createElement('div');
|
const errorEl = document.createElement('div');
|
||||||
errorEl.className = 'error-msg';
|
errorEl.className = 'error-msg';
|
||||||
errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "Space Mono", monospace; font-size: 11px; color: #ff6688; word-break: break-all;';
|
errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "Roboto Condensed", "Arial Narrow", sans-serif; font-size: 11px; color: #ff6688; word-break: break-all;';
|
||||||
errorEl.textContent = '⚠ ' + text;
|
errorEl.textContent = '⚠ ' + text;
|
||||||
output.insertBefore(errorEl, output.firstChild);
|
output.insertBefore(errorEl, output.firstChild);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -833,11 +833,11 @@ function renderUpdateStatus(data) {
|
|||||||
<div style="display: grid; gap: 8px; font-size: 12px;">
|
<div style="display: grid; gap: 8px; font-size: 12px;">
|
||||||
<div style="display: flex; justify-content: space-between;">
|
<div style="display: flex; justify-content: space-between;">
|
||||||
<span style="color: var(--text-dim);">Current Version</span>
|
<span style="color: var(--text-dim);">Current Version</span>
|
||||||
<span style="font-family: 'Space Mono', monospace; color: var(--text-primary);">v${data.current_version}</span>
|
<span style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; color: var(--text-primary);">v${data.current_version}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; justify-content: space-between;">
|
<div style="display: flex; justify-content: space-between;">
|
||||||
<span style="color: var(--text-dim);">Latest Version</span>
|
<span style="color: var(--text-dim);">Latest Version</span>
|
||||||
<span style="font-family: 'Space Mono', monospace; color: ${data.update_available ? 'var(--accent-green)' : 'var(--text-primary)'};">v${data.latest_version}</span>
|
<span style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; color: ${data.update_available ? 'var(--accent-green)' : 'var(--text-primary)'};">v${data.latest_version}</span>
|
||||||
</div>
|
</div>
|
||||||
${data.last_check ? `
|
${data.last_check ? `
|
||||||
<div style="display: flex; justify-content: space-between;">
|
<div style="display: flex; justify-content: space-between;">
|
||||||
|
|||||||
@@ -356,7 +356,9 @@ const BluetoothMode = (function() {
|
|||||||
|
|
||||||
// Update panel elements
|
// Update panel elements
|
||||||
document.getElementById('btDetailName').textContent = device.name || formatDeviceId(device.address);
|
document.getElementById('btDetailName').textContent = device.name || formatDeviceId(device.address);
|
||||||
document.getElementById('btDetailAddress').textContent = device.address;
|
document.getElementById('btDetailAddress').textContent = isUuidAddress(device)
|
||||||
|
? 'CB: ' + device.address
|
||||||
|
: device.address;
|
||||||
|
|
||||||
// RSSI
|
// RSSI
|
||||||
const rssiEl = document.getElementById('btDetailRssi');
|
const rssiEl = document.getElementById('btDetailRssi');
|
||||||
@@ -458,8 +460,98 @@ const BluetoothMode = (function() {
|
|||||||
? new Date(device.last_seen).toLocaleTimeString()
|
? new Date(device.last_seen).toLocaleTimeString()
|
||||||
: '--';
|
: '--';
|
||||||
|
|
||||||
|
// New stat cells
|
||||||
|
document.getElementById('btDetailTxPower').textContent = device.tx_power != null
|
||||||
|
? device.tx_power + ' dBm' : '--';
|
||||||
|
document.getElementById('btDetailSeenRate').textContent = device.seen_rate != null
|
||||||
|
? device.seen_rate.toFixed(1) + '/min' : '--';
|
||||||
|
|
||||||
|
// Stability from variance
|
||||||
|
const stabilityEl = document.getElementById('btDetailStability');
|
||||||
|
if (device.rssi_variance != null) {
|
||||||
|
let stabLabel, stabColor;
|
||||||
|
if (device.rssi_variance <= 5) { stabLabel = 'Stable'; stabColor = '#22c55e'; }
|
||||||
|
else if (device.rssi_variance <= 25) { stabLabel = 'Moderate'; stabColor = '#eab308'; }
|
||||||
|
else { stabLabel = 'Unstable'; stabColor = '#ef4444'; }
|
||||||
|
stabilityEl.textContent = stabLabel;
|
||||||
|
stabilityEl.style.color = stabColor;
|
||||||
|
} else {
|
||||||
|
stabilityEl.textContent = '--';
|
||||||
|
stabilityEl.style.color = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distance with confidence
|
||||||
|
const distEl = document.getElementById('btDetailDistance');
|
||||||
|
if (device.estimated_distance_m != null) {
|
||||||
|
const confPct = Math.round((device.distance_confidence || 0) * 100);
|
||||||
|
distEl.textContent = device.estimated_distance_m.toFixed(1) + 'm ±' + confPct + '%';
|
||||||
|
} else {
|
||||||
|
distEl.textContent = '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appearance badge
|
||||||
|
if (device.appearance_name) {
|
||||||
|
badgesHtml += '<span class="bt-detail-badge flag">' + escapeHtml(device.appearance_name) + '</span>';
|
||||||
|
badgesEl.innerHTML = badgesHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAC cluster indicator
|
||||||
|
const macClusterEl = document.getElementById('btDetailMacCluster');
|
||||||
|
if (macClusterEl) {
|
||||||
|
if (device.mac_cluster_count > 1) {
|
||||||
|
macClusterEl.textContent = device.mac_cluster_count + ' MACs';
|
||||||
|
macClusterEl.style.display = '';
|
||||||
|
} else {
|
||||||
|
macClusterEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service data inspector
|
||||||
|
const inspectorEl = document.getElementById('btDetailServiceInspector');
|
||||||
|
const inspectorContent = document.getElementById('btInspectorContent');
|
||||||
|
if (inspectorEl && inspectorContent) {
|
||||||
|
const hasData = device.manufacturer_bytes || device.appearance != null ||
|
||||||
|
(device.service_data && Object.keys(device.service_data).length > 0);
|
||||||
|
if (hasData) {
|
||||||
|
inspectorEl.style.display = '';
|
||||||
|
let inspHtml = '';
|
||||||
|
if (device.appearance != null) {
|
||||||
|
const name = device.appearance_name || '';
|
||||||
|
inspHtml += '<div class="bt-inspector-row"><span class="bt-inspector-label">Appearance</span><span class="bt-inspector-value">0x' + device.appearance.toString(16).toUpperCase().padStart(4, '0') + (name ? ' (' + escapeHtml(name) + ')' : '') + '</span></div>';
|
||||||
|
}
|
||||||
|
if (device.manufacturer_bytes) {
|
||||||
|
inspHtml += '<div class="bt-inspector-row"><span class="bt-inspector-label">Mfr Data</span><span class="bt-inspector-value">' + escapeHtml(device.manufacturer_bytes) + '</span></div>';
|
||||||
|
}
|
||||||
|
if (device.service_data) {
|
||||||
|
Object.entries(device.service_data).forEach(([uuid, hex]) => {
|
||||||
|
inspHtml += '<div class="bt-inspector-row"><span class="bt-inspector-label">' + escapeHtml(uuid) + '</span><span class="bt-inspector-value">' + escapeHtml(hex) + '</span></div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
inspectorContent.innerHTML = inspHtml;
|
||||||
|
} else {
|
||||||
|
inspectorEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updateWatchlistButton(device);
|
updateWatchlistButton(device);
|
||||||
|
|
||||||
|
// IRK
|
||||||
|
const irkContainer = document.getElementById('btDetailIrk');
|
||||||
|
if (irkContainer) {
|
||||||
|
if (device.has_irk) {
|
||||||
|
irkContainer.style.display = 'block';
|
||||||
|
const irkVal = document.getElementById('btDetailIrkValue');
|
||||||
|
if (irkVal) {
|
||||||
|
const label = device.irk_source_name
|
||||||
|
? device.irk_source_name + ' — ' + device.irk_hex
|
||||||
|
: device.irk_hex;
|
||||||
|
irkVal.textContent = label;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
irkContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
const servicesContainer = document.getElementById('btDetailServices');
|
const servicesContainer = document.getElementById('btDetailServices');
|
||||||
const servicesList = document.getElementById('btDetailServicesList');
|
const servicesList = document.getElementById('btDetailServicesList');
|
||||||
@@ -600,9 +692,25 @@ const BluetoothMode = (function() {
|
|||||||
if (parts.length === 6) {
|
if (parts.length === 6) {
|
||||||
return parts[0] + ':' + parts[1] + ':...:' + parts[4] + ':' + parts[5];
|
return parts[0] + ':' + parts[1] + ':...:' + parts[4] + ':' + parts[5];
|
||||||
}
|
}
|
||||||
|
// CoreBluetooth UUID format (8-4-4-4-12)
|
||||||
|
if (/^[0-9A-F]{8}-[0-9A-F]{4}-/i.test(address)) {
|
||||||
|
return address.substring(0, 8) + '...';
|
||||||
|
}
|
||||||
return address;
|
return address;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isUuidAddress(device) {
|
||||||
|
return device.address_type === 'uuid';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAddress(device) {
|
||||||
|
if (!device || !device.address) return '--';
|
||||||
|
if (isUuidAddress(device)) {
|
||||||
|
return device.address.substring(0, 8) + '-...' + device.address.slice(-4);
|
||||||
|
}
|
||||||
|
return device.address;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check system capabilities
|
* Check system capabilities
|
||||||
*/
|
*/
|
||||||
@@ -660,6 +768,12 @@ const BluetoothMode = (function() {
|
|||||||
hideCapabilityWarning();
|
hideCapabilityWarning();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show/hide Ubertooth option based on capabilities
|
||||||
|
const ubertoothOption = document.getElementById('btScanModeUbertooth');
|
||||||
|
if (ubertoothOption) {
|
||||||
|
ubertoothOption.style.display = data.has_ubertooth ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
if (scanModeSelect && data.preferred_backend) {
|
if (scanModeSelect && data.preferred_backend) {
|
||||||
const option = scanModeSelect.querySelector(`option[value="${data.preferred_backend}"]`);
|
const option = scanModeSelect.querySelector(`option[value="${data.preferred_backend}"]`);
|
||||||
if (option) option.selected = true;
|
if (option) option.selected = true;
|
||||||
@@ -1085,7 +1199,7 @@ const BluetoothMode = (function() {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div style="display:flex;justify-content:space-between;margin-top:3px;">' +
|
'<div style="display:flex;justify-content:space-between;margin-top:3px;">' +
|
||||||
'<span style="font-size:9px;color:#888;font-family:monospace;">' + t.address + '</span>' +
|
'<span style="font-size:9px;color:#888;font-family:monospace;">' + (t.address_type === 'uuid' ? formatAddress(t) : t.address) + '</span>' +
|
||||||
'<span style="font-size:9px;color:#666;">Seen ' + (t.seen_count || 0) + 'x</span>' +
|
'<span style="font-size:9px;color:#666;">Seen ' + (t.seen_count || 0) + 'x</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
evidenceHtml +
|
evidenceHtml +
|
||||||
@@ -1142,7 +1256,7 @@ const BluetoothMode = (function() {
|
|||||||
|
|
||||||
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(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 deviceIdEscaped = escapeHtml(device.device_id).replace(/'/g, "\\'");
|
||||||
@@ -1167,6 +1281,12 @@ const BluetoothMode = (function() {
|
|||||||
trackerBadge = '<span class="bt-tracker-badge" style="background:' + confBg + ';color:' + confColor + ';font-size:9px;padding:1px 4px;border-radius:3px;margin-left:4px;font-weight:600;">' + typeLabel + '</span>';
|
trackerBadge = '<span class="bt-tracker-badge" style="background:' + confBg + ';color:' + confColor + ';font-size:9px;padding:1px 4px;border-radius:3px;margin-left:4px;font-weight:600;">' + typeLabel + '</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IRK badge - show if paired IRK is available
|
||||||
|
let irkBadge = '';
|
||||||
|
if (device.has_irk) {
|
||||||
|
irkBadge = '<span class="bt-irk-badge">IRK</span>';
|
||||||
|
}
|
||||||
|
|
||||||
// Risk badge - show if risk score is significant
|
// Risk badge - show if risk score is significant
|
||||||
let riskBadge = '';
|
let riskBadge = '';
|
||||||
if (riskScore >= 0.3) {
|
if (riskScore >= 0.3) {
|
||||||
@@ -1184,9 +1304,36 @@ const BluetoothMode = (function() {
|
|||||||
statusDot = '<span class="bt-status-dot known"></span>';
|
statusDot = '<span class="bt-status-dot known"></span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Distance display
|
||||||
|
const distM = device.estimated_distance_m;
|
||||||
|
let distStr = '';
|
||||||
|
if (distM != null) {
|
||||||
|
distStr = '~' + distM.toFixed(1) + 'm';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Behavioral flag badges
|
||||||
|
const hFlags = device.heuristic_flags || [];
|
||||||
|
let flagBadges = '';
|
||||||
|
if (device.is_persistent || hFlags.includes('persistent')) {
|
||||||
|
flagBadges += '<span class="bt-flag-badge persistent">PERSIST</span>';
|
||||||
|
}
|
||||||
|
if (device.is_beacon_like || hFlags.includes('beacon_like')) {
|
||||||
|
flagBadges += '<span class="bt-flag-badge beacon-like">BEACON</span>';
|
||||||
|
}
|
||||||
|
if (device.is_strong_stable || hFlags.includes('strong_stable')) {
|
||||||
|
flagBadges += '<span class="bt-flag-badge strong-stable">STABLE</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// MAC cluster badge
|
||||||
|
let clusterBadge = '';
|
||||||
|
if (device.mac_cluster_count > 1) {
|
||||||
|
clusterBadge = '<span class="bt-mac-cluster-badge">' + device.mac_cluster_count + ' MACs</span>';
|
||||||
|
}
|
||||||
|
|
||||||
// Build secondary info line
|
// Build secondary info line
|
||||||
let secondaryParts = [addr];
|
let secondaryParts = [addr];
|
||||||
if (mfr) secondaryParts.push(mfr);
|
if (mfr) secondaryParts.push(mfr);
|
||||||
|
if (distStr) secondaryParts.push(distStr);
|
||||||
secondaryParts.push('Seen ' + seenCount + '×');
|
secondaryParts.push('Seen ' + seenCount + '×');
|
||||||
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
|
if (seenBefore) secondaryParts.push('<span class="bt-history-badge">SEEN BEFORE</span>');
|
||||||
// Add agent name if not Local
|
// Add agent name if not Local
|
||||||
@@ -1205,7 +1352,10 @@ const BluetoothMode = (function() {
|
|||||||
protoBadge +
|
protoBadge +
|
||||||
'<span class="bt-device-name">' + name + '</span>' +
|
'<span class="bt-device-name">' + name + '</span>' +
|
||||||
trackerBadge +
|
trackerBadge +
|
||||||
|
irkBadge +
|
||||||
riskBadge +
|
riskBadge +
|
||||||
|
flagBadges +
|
||||||
|
clusterBadge +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="bt-row-right">' +
|
'<div class="bt-row-right">' +
|
||||||
'<div class="bt-rssi-container">' +
|
'<div class="bt-rssi-container">' +
|
||||||
@@ -1300,6 +1450,18 @@ const BluetoothMode = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the service data inspector panel
|
||||||
|
*/
|
||||||
|
function toggleServiceInspector() {
|
||||||
|
const content = document.getElementById('btInspectorContent');
|
||||||
|
const arrow = document.getElementById('btInspectorArrow');
|
||||||
|
if (!content) return;
|
||||||
|
const open = content.style.display === 'none';
|
||||||
|
content.style.display = open ? '' : 'none';
|
||||||
|
if (arrow) arrow.classList.toggle('open', open);
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Agent Handling
|
// Agent Handling
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@@ -1425,9 +1587,15 @@ const BluetoothMode = (function() {
|
|||||||
BtLocate.handoff({
|
BtLocate.handoff({
|
||||||
device_id: device.device_id,
|
device_id: device.device_id,
|
||||||
mac_address: device.address,
|
mac_address: device.address,
|
||||||
|
address_type: device.address_type || null,
|
||||||
|
irk_hex: device.irk_hex || null,
|
||||||
known_name: device.name || null,
|
known_name: device.name || null,
|
||||||
known_manufacturer: device.manufacturer_name || null,
|
known_manufacturer: device.manufacturer_name || null,
|
||||||
last_known_rssi: device.rssi_current
|
last_known_rssi: device.rssi_current,
|
||||||
|
tx_power: device.tx_power || null,
|
||||||
|
appearance_name: device.appearance_name || null,
|
||||||
|
fingerprint_id: device.fingerprint_id || null,
|
||||||
|
mac_cluster_count: device.mac_cluster_count || 0
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1447,6 +1615,7 @@ const BluetoothMode = (function() {
|
|||||||
toggleWatchlist,
|
toggleWatchlist,
|
||||||
locateDevice,
|
locateDevice,
|
||||||
locateById,
|
locateById,
|
||||||
|
toggleServiceInspector,
|
||||||
|
|
||||||
// Agent handling
|
// Agent handling
|
||||||
handleAgentChange,
|
handleAgentChange,
|
||||||
|
|||||||
@@ -322,7 +322,8 @@ const BtLocate = (function() {
|
|||||||
const t = data.target;
|
const t = data.target;
|
||||||
const name = t.known_name || t.name_pattern || '';
|
const name = t.known_name || t.name_pattern || '';
|
||||||
const addr = t.mac_address || t.device_id || '';
|
const addr = t.mac_address || t.device_id || '';
|
||||||
targetEl.textContent = name ? (name + (addr ? ' (' + addr.substring(0, 8) + '...)' : '')) : addr || '--';
|
const addrDisplay = formatAddr(addr);
|
||||||
|
targetEl.textContent = name ? (name + (addrDisplay ? ' (' + addrDisplay + ')' : '')) : addrDisplay || '--';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Environment info
|
// Environment info
|
||||||
@@ -602,6 +603,16 @@ const BtLocate = (function() {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isUuid(addr) {
|
||||||
|
return addr && /^[0-9A-F]{8}-[0-9A-F]{4}-/i.test(addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAddr(addr) {
|
||||||
|
if (!addr) return '';
|
||||||
|
if (isUuid(addr)) return addr.substring(0, 8) + '-...' + addr.slice(-4);
|
||||||
|
return addr;
|
||||||
|
}
|
||||||
|
|
||||||
function handoff(deviceInfo) {
|
function handoff(deviceInfo) {
|
||||||
console.log('[BtLocate] Handoff received:', deviceInfo);
|
console.log('[BtLocate] Handoff received:', deviceInfo);
|
||||||
handoffData = deviceInfo;
|
handoffData = deviceInfo;
|
||||||
@@ -617,15 +628,21 @@ const BtLocate = (function() {
|
|||||||
const nameEl = document.getElementById('btLocateHandoffName');
|
const nameEl = document.getElementById('btLocateHandoffName');
|
||||||
const metaEl = document.getElementById('btLocateHandoffMeta');
|
const metaEl = document.getElementById('btLocateHandoffMeta');
|
||||||
if (card) card.style.display = '';
|
if (card) card.style.display = '';
|
||||||
if (nameEl) nameEl.textContent = deviceInfo.known_name || deviceInfo.mac_address || 'Unknown';
|
if (nameEl) nameEl.textContent = deviceInfo.known_name || formatAddr(deviceInfo.mac_address) || 'Unknown';
|
||||||
if (metaEl) {
|
if (metaEl) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (deviceInfo.mac_address) parts.push(deviceInfo.mac_address);
|
if (deviceInfo.mac_address) parts.push(formatAddr(deviceInfo.mac_address));
|
||||||
if (deviceInfo.known_manufacturer) parts.push(deviceInfo.known_manufacturer);
|
if (deviceInfo.known_manufacturer) parts.push(deviceInfo.known_manufacturer);
|
||||||
if (deviceInfo.last_known_rssi != null) parts.push(deviceInfo.last_known_rssi + ' dBm');
|
if (deviceInfo.last_known_rssi != null) parts.push(deviceInfo.last_known_rssi + ' dBm');
|
||||||
metaEl.textContent = parts.join(' \u00b7 ');
|
metaEl.textContent = parts.join(' \u00b7 ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-fill IRK if available from scanner
|
||||||
|
if (deviceInfo.irk_hex) {
|
||||||
|
const irkInput = document.getElementById('btLocateIrk');
|
||||||
|
if (irkInput) irkInput.value = deviceInfo.irk_hex;
|
||||||
|
}
|
||||||
|
|
||||||
// Switch to bt_locate mode
|
// Switch to bt_locate mode
|
||||||
if (typeof switchMode === 'function') {
|
if (typeof switchMode === 'function') {
|
||||||
switchMode('bt_locate');
|
switchMode('bt_locate');
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const GPS = (function() {
|
const GPS = (function() {
|
||||||
let eventSource = null;
|
|
||||||
let connected = false;
|
let connected = false;
|
||||||
let lastPosition = null;
|
let lastPosition = null;
|
||||||
let lastSky = null;
|
let lastSky = null;
|
||||||
|
let skyPollTimer = null;
|
||||||
|
|
||||||
// Constellation color map
|
// Constellation color map
|
||||||
const CONST_COLORS = {
|
const CONST_COLORS = {
|
||||||
@@ -26,6 +26,7 @@ const GPS = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
|
updateConnectionUI(false, false, 'connecting');
|
||||||
fetch('/gps/auto-connect', { method: 'POST' })
|
fetch('/gps/auto-connect', { method: 'POST' })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@@ -40,23 +41,26 @@ const GPS = (function() {
|
|||||||
lastSky = data.sky;
|
lastSky = data.sky;
|
||||||
updateSkyUI(data.sky);
|
updateSkyUI(data.sky);
|
||||||
}
|
}
|
||||||
startStream();
|
subscribeToStream();
|
||||||
|
startSkyPolling();
|
||||||
|
// Ensure the global GPS stream is running
|
||||||
|
if (typeof startGpsStream === 'function' && !gpsEventSource) {
|
||||||
|
startGpsStream();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
connected = false;
|
connected = false;
|
||||||
updateConnectionUI(false);
|
updateConnectionUI(false, false, 'error', data.message || 'gpsd not available');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
connected = false;
|
connected = false;
|
||||||
updateConnectionUI(false);
|
updateConnectionUI(false, false, 'error', 'Connection failed — is the server running?');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function disconnect() {
|
function disconnect() {
|
||||||
if (eventSource) {
|
unsubscribeFromStream();
|
||||||
eventSource.close();
|
stopSkyPolling();
|
||||||
eventSource = null;
|
|
||||||
}
|
|
||||||
fetch('/gps/stop', { method: 'POST' })
|
fetch('/gps/stop', { method: 'POST' })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
connected = false;
|
connected = false;
|
||||||
@@ -64,36 +68,64 @@ const GPS = (function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function startStream() {
|
function onGpsStreamData(data) {
|
||||||
if (eventSource) {
|
if (!connected) return;
|
||||||
eventSource.close();
|
if (data.type === 'position') {
|
||||||
|
lastPosition = data;
|
||||||
|
updatePositionUI(data);
|
||||||
|
updateConnectionUI(true, true);
|
||||||
|
} else if (data.type === 'sky') {
|
||||||
|
lastSky = data;
|
||||||
|
updateSkyUI(data);
|
||||||
}
|
}
|
||||||
eventSource = new EventSource('/gps/stream');
|
}
|
||||||
eventSource.onmessage = function(e) {
|
|
||||||
try {
|
function startSkyPolling() {
|
||||||
const data = JSON.parse(e.data);
|
stopSkyPolling();
|
||||||
if (data.type === 'position') {
|
// Poll satellite data every 5 seconds as a reliable fallback
|
||||||
lastPosition = data;
|
// SSE stream may miss sky updates due to queue contention with position messages
|
||||||
updatePositionUI(data);
|
pollSatellites();
|
||||||
updateConnectionUI(true, true);
|
skyPollTimer = setInterval(pollSatellites, 5000);
|
||||||
} else if (data.type === 'sky') {
|
}
|
||||||
lastSky = data;
|
|
||||||
updateSkyUI(data);
|
function stopSkyPolling() {
|
||||||
|
if (skyPollTimer) {
|
||||||
|
clearInterval(skyPollTimer);
|
||||||
|
skyPollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollSatellites() {
|
||||||
|
if (!connected) return;
|
||||||
|
fetch('/gps/satellites')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'ok' && data.sky) {
|
||||||
|
lastSky = data.sky;
|
||||||
|
updateSkyUI(data.sky);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
})
|
||||||
// ignore parse errors
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
};
|
|
||||||
eventSource.onerror = function() {
|
function subscribeToStream() {
|
||||||
// Reconnect handled by browser automatically
|
// Subscribe to the global GPS stream instead of opening a separate SSE connection
|
||||||
};
|
if (typeof addGpsStreamSubscriber === 'function') {
|
||||||
|
addGpsStreamSubscriber(onGpsStreamData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsubscribeFromStream() {
|
||||||
|
if (typeof removeGpsStreamSubscriber === 'function') {
|
||||||
|
removeGpsStreamSubscriber(onGpsStreamData);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================
|
// ========================
|
||||||
// UI Updates
|
// UI Updates
|
||||||
// ========================
|
// ========================
|
||||||
|
|
||||||
function updateConnectionUI(isConnected, hasFix) {
|
function updateConnectionUI(isConnected, hasFix, state, message) {
|
||||||
const dot = document.getElementById('gpsStatusDot');
|
const dot = document.getElementById('gpsStatusDot');
|
||||||
const text = document.getElementById('gpsStatusText');
|
const text = document.getElementById('gpsStatusText');
|
||||||
const connectBtn = document.getElementById('gpsConnectBtn');
|
const connectBtn = document.getElementById('gpsConnectBtn');
|
||||||
@@ -102,15 +134,22 @@ const GPS = (function() {
|
|||||||
|
|
||||||
if (dot) {
|
if (dot) {
|
||||||
dot.className = 'gps-status-dot';
|
dot.className = 'gps-status-dot';
|
||||||
if (isConnected && hasFix) dot.classList.add('connected');
|
if (state === 'connecting') dot.classList.add('waiting');
|
||||||
|
else if (state === 'error') dot.classList.add('error');
|
||||||
|
else if (isConnected && hasFix) dot.classList.add('connected');
|
||||||
else if (isConnected) dot.classList.add('waiting');
|
else if (isConnected) dot.classList.add('waiting');
|
||||||
}
|
}
|
||||||
if (text) {
|
if (text) {
|
||||||
if (isConnected && hasFix) text.textContent = 'Connected (Fix)';
|
if (state === 'connecting') text.textContent = 'Connecting...';
|
||||||
|
else if (state === 'error') text.textContent = message || 'Connection failed';
|
||||||
|
else if (isConnected && hasFix) text.textContent = 'Connected (Fix)';
|
||||||
else if (isConnected) text.textContent = 'Connected (No Fix)';
|
else if (isConnected) text.textContent = 'Connected (No Fix)';
|
||||||
else text.textContent = 'Disconnected';
|
else text.textContent = 'Disconnected';
|
||||||
}
|
}
|
||||||
if (connectBtn) connectBtn.style.display = isConnected ? 'none' : '';
|
if (connectBtn) {
|
||||||
|
connectBtn.style.display = isConnected ? 'none' : '';
|
||||||
|
connectBtn.disabled = state === 'connecting';
|
||||||
|
}
|
||||||
if (disconnectBtn) disconnectBtn.style.display = isConnected ? '' : 'none';
|
if (disconnectBtn) disconnectBtn.style.display = isConnected ? '' : 'none';
|
||||||
if (devicePath) devicePath.textContent = isConnected ? 'gpsd://localhost:2947' : '';
|
if (devicePath) devicePath.textContent = isConnected ? 'gpsd://localhost:2947' : '';
|
||||||
}
|
}
|
||||||
@@ -252,7 +291,7 @@ const GPS = (function() {
|
|||||||
|
|
||||||
// PRN label
|
// PRN label
|
||||||
ctx.fillStyle = color;
|
ctx.fillStyle = color;
|
||||||
ctx.font = '8px JetBrains Mono, monospace';
|
ctx.font = '8px Roboto Condensed, monospace';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'bottom';
|
ctx.textBaseline = 'bottom';
|
||||||
ctx.fillText(sat.prn, px, py - dotSize - 2);
|
ctx.fillText(sat.prn, px, py - dotSize - 2);
|
||||||
@@ -260,7 +299,7 @@ const GPS = (function() {
|
|||||||
// SNR value
|
// SNR value
|
||||||
if (sat.snr != null) {
|
if (sat.snr != null) {
|
||||||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||||
ctx.font = '7px JetBrains Mono, monospace';
|
ctx.font = '7px Roboto Condensed, monospace';
|
||||||
ctx.textBaseline = 'top';
|
ctx.textBaseline = 'top';
|
||||||
ctx.fillText(Math.round(sat.snr), px, py + dotSize + 1);
|
ctx.fillText(Math.round(sat.snr), px, py + dotSize + 1);
|
||||||
}
|
}
|
||||||
@@ -292,7 +331,7 @@ const GPS = (function() {
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
// Label
|
// Label
|
||||||
ctx.fillStyle = '#555';
|
ctx.fillStyle = '#555';
|
||||||
ctx.font = '9px JetBrains Mono, monospace';
|
ctx.font = '9px Roboto Condensed, monospace';
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
|
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
|
||||||
@@ -307,7 +346,7 @@ const GPS = (function() {
|
|||||||
|
|
||||||
// Cardinal directions
|
// Cardinal directions
|
||||||
ctx.fillStyle = '#888';
|
ctx.fillStyle = '#888';
|
||||||
ctx.font = 'bold 11px JetBrains Mono, monospace';
|
ctx.font = 'bold 11px Roboto Condensed, monospace';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
ctx.fillText('N', cx, cy - r - 12);
|
ctx.fillText('N', cx, cy - r - 12);
|
||||||
@@ -386,10 +425,8 @@ const GPS = (function() {
|
|||||||
// ========================
|
// ========================
|
||||||
|
|
||||||
function destroy() {
|
function destroy() {
|
||||||
if (eventSource) {
|
unsubscribeFromStream();
|
||||||
eventSource.close();
|
stopSkyPolling();
|
||||||
eventSource = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1483,7 +1483,7 @@ function drawAudioVisualizer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
|
||||||
ctx.font = '8px Space Mono';
|
ctx.font = '8px Roboto Condensed';
|
||||||
ctx.fillText('0', 2, canvas.height - 2);
|
ctx.fillText('0', 2, canvas.height - 2);
|
||||||
ctx.fillText('4kHz', canvas.width / 4, canvas.height - 2);
|
ctx.fillText('4kHz', canvas.width / 4, canvas.height - 2);
|
||||||
ctx.fillText('8kHz', canvas.width / 2, canvas.height - 2);
|
ctx.fillText('8kHz', canvas.width / 2, canvas.height - 2);
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const SpyStations = (function() {
|
|||||||
modeContainer.innerHTML = modes.map(m => `
|
modeContainer.innerHTML = modes.map(m => `
|
||||||
<label class="inline-checkbox">
|
<label class="inline-checkbox">
|
||||||
<input type="checkbox" data-mode="${m}" checked onchange="SpyStations.applyFilters()">
|
<input type="checkbox" data-mode="${m}" checked onchange="SpyStations.applyFilters()">
|
||||||
<span style="font-family: 'Space Mono', monospace; font-size: 10px;">${m}</span>
|
<span style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 10px;">${m}</span>
|
||||||
</label>
|
</label>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1754,7 +1754,7 @@ const SubGhz = (function() {
|
|||||||
// Grid
|
// Grid
|
||||||
ctx.strokeStyle = '#1a1f2e';
|
ctx.strokeStyle = '#1a1f2e';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.font = '10px JetBrains Mono, monospace';
|
ctx.font = '10px Roboto Condensed, monospace';
|
||||||
ctx.fillStyle = '#666';
|
ctx.fillStyle = '#666';
|
||||||
|
|
||||||
for (let db = powerMin; db <= powerMax; db += 20) {
|
for (let db = powerMin; db <= powerMax; db += 20) {
|
||||||
@@ -1824,7 +1824,7 @@ const SubGhz = (function() {
|
|||||||
ctx.lineTo(x + 4, y - 2);
|
ctx.lineTo(x + 4, y - 2);
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.font = '9px JetBrains Mono, monospace';
|
ctx.font = '9px Roboto Condensed, monospace';
|
||||||
ctx.fillStyle = 'rgba(255, 170, 0, 0.8)';
|
ctx.fillStyle = 'rgba(255, 170, 0, 0.8)';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.fillText(peak.freq.toFixed(1), x, y - 10);
|
ctx.fillText(peak.freq.toFixed(1), x, y - 10);
|
||||||
|
|||||||
@@ -566,7 +566,7 @@ const WeatherSat = (function() {
|
|||||||
</div>
|
</div>
|
||||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-top: 4px;">
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-top: 4px;">
|
||||||
<span class="wxsat-pass-quality ${pass.quality}">${pass.quality}</span>
|
<span class="wxsat-pass-quality ${pass.quality}">${pass.quality}</span>
|
||||||
<span style="font-size: 10px; color: var(--text-dim); font-family: 'JetBrains Mono', monospace;">${countdown}</span>
|
<span style="font-size: 10px; color: var(--text-dim); font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">${countdown}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top: 6px; text-align: right;">
|
<div style="margin-top: 6px; text-align: right;">
|
||||||
<button class="wxsat-strip-btn" onclick="event.stopPropagation(); WeatherSat.startPass('${escapeHtml(pass.satellite)}')" style="font-size: 10px; padding: 2px 8px;">Capture</button>
|
<button class="wxsat-strip-btn" onclick="event.stopPropagation(); WeatherSat.startPass('${escapeHtml(pass.satellite)}')" style="font-size: 10px; padding: 2px 8px;">Capture</button>
|
||||||
@@ -610,7 +610,7 @@ const WeatherSat = (function() {
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
// Label
|
// Label
|
||||||
ctx.fillStyle = '#555';
|
ctx.fillStyle = '#555';
|
||||||
ctx.font = '9px JetBrains Mono, monospace';
|
ctx.font = '9px Roboto Condensed, monospace';
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
|
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
|
||||||
});
|
});
|
||||||
@@ -624,7 +624,7 @@ const WeatherSat = (function() {
|
|||||||
|
|
||||||
// Cardinal directions
|
// Cardinal directions
|
||||||
ctx.fillStyle = '#666';
|
ctx.fillStyle = '#666';
|
||||||
ctx.font = '10px JetBrains Mono, monospace';
|
ctx.font = '10px Roboto Condensed, monospace';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
ctx.fillText('N', cx, cy - r - 10);
|
ctx.fillText('N', cx, cy - r - 10);
|
||||||
@@ -692,7 +692,7 @@ const WeatherSat = (function() {
|
|||||||
ctx.arc(cx + r * maxR * Math.cos(maxAz), cy + r * maxR * Math.sin(maxAz), 3, 0, Math.PI * 2);
|
ctx.arc(cx + r * maxR * Math.cos(maxAz), cy + r * maxR * Math.sin(maxAz), 3, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.fillStyle = color;
|
ctx.fillStyle = color;
|
||||||
ctx.font = '9px JetBrains Mono, monospace';
|
ctx.font = '9px Roboto Condensed, monospace';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
{% if offline_settings.fonts_source == 'local' %}
|
{% if offline_settings.fonts_source == 'local' %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||||
{% else %}
|
{% else %}
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
{% 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') }}">
|
||||||
@@ -472,7 +472,7 @@
|
|||||||
|
|
||||||
if (!points.length) {
|
if (!points.length) {
|
||||||
ctx.fillStyle = 'rgba(156, 163, 175, 0.6)';
|
ctx.fillStyle = 'rgba(156, 163, 175, 0.6)';
|
||||||
ctx.font = '12px "Space Mono", monospace';
|
ctx.font = '12px "Roboto Condensed", "Arial Narrow", sans-serif';
|
||||||
ctx.fillText(`No ${label.toLowerCase()} data`, 12, height / 2);
|
ctx.fillText(`No ${label.toLowerCase()} data`, 12, height / 2);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -480,7 +480,7 @@
|
|||||||
const series = points.map(p => p[field]).filter(v => v !== null && v !== undefined);
|
const series = points.map(p => p[field]).filter(v => v !== null && v !== undefined);
|
||||||
if (!series.length) {
|
if (!series.length) {
|
||||||
ctx.fillStyle = 'rgba(156, 163, 175, 0.6)';
|
ctx.fillStyle = 'rgba(156, 163, 175, 0.6)';
|
||||||
ctx.font = '12px "Space Mono", monospace';
|
ctx.font = '12px "Roboto Condensed", "Arial Narrow", sans-serif';
|
||||||
ctx.fillText(`No ${label.toLowerCase()} data`, 12, height / 2);
|
ctx.fillText(`No ${label.toLowerCase()} data`, 12, height / 2);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -521,7 +521,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(226, 232, 240, 0.8)';
|
ctx.fillStyle = 'rgba(226, 232, 240, 0.8)';
|
||||||
ctx.font = '11px "Space Mono", monospace';
|
ctx.font = '11px "Roboto Condensed", "Arial Narrow", sans-serif';
|
||||||
ctx.fillText(`${maxVal} ${unit}`, 12, padding);
|
ctx.fillText(`${maxVal} ${unit}`, 12, padding);
|
||||||
ctx.fillText(`${minVal} ${unit}`, 12, height - padding);
|
ctx.fillText(`${minVal} ${unit}`, 12, height - padding);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
{% if offline_settings.fonts_source == 'local' %}
|
{% if offline_settings.fonts_source == 'local' %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||||
{% else %}
|
{% else %}
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Orbitron:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<!-- Leaflet.js - Conditional CDN/Local loading -->
|
<!-- Leaflet.js - Conditional CDN/Local loading -->
|
||||||
{% if offline_settings.assets_source == 'local' %}
|
{% if offline_settings.assets_source == 'local' %}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
{% if offline_settings.fonts_source == 'local' %}
|
{% if offline_settings.fonts_source == 'local' %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||||
{% else %}
|
{% else %}
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<!-- Leaflet.js for APRS map - Conditional CDN/Local loading -->
|
<!-- Leaflet.js for APRS map - Conditional CDN/Local loading -->
|
||||||
{% if offline_settings.assets_source == 'local' %}
|
{% if offline_settings.assets_source == 'local' %}
|
||||||
@@ -50,7 +50,6 @@
|
|||||||
<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/index.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/acars.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/aprs.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/aprs.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/tscm.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/tscm.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-cards.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-cards.css') }}">
|
||||||
@@ -216,6 +215,10 @@
|
|||||||
<span class="mode-icon icon"><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></span>
|
<span class="mode-icon icon"><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></span>
|
||||||
<span class="mode-name">Bluetooth</span>
|
<span class="mode-name">Bluetooth</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="mode-card mode-card-sm" onclick="selectMode('bt_locate')">
|
||||||
|
<span class="mode-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="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></span>
|
||||||
|
<span class="mode-name">BT Locate</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -439,6 +442,7 @@
|
|||||||
<label style="font-size: 11px; color: #888; margin-bottom: 4px;">Hardware Type</label>
|
<label style="font-size: 11px; color: #888; margin-bottom: 4px;">Hardware Type</label>
|
||||||
<select id="sdrTypeSelect" onchange="onSDRTypeChanged()">
|
<select id="sdrTypeSelect" onchange="onSDRTypeChanged()">
|
||||||
<option value="rtlsdr">RTL-SDR</option>
|
<option value="rtlsdr">RTL-SDR</option>
|
||||||
|
<option value="sdrplay">SDRplay</option>
|
||||||
<option value="limesdr">LimeSDR</option>
|
<option value="limesdr">LimeSDR</option>
|
||||||
<option value="hackrf">HackRF</option>
|
<option value="hackrf">HackRF</option>
|
||||||
<option value="airspy">Airspy</option>
|
<option value="airspy">Airspy</option>
|
||||||
@@ -564,6 +568,8 @@
|
|||||||
|
|
||||||
{% include 'partials/modes/bt_locate.html' %}
|
{% include 'partials/modes/bt_locate.html' %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<button class="preset-btn" onclick="killAll()"
|
<button class="preset-btn" onclick="killAll()"
|
||||||
style="width: 100%; margin-top: 10px; border-color: #ff3366; color: #ff3366;">
|
style="width: 100%; margin-top: 10px; border-color: #ff3366; color: #ff3366;">
|
||||||
Kill All Processes
|
Kill All Processes
|
||||||
@@ -790,7 +796,10 @@
|
|||||||
<div class="bt-detail-top-row">
|
<div class="bt-detail-top-row">
|
||||||
<div class="bt-detail-identity">
|
<div class="bt-detail-identity">
|
||||||
<div class="bt-detail-name" id="btDetailName">Device Name</div>
|
<div class="bt-detail-name" id="btDetailName">Device Name</div>
|
||||||
<div class="bt-detail-address" id="btDetailAddress">00:00:00:00:00:00</div>
|
<div class="bt-detail-address">
|
||||||
|
<span id="btDetailAddress">00:00:00:00:00:00</span>
|
||||||
|
<span class="bt-mac-cluster-badge" id="btDetailMacCluster" style="display:none;"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bt-detail-rssi-display">
|
<div class="bt-detail-rssi-display">
|
||||||
<span class="bt-detail-rssi-value" id="btDetailRssi">--</span>
|
<span class="bt-detail-rssi-value" id="btDetailRssi">--</span>
|
||||||
@@ -831,8 +840,36 @@
|
|||||||
<span class="bt-detail-stat-label">Mfr ID</span>
|
<span class="bt-detail-stat-label">Mfr ID</span>
|
||||||
<span class="bt-detail-stat-value" id="btDetailMfrId">--</span>
|
<span class="bt-detail-stat-value" id="btDetailMfrId">--</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="bt-detail-stat">
|
||||||
|
<span class="bt-detail-stat-label">TX Power</span>
|
||||||
|
<span class="bt-detail-stat-value" id="btDetailTxPower">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="bt-detail-stat">
|
||||||
|
<span class="bt-detail-stat-label">Seen Rate</span>
|
||||||
|
<span class="bt-detail-stat-value" id="btDetailSeenRate">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="bt-detail-stat">
|
||||||
|
<span class="bt-detail-stat-label">Stability</span>
|
||||||
|
<span class="bt-detail-stat-value" id="btDetailStability">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="bt-detail-stat">
|
||||||
|
<span class="bt-detail-stat-label">Distance</span>
|
||||||
|
<span class="bt-detail-stat-value" id="btDetailDistance">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Service Data Inspector (collapsible) -->
|
||||||
|
<div class="bt-detail-service-inspector" id="btDetailServiceInspector" style="display:none;">
|
||||||
|
<div class="bt-inspector-toggle" onclick="BluetoothMode.toggleServiceInspector()">
|
||||||
|
<span class="bt-inspector-arrow" id="btInspectorArrow">▸</span> Raw Data
|
||||||
|
</div>
|
||||||
|
<div class="bt-inspector-content" id="btInspectorContent" style="display:none;">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bt-detail-bottom-row">
|
<div class="bt-detail-bottom-row">
|
||||||
|
<div class="bt-detail-irk" id="btDetailIrk" style="display: none;">
|
||||||
|
<span class="bt-irk-badge">IRK</span>
|
||||||
|
<span class="bt-detail-irk-value" id="btDetailIrkValue" style="font-size:10px;color:var(--text-dim);font-family:var(--font-mono);margin-left:6px;word-break:break-all;"></span>
|
||||||
|
</div>
|
||||||
<div class="bt-detail-services" id="btDetailServices" style="display: none;">
|
<div class="bt-detail-services" id="btDetailServices" style="display: none;">
|
||||||
<span class="bt-detail-services-list" id="btDetailServicesList"></span>
|
<span class="bt-detail-services-list" id="btDetailServicesList"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -2525,7 +2562,7 @@
|
|||||||
|
|
||||||
<!-- Signal Scope -->
|
<!-- Signal Scope -->
|
||||||
<div id="sstvScopePanel" style="display: none; margin-bottom: 12px;">
|
<div id="sstvScopePanel" style="display: none; margin-bottom: 12px;">
|
||||||
<div style="background: #0a0a0a; border: 1px solid #1e1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'JetBrains Mono', 'Fira Code', monospace;">
|
<div style="background: #0a0a0a; border: 1px solid #1e1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||||||
<span>Signal Scope</span>
|
<span>Signal Scope</span>
|
||||||
<div style="display: flex; gap: 14px;">
|
<div style="display: flex; gap: 14px;">
|
||||||
@@ -2792,7 +2829,7 @@
|
|||||||
|
|
||||||
<!-- Signal Scope -->
|
<!-- Signal Scope -->
|
||||||
<div id="sstvGeneralScopePanel" style="display: none; margin-bottom: 12px;">
|
<div id="sstvGeneralScopePanel" style="display: none; margin-bottom: 12px;">
|
||||||
<div style="background: #0a0a0a; border: 1px solid #1e1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'JetBrains Mono', 'Fira Code', monospace;">
|
<div style="background: #0a0a0a; border: 1px solid #1e1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||||||
<span>Signal Scope</span>
|
<span>Signal Scope</span>
|
||||||
<div style="display: flex; gap: 14px;">
|
<div style="display: flex; gap: 14px;">
|
||||||
@@ -2884,7 +2921,7 @@
|
|||||||
|
|
||||||
<!-- Pager Signal Scope -->
|
<!-- Pager Signal Scope -->
|
||||||
<div id="pagerScopePanel" style="display: none; margin-bottom: 12px;">
|
<div id="pagerScopePanel" style="display: none; margin-bottom: 12px;">
|
||||||
<div style="background: #0a0a0a; border: 1px solid #1a1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'JetBrains Mono', 'Fira Code', monospace;">
|
<div style="background: #0a0a0a; border: 1px solid #1a1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||||||
<span>Signal Scope</span>
|
<span>Signal Scope</span>
|
||||||
<div style="display: flex; gap: 14px;">
|
<div style="display: flex; gap: 14px;">
|
||||||
@@ -2902,7 +2939,7 @@
|
|||||||
|
|
||||||
<!-- Sensor Signal Scope -->
|
<!-- Sensor Signal Scope -->
|
||||||
<div id="sensorScopePanel" style="display: none; margin-bottom: 12px;">
|
<div id="sensorScopePanel" style="display: none; margin-bottom: 12px;">
|
||||||
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px; font-family: 'JetBrains Mono', 'Fira Code', monospace;">
|
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||||||
<span>Signal Scope</span>
|
<span>Signal Scope</span>
|
||||||
<div style="display: flex; gap: 14px;">
|
<div style="display: flex; gap: 14px;">
|
||||||
@@ -3686,6 +3723,8 @@
|
|||||||
document.getElementById('dmrMode')?.classList.toggle('active', mode === 'dmr');
|
document.getElementById('dmrMode')?.classList.toggle('active', mode === 'dmr');
|
||||||
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
|
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
|
||||||
document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz');
|
document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz');
|
||||||
|
|
||||||
|
|
||||||
const pagerStats = document.getElementById('pagerStats');
|
const pagerStats = document.getElementById('pagerStats');
|
||||||
const sensorStats = document.getElementById('sensorStats');
|
const sensorStats = document.getElementById('sensorStats');
|
||||||
const satelliteStats = document.getElementById('satelliteStats');
|
const satelliteStats = document.getElementById('satelliteStats');
|
||||||
@@ -3833,7 +3872,7 @@
|
|||||||
|
|
||||||
// Show agent selector for modes that support remote agents
|
// Show agent selector for modes that support remote agents
|
||||||
const agentSection = document.getElementById('agentSection');
|
const agentSection = document.getElementById('agentSection');
|
||||||
const agentModes = ['pager', 'sensor', 'rtlamr', 'listening', 'aprs', 'wifi', 'bluetooth', 'aircraft', 'tscm', 'ais', 'acars', 'dsc'];
|
const agentModes = ['pager', 'sensor', 'rtlamr', 'listening', 'aprs', 'wifi', 'bluetooth', 'aircraft', 'tscm', 'ais', 'dsc'];
|
||||||
if (agentSection) agentSection.style.display = agentModes.includes(mode) ? 'block' : 'none';
|
if (agentSection) agentSection.style.display = agentModes.includes(mode) ? 'block' : 'none';
|
||||||
|
|
||||||
// Show RTL-SDR device section for modes that use it
|
// Show RTL-SDR device section for modes that use it
|
||||||
@@ -4942,8 +4981,10 @@
|
|||||||
// SDR hardware capabilities
|
// SDR hardware capabilities
|
||||||
const sdrCapabilities = {
|
const sdrCapabilities = {
|
||||||
'rtlsdr': { name: 'RTL-SDR', freq_min: 24, freq_max: 1766, gain_min: 0, gain_max: 50 },
|
'rtlsdr': { name: 'RTL-SDR', freq_min: 24, freq_max: 1766, gain_min: 0, gain_max: 50 },
|
||||||
|
'sdrplay': { name: 'SDRplay', freq_min: 0.001, freq_max: 2000, gain_min: 0, gain_max: 59 },
|
||||||
'limesdr': { name: 'LimeSDR', freq_min: 0.1, freq_max: 3800, gain_min: 0, gain_max: 73 },
|
'limesdr': { name: 'LimeSDR', freq_min: 0.1, freq_max: 3800, gain_min: 0, gain_max: 73 },
|
||||||
'hackrf': { name: 'HackRF', freq_min: 1, freq_max: 6000, gain_min: 0, gain_max: 62 }
|
'hackrf': { name: 'HackRF', freq_min: 1, freq_max: 6000, gain_min: 0, gain_max: 62 },
|
||||||
|
'airspy': { name: 'Airspy', freq_min: 24, freq_max: 1800, gain_min: 0, gain_max: 21 }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Current device list with SDR type info
|
// Current device list with SDR type info
|
||||||
@@ -5811,7 +5852,7 @@
|
|||||||
|
|
||||||
const infoEl = document.createElement('div');
|
const infoEl = document.createElement('div');
|
||||||
infoEl.className = 'info-msg';
|
infoEl.className = 'info-msg';
|
||||||
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "Space Mono", monospace; font-size: 11px; color: #888; word-break: break-all;';
|
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "Roboto Condensed", "Arial Narrow", sans-serif; font-size: 11px; color: #888; word-break: break-all;';
|
||||||
infoEl.textContent = text;
|
infoEl.textContent = text;
|
||||||
output.insertBefore(infoEl, output.firstChild);
|
output.insertBefore(infoEl, output.firstChild);
|
||||||
}
|
}
|
||||||
@@ -5827,7 +5868,7 @@
|
|||||||
|
|
||||||
const errorEl = document.createElement('div');
|
const errorEl = document.createElement('div');
|
||||||
errorEl.className = 'error-msg';
|
errorEl.className = 'error-msg';
|
||||||
errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "Space Mono", monospace; font-size: 11px; color: #ff6688; word-break: break-all;';
|
errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "Roboto Condensed", "Arial Narrow", sans-serif; font-size: 11px; color: #ff6688; word-break: break-all;';
|
||||||
errorEl.textContent = '⚠ ' + text;
|
errorEl.textContent = '⚠ ' + text;
|
||||||
output.insertBefore(errorEl, output.firstChild);
|
output.insertBefore(errorEl, output.firstChild);
|
||||||
}
|
}
|
||||||
@@ -8481,7 +8522,7 @@
|
|||||||
|
|
||||||
// Draw total in center
|
// Draw total in center
|
||||||
ctx.fillStyle = '#fff';
|
ctx.fillStyle = '#fff';
|
||||||
ctx.font = 'bold 16px Space Mono';
|
ctx.font = 'bold 16px Roboto Condensed';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
ctx.fillText(total, cx, cy);
|
ctx.fillText(total, cx, cy);
|
||||||
@@ -10170,6 +10211,20 @@
|
|||||||
|
|
||||||
let gpsReconnectTimeout = null;
|
let gpsReconnectTimeout = null;
|
||||||
|
|
||||||
|
// GPS subscriber callbacks - modules can register to receive GPS stream data
|
||||||
|
const gpsStreamSubscribers = [];
|
||||||
|
|
||||||
|
function addGpsStreamSubscriber(fn) {
|
||||||
|
if (!gpsStreamSubscribers.includes(fn)) {
|
||||||
|
gpsStreamSubscribers.push(fn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeGpsStreamSubscriber(fn) {
|
||||||
|
const idx = gpsStreamSubscribers.indexOf(fn);
|
||||||
|
if (idx !== -1) gpsStreamSubscribers.splice(idx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
function startGpsStream() {
|
function startGpsStream() {
|
||||||
if (gpsEventSource) {
|
if (gpsEventSource) {
|
||||||
gpsEventSource.close();
|
gpsEventSource.close();
|
||||||
@@ -10187,6 +10242,8 @@
|
|||||||
gpsLastPosition = data;
|
gpsLastPosition = data;
|
||||||
updateLocationFromGps(data);
|
updateLocationFromGps(data);
|
||||||
}
|
}
|
||||||
|
// Dispatch to all subscribers (e.g. GPS mode UI)
|
||||||
|
gpsStreamSubscribers.forEach(fn => fn(data));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('GPS parse error:', e);
|
console.error('GPS parse error:', e);
|
||||||
}
|
}
|
||||||
@@ -10285,7 +10342,7 @@
|
|||||||
// Label
|
// Label
|
||||||
if (el > 0) {
|
if (el > 0) {
|
||||||
ctx.fillStyle = '#444';
|
ctx.fillStyle = '#444';
|
||||||
ctx.font = '10px Space Mono';
|
ctx.font = '10px Roboto Condensed';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.fillText(el + '°', cx, cy - r + 12);
|
ctx.fillText(el + '°', cx, cy - r + 12);
|
||||||
}
|
}
|
||||||
@@ -10358,7 +10415,7 @@
|
|||||||
|
|
||||||
// Label
|
// Label
|
||||||
ctx.fillStyle = '#fff';
|
ctx.fillStyle = '#fff';
|
||||||
ctx.font = '11px Space Mono';
|
ctx.font = '11px Roboto Condensed';
|
||||||
ctx.fillText(pass.satellite, maxX + 10, maxY - 5);
|
ctx.fillText(pass.satellite, maxX + 10, maxY - 5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15399,313 +15456,8 @@
|
|||||||
|
|
||||||
<!-- Scanner/Audio code moved to static/js/modes/listening-post.js -->
|
<!-- Scanner/Audio code moved to static/js/modes/listening-post.js -->
|
||||||
|
|
||||||
<!-- Help Modal -->
|
{% include 'partials/help-modal.html' %}
|
||||||
<div id="helpModal" class="help-modal" onclick="if(event.target === this) hideHelp()">
|
|
||||||
<div class="help-content">
|
|
||||||
<button class="help-close" onclick="hideHelp()">×</button>
|
|
||||||
<h2>iNTERCEPT Help</h2>
|
|
||||||
|
|
||||||
<div class="help-tabs">
|
|
||||||
<button class="help-tab active" data-tab="icons" onclick="switchHelpTab('icons')">Icons</button>
|
|
||||||
<button class="help-tab" data-tab="modes" onclick="switchHelpTab('modes')">Modes</button>
|
|
||||||
<button class="help-tab" data-tab="wifi" onclick="switchHelpTab('wifi')">WiFi</button>
|
|
||||||
<button class="help-tab" data-tab="tips" onclick="switchHelpTab('tips')">Tips</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Icons Section -->
|
|
||||||
<div id="help-icons" class="help-section active">
|
|
||||||
<h3>Stats Bar Icons</h3>
|
|
||||||
<div class="icon-grid">
|
|
||||||
<div class="icon-item"><span class="icon">📟</span><span class="desc">POCSAG messages decoded</span>
|
|
||||||
</div>
|
|
||||||
<div class="icon-item"><span class="icon">📠</span><span class="desc">FLEX messages decoded</span>
|
|
||||||
</div>
|
|
||||||
<div class="icon-item"><span class="icon">📨</span><span class="desc">Total messages received</span>
|
|
||||||
</div>
|
|
||||||
<div class="icon-item"><span class="icon">🌡️</span><span class="desc">Unique sensors
|
|
||||||
detected</span></div>
|
|
||||||
<div class="icon-item"><span class="icon">📊</span><span class="desc">Device types found</span>
|
|
||||||
</div>
|
|
||||||
<div class="icon-item"><span class="icon">🛰️</span><span class="desc">Satellites monitored</span>
|
|
||||||
</div>
|
|
||||||
<div class="icon-item"><span class="icon">📡</span><span class="desc">WiFi Access Points</span>
|
|
||||||
</div>
|
|
||||||
<div class="icon-item"><span class="icon">👤</span><span class="desc">Connected WiFi clients</span>
|
|
||||||
</div>
|
|
||||||
<div class="icon-item"><span class="icon">🤝</span><span class="desc">Captured handshakes</span>
|
|
||||||
</div>
|
|
||||||
<div class="icon-item"><span class="icon">🚁</span><span class="desc">Detected drones (click for
|
|
||||||
details)</span></div>
|
|
||||||
<div class="icon-item"><span class="icon">⚠️</span><span class="desc">Rogue APs (click for
|
|
||||||
details)</span></div>
|
|
||||||
<div class="icon-item"><span class="icon">🔵</span><span class="desc">Bluetooth devices</span></div>
|
|
||||||
<div class="icon-item"><span class="icon">📍</span><span class="desc">BLE beacons / APRS
|
|
||||||
stations</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Mode Tab Icons</h3>
|
|
||||||
<div class="icon-grid">
|
|
||||||
<div class="icon-item"><span class="icon">📟</span><span class="desc">Pager - POCSAG/FLEX
|
|
||||||
decoder</span></div>
|
|
||||||
<div class="icon-item"><span class="icon">📡</span><span class="desc">433MHz - Sensor decoder</span>
|
|
||||||
</div>
|
|
||||||
<div class="icon-item"><span class="icon">⚡</span><span class="desc">Meters - Utility meter decoder</span>
|
|
||||||
</div>
|
|
||||||
<div class="icon-item"><span class="icon">✈️</span><span class="desc">Aircraft - ADS-B tracking & history</span></div>
|
|
||||||
<div class="icon-item"><span class="icon">🚢</span><span class="desc">Vessels - AIS & VHF DSC distress</span></div>
|
|
||||||
<div class="icon-item"><span class="icon">📻</span><span class="desc">Spy Stations - Number stations database</span></div>
|
|
||||||
<div class="icon-item"><span class="icon">📍</span><span class="desc">APRS - Amateur radio
|
|
||||||
tracking</span></div>
|
|
||||||
<div class="icon-item"><span class="icon">🛰️</span><span class="desc">Satellite - Pass
|
|
||||||
prediction</span></div>
|
|
||||||
<div class="icon-item"><span class="icon">📶</span><span class="desc">WiFi - Network scanner</span>
|
|
||||||
</div>
|
|
||||||
<div class="icon-item"><span class="icon">🔵</span><span class="desc">Bluetooth - BT/BLE
|
|
||||||
scanner</span></div>
|
|
||||||
<div class="icon-item"><span class="icon">📻</span><span class="desc">Listening Post - SDR
|
|
||||||
scanner</span></div>
|
|
||||||
<div class="icon-item"><span class="icon">🔍</span><span class="desc">TSCM -
|
|
||||||
Counter-surveillance</span></div>
|
|
||||||
<div class="icon-item"><span class="icon">📺</span><span class="desc">ISS SSTV - Space station
|
|
||||||
images</span></div>
|
|
||||||
<div class="icon-item"><span class="icon">📺</span><span class="desc">HF SSTV - Terrestrial
|
|
||||||
SSTV images</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modes Section -->
|
|
||||||
<div id="help-modes" class="help-section">
|
|
||||||
<h3>Pager Mode</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li>Decodes POCSAG and FLEX pager signals using RTL-SDR</li>
|
|
||||||
<li>Set frequency to local pager frequencies (common: 152-158 MHz)</li>
|
|
||||||
<li>Messages are displayed in real-time as they're decoded</li>
|
|
||||||
<li>Use presets for common pager frequencies</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>433MHz Sensor Mode</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li>Decodes wireless sensors on 433.92 MHz ISM band</li>
|
|
||||||
<li>Detects temperature, humidity, weather stations, tire pressure monitors</li>
|
|
||||||
<li>Supports many common protocols (Acurite, LaCrosse, Oregon Scientific, etc.)</li>
|
|
||||||
<li>Device intelligence builds profiles of recurring devices</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Utility Meter Mode</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li>Decodes utility meter transmissions (water, gas, electric) using rtlamr</li>
|
|
||||||
<li>Supports ERT protocol on 912 MHz (North America) or 868 MHz (Europe)</li>
|
|
||||||
<li>Displays meter IDs and consumption data in real-time</li>
|
|
||||||
<li>Supports SCM, SCM+, IDM, NetIDM, and R900 message types</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Aircraft (Dashboard)</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li>Opens the dedicated ADS-B Dashboard for aircraft tracking</li>
|
|
||||||
<li>Features radar scope, map view, airband audio, and ACARS decoding</li>
|
|
||||||
<li>Optional history mode persists data to Postgres for long-term analysis</li>
|
|
||||||
<li>Access history dashboard at <code>/adsb/history</code></li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Vessels (Dashboard)</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li>Opens the AIS Dashboard for maritime vessel tracking</li>
|
|
||||||
<li>Displays vessel name, MMSI, callsign, destination, and navigation data</li>
|
|
||||||
<li><strong>VHF DSC Channel 70:</strong> Monitors maritime distress frequency (156.525 MHz)</li>
|
|
||||||
<li>Decodes DSC messages: Distress, Urgency, Safety, and Routine calls</li>
|
|
||||||
<li>MMSI country identification via Maritime Identification Digits (MID)</li>
|
|
||||||
<li>Visual alerts for DISTRESS and URGENCY messages with map markers</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Spy Stations</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li>Database of number stations and diplomatic HF networks</li>
|
|
||||||
<li>Browse stations from priyom.org with frequencies and schedules</li>
|
|
||||||
<li>Filter by type (number/diplomatic), country, and mode</li>
|
|
||||||
<li>Famous stations: UVB-76 "The Buzzer", Cuban HM01, Israeli E17z</li>
|
|
||||||
<li>Click "Tune" to listen via Listening Post mode</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>APRS Mode</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li>Decodes APRS (Automatic Packet Reporting System) on VHF</li>
|
|
||||||
<li>Tracks amateur radio operators transmitting position data</li>
|
|
||||||
<li>Regional frequencies: 144.390 MHz (N. America), 144.800 MHz (Europe)</li>
|
|
||||||
<li>Uses Direwolf or multimon-ng for packet decoding</li>
|
|
||||||
<li>Interactive map shows station positions in real-time</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Satellite Mode</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li>Track satellites using TLE (Two-Line Element) data</li>
|
|
||||||
<li>Add satellites manually or fetch from Celestrak by category</li>
|
|
||||||
<li>Categories: Amateur, Weather, ISS, Starlink, GPS, and more</li>
|
|
||||||
<li>View next pass predictions with elevation and duration</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>WiFi Mode</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li>Requires a WiFi adapter capable of monitor mode</li>
|
|
||||||
<li>Click "Enable Monitor" to put adapter in monitor mode</li>
|
|
||||||
<li>Scans all channels or lock to a specific channel</li>
|
|
||||||
<li>Detects drones by SSID patterns and manufacturer OUI</li>
|
|
||||||
<li>Rogue AP detection flags same SSID on multiple BSSIDs</li>
|
|
||||||
<li>Click network rows to target for deauth or handshake capture</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Bluetooth Mode</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li>Scans for classic Bluetooth and BLE devices</li>
|
|
||||||
<li>Shows device names, addresses, and signal strength</li>
|
|
||||||
<li>Manufacturer lookup from MAC address OUI</li>
|
|
||||||
<li>Radar visualization shows device proximity</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Listening Post Mode</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li>Wideband SDR scanner with spectrum visualization</li>
|
|
||||||
<li>Tune to any frequency supported by your SDR hardware</li>
|
|
||||||
<li>AM/FM/USB/LSB demodulation modes</li>
|
|
||||||
<li>Bookmark frequencies for quick recall</li>
|
|
||||||
<li>Quick tune presets for emergency and marine channels</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>TSCM Mode</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li>Technical Surveillance Countermeasures sweep</li>
|
|
||||||
<li>Scans for unknown RF transmitters, WiFi devices, Bluetooth</li>
|
|
||||||
<li>Baseline comparison to detect new/anomalous devices</li>
|
|
||||||
<li>Threat classification: Critical, High, Medium, Low</li>
|
|
||||||
<li>Useful for security audits and bug sweeps</li>
|
|
||||||
<li><em style="color: var(--text-muted);">Note: This feature is in early development</em></li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Meshtastic Mode</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li>Integrates with Meshtastic LoRa mesh network devices</li>
|
|
||||||
<li>Connect Heltec, T-Beam, RAK, or other compatible devices via USB</li>
|
|
||||||
<li>Real-time message streaming with RSSI and SNR metrics</li>
|
|
||||||
<li>Configure channels with encryption keys</li>
|
|
||||||
<li>View connected nodes and message history</li>
|
|
||||||
<li>Requires: Meshtastic device + <code>pip install meshtastic</code></li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>ISS SSTV Mode</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li>Decode Slow-Scan Television images from the International Space Station</li>
|
|
||||||
<li>ISS transmits on 145.800 MHz FM during special ARISS events</li>
|
|
||||||
<li>Real-time ISS tracking map with ground track overlay</li>
|
|
||||||
<li>Next-pass countdown with elevation and duration predictions</li>
|
|
||||||
<li>Optional Doppler shift compensation for improved reception</li>
|
|
||||||
<li>Requires: RTL-SDR (no external decoder needed - built-in Python SSTV decoder)</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>HF SSTV Mode</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li>Decode terrestrial SSTV images on HF/VHF/UHF amateur radio frequencies</li>
|
|
||||||
<li>Predefined frequencies: 14.230 MHz USB (20m, most popular), 3.845/7.171 MHz LSB, and more</li>
|
|
||||||
<li>Supports USB, LSB, and FM demodulation modes</li>
|
|
||||||
<li>Auto-detects correct modulation when selecting a preset frequency</li>
|
|
||||||
<li>HF frequencies (below 30 MHz) require an upconverter with RTL-SDR</li>
|
|
||||||
<li>Requires: RTL-SDR (+ upconverter for HF, no external decoder needed)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- WiFi Section -->
|
|
||||||
<div id="help-wifi" class="help-section">
|
|
||||||
<h3>Monitor Mode</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li><strong>Enable Monitor:</strong> Puts WiFi adapter in monitor mode for passive scanning</li>
|
|
||||||
<li><strong>Kill Processes:</strong> Optional - stops NetworkManager/wpa_supplicant (may drop other
|
|
||||||
connections)</li>
|
|
||||||
<li>Some adapters rename when entering monitor mode (e.g., wlan0 → wlan0mon)</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Handshake Capture</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li>Click "Capture" on a network to start targeted handshake capture</li>
|
|
||||||
<li>Status panel shows capture progress and file location</li>
|
|
||||||
<li>Use deauth to force clients to reconnect (only on authorized networks!)</li>
|
|
||||||
<li>Handshake files saved to /tmp/intercept_handshake_*.cap</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Drone Detection</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li>Drones detected by SSID patterns (DJI, Parrot, Autel, etc.)</li>
|
|
||||||
<li>Also detected by manufacturer OUI in MAC address</li>
|
|
||||||
<li>Distance estimated from signal strength (approximate)</li>
|
|
||||||
<li>Click drone count in stats bar to see all detected drones</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Rogue AP Detection</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li>Flags networks where same SSID appears on multiple BSSIDs</li>
|
|
||||||
<li>Could indicate evil twin attack or legitimate multi-AP setup</li>
|
|
||||||
<li>Click rogue count to see which SSIDs are flagged</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Proximity Alerts</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li>Add MAC addresses to watch list for alerts when detected</li>
|
|
||||||
<li>Watch list persists in browser localStorage</li>
|
|
||||||
<li>Useful for tracking specific devices</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Client Probe Analysis</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li>Shows what networks client devices are looking for</li>
|
|
||||||
<li>Orange highlights indicate sensitive/private network names</li>
|
|
||||||
<li>Reveals user location history (home, work, hotels, airports)</li>
|
|
||||||
<li>Useful for security awareness and pen test reports</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tips Section -->
|
|
||||||
<div id="help-tips" class="help-section">
|
|
||||||
<h3>General Tips</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li><strong>Collapsible sections:</strong> Click any section header (▼) to collapse/expand</li>
|
|
||||||
<li><strong>Sound alerts:</strong> Toggle sound on/off in settings for each mode</li>
|
|
||||||
<li><strong>Export data:</strong> Use export buttons to save captured data as JSON</li>
|
|
||||||
<li><strong>Device Intelligence:</strong> Tracks device patterns over time</li>
|
|
||||||
<li><strong>Theme toggle:</strong> Click the theme button in header to switch dark/light mode</li>
|
|
||||||
<li><strong>Settings:</strong> Click the gear icon in the header to access settings</li>
|
|
||||||
<li><strong>Offline mode:</strong> Enable in Settings to use local assets without internet</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Keyboard Shortcuts</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li><strong>F1</strong> - Open this help page</li>
|
|
||||||
<li><strong>?</strong> - Open help (when not typing in a field)</li>
|
|
||||||
<li><strong>Escape</strong> - Close help and modal dialogs</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Requirements</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li><strong>Pager:</strong> RTL-SDR, rtl_fm, multimon-ng</li>
|
|
||||||
<li><strong>433MHz Sensors:</strong> RTL-SDR, rtl_433</li>
|
|
||||||
<li><strong>Utility Meters:</strong> RTL-SDR, rtl_tcp, rtlamr</li>
|
|
||||||
<li><strong>Aircraft (ADS-B):</strong> RTL-SDR, dump1090 or rtl_adsb</li>
|
|
||||||
<li><strong>Aircraft (ACARS):</strong> Second RTL-SDR, acarsdec</li>
|
|
||||||
<li><strong>Vessels (AIS):</strong> RTL-SDR, AIS-catcher</li>
|
|
||||||
<li><strong>APRS:</strong> RTL-SDR, direwolf or multimon-ng</li>
|
|
||||||
<li><strong>Satellite:</strong> Internet for Celestrak (optional), skyfield</li>
|
|
||||||
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>
|
|
||||||
<li><strong>Bluetooth:</strong> Bluetooth adapter, bluez (hcitool/bluetoothctl)</li>
|
|
||||||
<li><strong>Listening Post:</strong> RTL-SDR or SoapySDR-compatible hardware</li>
|
|
||||||
<li><strong>TSCM:</strong> WiFi adapter, Bluetooth adapter, RTL-SDR (all optional)</li>
|
|
||||||
<li>Run as root/sudo for full hardware access</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Legal Notice</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li>Only use on networks and devices you own or have authorization to test</li>
|
|
||||||
<li>Passive monitoring may be legal; active attacks require authorization</li>
|
|
||||||
<li>Check local laws regarding radio frequency monitoring</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Satellite Add Modal -->
|
<!-- Satellite Add Modal -->
|
||||||
<div id="satModal" class="help-modal" onclick="if(event.target === this) closeSatModal()">
|
<div id="satModal" class="help-modal" onclick="if(event.target === this) closeSatModal()">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
{% if offline_settings and offline_settings.fonts_source == 'local' %}
|
{% if offline_settings and offline_settings.fonts_source == 'local' %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||||
{% else %}
|
{% else %}
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# Core CSS (Design System) #}
|
{# Core CSS (Design System) #}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
{% if offline_settings.fonts_source == 'local' %}
|
{% if offline_settings.fonts_source == 'local' %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||||
{% else %}
|
{% else %}
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
{% 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/agents.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/agents.css') }}">
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--font-sans: 'Space Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
--font-mono: 'Space Mono', 'Fira Code', 'Consolas', monospace;
|
--font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
--bg-primary: #0a0c10;
|
--bg-primary: #0a0c10;
|
||||||
--bg-secondary: #0f1218;
|
--bg-secondary: #0f1218;
|
||||||
--bg-tertiary: #151a23;
|
--bg-tertiary: #151a23;
|
||||||
|
|||||||
@@ -20,35 +20,43 @@
|
|||||||
<div id="help-icons" class="help-section active">
|
<div id="help-icons" class="help-section active">
|
||||||
<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">📟</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>
|
||||||
<div class="icon-item"><span class="icon">📠</span><span class="desc">FLEX 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">FLEX messages decoded</span></div>
|
||||||
<div class="icon-item"><span class="icon">📨</span><span class="desc">Total messages received</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg></span><span class="desc">Total messages received</span></div>
|
||||||
<div class="icon-item"><span class="icon">🌡️</span><span class="desc">Unique sensors detected</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><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></span><span class="desc">Unique sensors detected</span></div>
|
||||||
<div class="icon-item"><span class="icon">📊</span><span class="desc">Device types found</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg></span><span class="desc">Device types found</span></div>
|
||||||
<div class="icon-item"><span class="icon">🛰️</span><span class="desc">Satellites monitored</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><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></span><span class="desc">Satellites monitored</span></div>
|
||||||
<div class="icon-item"><span class="icon">📡</span><span class="desc">WiFi Access Points</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><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></span><span class="desc">WiFi Access Points</span></div>
|
||||||
<div class="icon-item"><span class="icon">👤</span><span class="desc">Connected WiFi clients</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></span><span class="desc">Connected WiFi clients</span></div>
|
||||||
<div class="icon-item"><span class="icon">🤝</span><span class="desc">Captured handshakes</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m11 17 2 2a1 1 0 1 0 3-3"/><path d="m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.88-3.88a3 3 0 0 0-4.24 0l-.88.88a1 1 0 1 1-3-3l2.81-2.81a5.79 5.79 0 0 1 7.06-.87l.47.28a2 2 0 0 0 1.42.25L21 4"/><path d="m21 3 1 11h-2"/><path d="M3 3 2 14l6.5 6.5a1 1 0 1 0 3-3"/><path d="M3 4h8"/></svg></span><span class="desc">Captured handshakes</span></div>
|
||||||
<div class="icon-item"><span class="icon">🚁</span><span class="desc">Detected drones (click for details)</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/><path d="M3 9a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M17 9a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M3 15a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M17 15a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M9 9l-4 -1"/><path d="M15 9l4 -1"/><path d="M9 15l-4 1"/><path d="M15 15l4 1"/></svg></span><span class="desc">Detected drones (click for details)</span></div>
|
||||||
<div class="icon-item"><span class="icon">⚠️</span><span class="desc">Rogue APs (click for details)</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span><span class="desc">Rogue APs (click for details)</span></div>
|
||||||
<div class="icon-item"><span class="icon">🔵</span><span class="desc">Bluetooth devices</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><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></span><span class="desc">Bluetooth devices</span></div>
|
||||||
<div class="icon-item"><span class="icon">📍</span><span class="desc">BLE beacons / APRS stations</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><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></span><span class="desc">BLE beacons / APRS stations</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Mode Tab Icons</h3>
|
<h3>Mode Tab Icons</h3>
|
||||||
<div class="icon-grid">
|
<div class="icon-grid">
|
||||||
<div class="icon-item"><span class="icon">📟</span><span class="desc">Pager - POCSAG/FLEX decoder</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="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">Pager - POCSAG/FLEX decoder</span></div>
|
||||||
<div class="icon-item"><span class="icon">📡</span><span class="desc">433MHz - Sensor decoder</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="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></span><span class="desc">433MHz - Sensor decoder</span></div>
|
||||||
<div class="icon-item"><span class="icon">⚡</span><span class="desc">Meters - Utility meter decoder</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></span><span class="desc">Meters - Utility meter decoder</span></div>
|
||||||
<div class="icon-item"><span class="icon">✈️</span><span class="desc">Aircraft - ADS-B tracking & history</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span><span class="desc">Aircraft - ADS-B tracking & history</span></div>
|
||||||
<div class="icon-item"><span class="icon">🚢</span><span class="desc">Vessels - AIS & VHF DSC distress</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg></span><span class="desc">Vessels - AIS & VHF DSC distress</span></div>
|
||||||
<div class="icon-item"><span class="icon">📻</span><span class="desc">Spy Stations - Number stations database</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span><span class="desc">APRS - Amateur radio tracking</span></div>
|
||||||
<div class="icon-item"><span class="icon">📍</span><span class="desc">APRS - Amateur radio tracking</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span><span class="desc">Listening Post - SDR scanner</span></div>
|
||||||
<div class="icon-item"><span class="icon">🛰️</span><span class="desc">Satellite - Pass prediction</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span><span class="desc">Spy Stations - Number stations database</span></div>
|
||||||
<div class="icon-item"><span class="icon">📶</span><span class="desc">WiFi - Network scanner</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span><span class="desc">Meshtastic - LoRa mesh networking</span></div>
|
||||||
<div class="icon-item"><span class="icon">🔵</span><span class="desc">Bluetooth - BT/BLE scanner</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span><span class="desc">WebSDR - Remote SDR receivers</span></div>
|
||||||
<div class="icon-item"><span class="icon">📻</span><span class="desc">Listening Post - SDR scanner</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg></span><span class="desc">SubGHz - Sub-GHz signal analysis</span></div>
|
||||||
<div class="icon-item"><span class="icon">🔍</span><span class="desc">TSCM - Counter-surveillance</span></div>
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="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="desc">WiFi - Network scanner</span></div>
|
||||||
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span><span class="desc">Bluetooth - BT/BLE scanner</span></div>
|
||||||
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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></span><span class="desc">BT Locate - Bluetooth device locator</span></div>
|
||||||
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span><span class="desc">TSCM - Counter-surveillance</span></div>
|
||||||
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="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"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg></span><span class="desc">Satellite - Pass prediction</span></div>
|
||||||
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg></span><span class="desc">ISS SSTV - Space station image decoder</span></div>
|
||||||
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><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></span><span class="desc">Weather Sat - NOAA & Meteor imagery</span></div>
|
||||||
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><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></span><span class="desc">HF SSTV - Shortwave image decoder</span></div>
|
||||||
|
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="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></span><span class="desc">GPS - GNSS signal analysis</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -96,15 +104,6 @@
|
|||||||
<li>Visual alerts for DISTRESS and URGENCY messages with map markers</li>
|
<li>Visual alerts for DISTRESS and URGENCY messages with map markers</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Spy Stations</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li>Database of number stations and diplomatic HF networks</li>
|
|
||||||
<li>Browse stations from priyom.org with frequencies and schedules</li>
|
|
||||||
<li>Filter by type (number/diplomatic), country, and mode</li>
|
|
||||||
<li>Famous stations: UVB-76 "The Buzzer", Cuban HM01, Israeli E17z</li>
|
|
||||||
<li>Click "Tune" to listen via Listening Post mode</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>APRS Mode</h3>
|
<h3>APRS Mode</h3>
|
||||||
<ul class="tip-list">
|
<ul class="tip-list">
|
||||||
<li>Decodes APRS (Automatic Packet Reporting System) on VHF</li>
|
<li>Decodes APRS (Automatic Packet Reporting System) on VHF</li>
|
||||||
@@ -114,6 +113,50 @@
|
|||||||
<li>Interactive map shows station positions in real-time</li>
|
<li>Interactive map shows station positions in real-time</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<h3>Listening Post Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Wideband SDR scanner with spectrum visualization</li>
|
||||||
|
<li>Tune to any frequency supported by your SDR hardware</li>
|
||||||
|
<li>AM/FM/USB/LSB demodulation modes</li>
|
||||||
|
<li>Bookmark frequencies for quick recall</li>
|
||||||
|
<li>Quick tune presets for emergency and marine channels</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Spy Stations</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Database of number stations and diplomatic HF networks</li>
|
||||||
|
<li>Browse stations from priyom.org with frequencies and schedules</li>
|
||||||
|
<li>Filter by type (number/diplomatic), country, and mode</li>
|
||||||
|
<li>Famous stations: UVB-76 "The Buzzer", Cuban HM01, Israeli E17z</li>
|
||||||
|
<li>Click "Tune" to listen via Listening Post mode</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Meshtastic Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Integrates with Meshtastic LoRa mesh network devices</li>
|
||||||
|
<li>Connect Heltec, T-Beam, RAK, or other compatible devices via USB</li>
|
||||||
|
<li>Real-time message streaming with RSSI and SNR metrics</li>
|
||||||
|
<li>Configure channels with encryption keys</li>
|
||||||
|
<li>View connected nodes and message history</li>
|
||||||
|
<li>Requires: Meshtastic device + <code>pip install meshtastic</code></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>WebSDR Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Access remote WebSDR receivers around the world</li>
|
||||||
|
<li>Listen to shortwave, amateur, and broadcast stations without local SDR hardware</li>
|
||||||
|
<li>Browse available public WebSDR servers by location and frequency range</li>
|
||||||
|
<li>Useful for monitoring HF bands from different geographic locations</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>SubGHz Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Analyzes sub-GHz radio signals (common ISM bands: 315, 433, 868, 915 MHz)</li>
|
||||||
|
<li>Captures and decodes wireless remote controls, key fobs, and IoT devices</li>
|
||||||
|
<li>Protocol identification and signal analysis</li>
|
||||||
|
<li>Useful for RF security research and device testing</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<h3>Satellite Mode</h3>
|
<h3>Satellite Mode</h3>
|
||||||
<ul class="tip-list">
|
<ul class="tip-list">
|
||||||
<li>Track satellites using TLE (Two-Line Element) data</li>
|
<li>Track satellites using TLE (Two-Line Element) data</li>
|
||||||
@@ -122,6 +165,38 @@
|
|||||||
<li>View next pass predictions with elevation and duration</li>
|
<li>View next pass predictions with elevation and duration</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<h3>ISS SSTV Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Decodes Slow Scan Television (SSTV) images from the International Space Station</li>
|
||||||
|
<li>Automated ISS pass tracking with Doppler correction on 145.800 MHz</li>
|
||||||
|
<li>Images decoded in real-time using slowrx</li>
|
||||||
|
<li>Gallery view with timestamped decoded images</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Weather Sat Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Receives weather satellite imagery from NOAA APT and Meteor M2 LRPT</li>
|
||||||
|
<li>Uses SatDump for satellite signal processing and image decoding</li>
|
||||||
|
<li>Automated pass prediction and scheduling</li>
|
||||||
|
<li>Decoded images displayed in gallery with pass metadata</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>HF SSTV Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Decodes Slow Scan Television images from HF amateur radio bands</li>
|
||||||
|
<li>Common SSTV frequencies: 14.230 MHz (20m), 7.171 MHz (40m), 3.730 MHz (80m)</li>
|
||||||
|
<li>Supports multiple SSTV modes (Martin, Scottie, Robot, etc.)</li>
|
||||||
|
<li>Real-time image decoding with gallery view</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>GPS Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>GNSS signal analysis and satellite constellation tracking</li>
|
||||||
|
<li>Displays GPS, GLONASS, Galileo, and BeiDou satellite positions</li>
|
||||||
|
<li>Signal strength visualization and fix quality metrics</li>
|
||||||
|
<li>Useful for evaluating GNSS reception and interference</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<h3>WiFi Mode</h3>
|
<h3>WiFi Mode</h3>
|
||||||
<ul class="tip-list">
|
<ul class="tip-list">
|
||||||
<li>Requires a WiFi adapter capable of monitor mode</li>
|
<li>Requires a WiFi adapter capable of monitor mode</li>
|
||||||
@@ -140,13 +215,12 @@
|
|||||||
<li>Radar visualization shows device proximity</li>
|
<li>Radar visualization shows device proximity</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Listening Post Mode</h3>
|
<h3>BT Locate Mode</h3>
|
||||||
<ul class="tip-list">
|
<ul class="tip-list">
|
||||||
<li>Wideband SDR scanner with spectrum visualization</li>
|
<li>Locate and track specific Bluetooth devices by signal strength</li>
|
||||||
<li>Tune to any frequency supported by your SDR hardware</li>
|
<li>Directional signal strength indicator for physical device finding</li>
|
||||||
<li>AM/FM/USB/LSB demodulation modes</li>
|
<li>Detects known tracker signatures (AirTag, Tile, SmartTag)</li>
|
||||||
<li>Bookmark frequencies for quick recall</li>
|
<li>Useful for finding lost devices or detecting unwanted trackers</li>
|
||||||
<li>Quick tune presets for emergency and marine channels</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>TSCM Mode</h3>
|
<h3>TSCM Mode</h3>
|
||||||
@@ -159,16 +233,6 @@
|
|||||||
<li><em style="color: var(--text-muted);">Note: This feature is in early development</em></li>
|
<li><em style="color: var(--text-muted);">Note: This feature is in early development</em></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Meshtastic Mode</h3>
|
|
||||||
<ul class="tip-list">
|
|
||||||
<li>Integrates with Meshtastic LoRa mesh network devices</li>
|
|
||||||
<li>Connect Heltec, T-Beam, RAK, or other compatible devices via USB</li>
|
|
||||||
<li>Real-time message streaming with RSSI and SNR metrics</li>
|
|
||||||
<li>Configure channels with encryption keys</li>
|
|
||||||
<li>View connected nodes and message history</li>
|
|
||||||
<li>Requires: Meshtastic device + <code>pip install meshtastic</code></li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Network Monitor</h3>
|
<h3>Network Monitor</h3>
|
||||||
<ul class="tip-list">
|
<ul class="tip-list">
|
||||||
<li>Aggregates data from multiple remote INTERCEPT agents</li>
|
<li>Aggregates data from multiple remote INTERCEPT agents</li>
|
||||||
@@ -256,10 +320,19 @@
|
|||||||
<li><strong>Aircraft (ACARS):</strong> Second RTL-SDR, acarsdec</li>
|
<li><strong>Aircraft (ACARS):</strong> Second RTL-SDR, acarsdec</li>
|
||||||
<li><strong>Vessels (AIS):</strong> RTL-SDR, AIS-catcher</li>
|
<li><strong>Vessels (AIS):</strong> RTL-SDR, AIS-catcher</li>
|
||||||
<li><strong>APRS:</strong> RTL-SDR, direwolf or multimon-ng</li>
|
<li><strong>APRS:</strong> RTL-SDR, direwolf or multimon-ng</li>
|
||||||
|
<li><strong>Listening Post:</strong> RTL-SDR or SoapySDR-compatible hardware</li>
|
||||||
|
<li><strong>Spy Stations:</strong> Internet connection (database lookup)</li>
|
||||||
|
<li><strong>Meshtastic:</strong> Meshtastic LoRa device, <code>pip install meshtastic</code></li>
|
||||||
|
<li><strong>WebSDR:</strong> Internet connection (remote receivers)</li>
|
||||||
|
<li><strong>SubGHz:</strong> RTL-SDR or compatible SDR hardware</li>
|
||||||
<li><strong>Satellite:</strong> Internet for Celestrak (optional), skyfield</li>
|
<li><strong>Satellite:</strong> Internet for Celestrak (optional), skyfield</li>
|
||||||
|
<li><strong>ISS SSTV:</strong> RTL-SDR, slowrx</li>
|
||||||
|
<li><strong>Weather Sat:</strong> RTL-SDR, SatDump</li>
|
||||||
|
<li><strong>HF SSTV:</strong> RTL-SDR or SoapySDR-compatible hardware, slowrx</li>
|
||||||
|
<li><strong>GPS:</strong> RTL-SDR or GPS-capable SDR</li>
|
||||||
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>
|
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>
|
||||||
<li><strong>Bluetooth:</strong> Bluetooth adapter, bluez (hcitool/bluetoothctl)</li>
|
<li><strong>Bluetooth:</strong> Bluetooth adapter, bluez (hcitool/bluetoothctl)</li>
|
||||||
<li><strong>Listening Post:</strong> RTL-SDR or SoapySDR-compatible hardware</li>
|
<li><strong>BT Locate:</strong> Bluetooth adapter, bluez</li>
|
||||||
<li><strong>TSCM:</strong> WiFi adapter, Bluetooth adapter, RTL-SDR (all optional)</li>
|
<li><strong>TSCM:</strong> WiFi adapter, Bluetooth adapter, RTL-SDR (all optional)</li>
|
||||||
<li>Run as root/sudo for full hardware access</li>
|
<li>Run as root/sudo for full hardware access</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
<!-- ACARS AIRCRAFT MESSAGING MODE -->
|
||||||
|
<div id="acarsMode" class="mode-content" style="display: none;">
|
||||||
|
<div class="section">
|
||||||
|
<h3>ACARS Messaging</h3>
|
||||||
|
<div class="info-text" style="margin-bottom: 15px;">
|
||||||
|
Decode ACARS (Aircraft Communications Addressing and Reporting System) messages on VHF frequencies (~129-131 MHz). Captures flight data, weather reports, position updates, and operational messages from aircraft.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Region & Frequencies</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Region</label>
|
||||||
|
<select id="acarsRegionSelect" onchange="updateAcarsMainFreqs()" style="width: 100%;">
|
||||||
|
<option value="na">North America</option>
|
||||||
|
<option value="eu">Europe</option>
|
||||||
|
<option value="ap">Asia-Pacific</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="acarsMainFreqSelector" style="display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; font-size: 11px;">
|
||||||
|
<!-- Populated by JS -->
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Gain (dB, 0 = auto)</label>
|
||||||
|
<input type="number" id="acarsGainInput" value="40" min="0" max="50" placeholder="0-50">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Status</h3>
|
||||||
|
<div id="acarsStatusDisplay" class="info-text">
|
||||||
|
<p>Status: <span id="acarsStatusText" style="color: var(--accent-yellow);">Standby</span></p>
|
||||||
|
<p>Messages: <span id="acarsMessageCount">0</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Antenna Guide -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>Antenna Guide</h3>
|
||||||
|
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||||
|
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||||
|
VHF Airband (~130 MHz) — stock SDR antenna may work at close range
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Simple Dipole</strong>
|
||||||
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||||
|
<li><strong style="color: var(--text-primary);">Element length:</strong> ~57 cm each (quarter-wave at 130 MHz)</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Material:</strong> Wire, coat hanger, or copper rod</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Orientation:</strong> Vertical (airband is vertically polarized)</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Placement:</strong> Outdoors, as high as possible with clear sky view</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
||||||
|
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Primary (worldwide)</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">131.550 MHz</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">57 cm</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">AM MSK 2400 baud</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="run-btn" id="startAcarsBtn" onclick="startAcarsMode()">
|
||||||
|
Start ACARS
|
||||||
|
</button>
|
||||||
|
<button class="stop-btn" id="stopAcarsBtn" onclick="stopAcarsMode()" style="display: none;">
|
||||||
|
Stop ACARS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let acarsMainEventSource = null;
|
||||||
|
let acarsMainMsgCount = 0;
|
||||||
|
|
||||||
|
const acarsMainFrequencies = {
|
||||||
|
'na': ['129.125', '130.025', '130.450', '131.550'],
|
||||||
|
'eu': ['131.525', '131.725', '131.550'],
|
||||||
|
'ap': ['131.550', '131.450']
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateAcarsMainFreqs() {
|
||||||
|
const region = document.getElementById('acarsRegionSelect').value;
|
||||||
|
const freqs = acarsMainFrequencies[region] || acarsMainFrequencies['na'];
|
||||||
|
const container = document.getElementById('acarsMainFreqSelector');
|
||||||
|
|
||||||
|
const previouslyChecked = new Set();
|
||||||
|
container.querySelectorAll('input:checked').forEach(cb => previouslyChecked.add(cb.value));
|
||||||
|
|
||||||
|
container.innerHTML = freqs.map(freq => {
|
||||||
|
const checked = previouslyChecked.size === 0 || previouslyChecked.has(freq) ? 'checked' : '';
|
||||||
|
return `
|
||||||
|
<label style="display: flex; align-items: center; gap: 3px; padding: 2px 6px; background: var(--bg-secondary); border-radius: 3px; cursor: pointer;">
|
||||||
|
<input type="checkbox" class="acars-main-freq-cb" value="${freq}" ${checked} style="margin: 0; cursor: pointer;">
|
||||||
|
<span>${freq}</span>
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAcarsMainSelectedFreqs() {
|
||||||
|
const checkboxes = document.querySelectorAll('.acars-main-freq-cb:checked');
|
||||||
|
const selected = Array.from(checkboxes).map(cb => cb.value);
|
||||||
|
if (selected.length === 0) {
|
||||||
|
const region = document.getElementById('acarsRegionSelect').value;
|
||||||
|
return acarsMainFrequencies[region] || acarsMainFrequencies['na'];
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAcarsMode() {
|
||||||
|
const gain = document.getElementById('acarsGainInput').value || '40';
|
||||||
|
const device = document.getElementById('deviceSelect')?.value || '0';
|
||||||
|
const frequencies = getAcarsMainSelectedFreqs();
|
||||||
|
|
||||||
|
fetch('/acars/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ device, gain, frequencies })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'started') {
|
||||||
|
document.getElementById('startAcarsBtn').style.display = 'none';
|
||||||
|
document.getElementById('stopAcarsBtn').style.display = 'block';
|
||||||
|
document.getElementById('acarsStatusText').textContent = 'Listening';
|
||||||
|
document.getElementById('acarsStatusText').style.color = 'var(--accent-green)';
|
||||||
|
acarsMainMsgCount = 0;
|
||||||
|
startAcarsMainSSE();
|
||||||
|
} else {
|
||||||
|
alert(data.message || 'Failed to start ACARS');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => alert('Error: ' + err.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAcarsMode() {
|
||||||
|
fetch('/acars/stop', { method: 'POST' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => {
|
||||||
|
document.getElementById('startAcarsBtn').style.display = 'block';
|
||||||
|
document.getElementById('stopAcarsBtn').style.display = 'none';
|
||||||
|
document.getElementById('acarsStatusText').textContent = 'Standby';
|
||||||
|
document.getElementById('acarsStatusText').style.color = 'var(--accent-yellow)';
|
||||||
|
if (acarsMainEventSource) {
|
||||||
|
acarsMainEventSource.close();
|
||||||
|
acarsMainEventSource = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAcarsMainSSE() {
|
||||||
|
if (acarsMainEventSource) acarsMainEventSource.close();
|
||||||
|
|
||||||
|
acarsMainEventSource = new EventSource('/acars/stream');
|
||||||
|
acarsMainEventSource.onmessage = function(e) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data.type === 'acars') {
|
||||||
|
acarsMainMsgCount++;
|
||||||
|
document.getElementById('acarsMessageCount').textContent = acarsMainMsgCount;
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
acarsMainEventSource.onerror = function() {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (document.getElementById('stopAcarsBtn').style.display === 'block') {
|
||||||
|
startAcarsMainSSE();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check initial status
|
||||||
|
fetch('/acars/status')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.running) {
|
||||||
|
document.getElementById('startAcarsBtn').style.display = 'none';
|
||||||
|
document.getElementById('stopAcarsBtn').style.display = 'block';
|
||||||
|
document.getElementById('acarsStatusText').textContent = 'Listening';
|
||||||
|
document.getElementById('acarsStatusText').style.color = 'var(--accent-green)';
|
||||||
|
document.getElementById('acarsMessageCount').textContent = data.message_count || 0;
|
||||||
|
acarsMainMsgCount = data.message_count || 0;
|
||||||
|
startAcarsMainSSE();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
// Initialize frequency checkboxes
|
||||||
|
document.addEventListener('DOMContentLoaded', () => updateAcarsMainFreqs());
|
||||||
|
</script>
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
<option value="bleak">Bleak Library</option>
|
<option value="bleak">Bleak Library</option>
|
||||||
<option value="hcitool">hcitool (Linux)</option>
|
<option value="hcitool">hcitool (Linux)</option>
|
||||||
<option value="bluetoothctl">bluetoothctl (Linux)</option>
|
<option value="bluetoothctl">bluetoothctl (Linux)</option>
|
||||||
|
<option value="ubertooth" id="btScanModeUbertooth" style="display:none;">Ubertooth One</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-top: 8px;">
|
<div class="form-group" style="margin-top: 8px;">
|
||||||
<label>Device Serial <span style="color: var(--text-dim); font-weight: normal;">(optional)</span></label>
|
<label>Device Serial <span style="color: var(--text-dim); font-weight: normal;">(optional)</span></label>
|
||||||
<input type="text" id="subghzDeviceSerial" placeholder="auto-detect" style="font-family: 'JetBrains Mono', monospace; font-size: 11px;">
|
<input type="text" id="subghzDeviceSerial" placeholder="auto-detect" style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 11px;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -55,12 +55,12 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>LNA Gain (0-40 dB)</label>
|
<label>LNA Gain (0-40 dB)</label>
|
||||||
<input type="range" id="subghzLnaGain" min="0" max="40" value="24" step="8" oninput="document.getElementById('subghzLnaVal').textContent=this.value">
|
<input type="range" id="subghzLnaGain" min="0" max="40" value="24" step="8" oninput="document.getElementById('subghzLnaVal').textContent=this.value">
|
||||||
<span id="subghzLnaVal" style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-secondary);">24</span>
|
<span id="subghzLnaVal" style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 11px; color: var(--text-secondary);">24</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>VGA Gain (0-62 dB)</label>
|
<label>VGA Gain (0-62 dB)</label>
|
||||||
<input type="range" id="subghzVgaGain" min="0" max="62" value="20" step="2" oninput="document.getElementById('subghzVgaVal').textContent=this.value">
|
<input type="range" id="subghzVgaGain" min="0" max="62" value="20" step="2" oninput="document.getElementById('subghzVgaVal').textContent=this.value">
|
||||||
<span id="subghzVgaVal" style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-secondary);">20</span>
|
<span id="subghzVgaVal" style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 11px; color: var(--text-secondary);">20</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Sample Rate</label>
|
<label>Sample Rate</label>
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>TX VGA Gain (0-47 dB)</label>
|
<label>TX VGA Gain (0-47 dB)</label>
|
||||||
<input type="range" id="subghzTxGain" min="0" max="47" value="20" step="1" oninput="document.getElementById('subghzTxGainVal').textContent=this.value">
|
<input type="range" id="subghzTxGain" min="0" max="47" value="20" step="1" oninput="document.getElementById('subghzTxGainVal').textContent=this.value">
|
||||||
<span id="subghzTxGainVal" style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-secondary);">20</span>
|
<span id="subghzTxGainVal" style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 11px; color: var(--text-secondary);">20</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Max Duration (seconds)</label>
|
<label>Max Duration (seconds)</label>
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<!-- VDL2 AIRCRAFT DATALINK MODE -->
|
||||||
|
<div id="vdl2Mode" class="mode-content" style="display: none;">
|
||||||
|
<div class="section">
|
||||||
|
<h3>VDL2 Datalink</h3>
|
||||||
|
<div class="info-text" style="margin-bottom: 15px;">
|
||||||
|
Decode VDL Mode 2 (VHF Digital Link) messages on ~136 MHz. VDL2 is the digital successor to ACARS, using D8PSK modulation for higher throughput aircraft datalink communications.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Region & Frequencies</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Region</label>
|
||||||
|
<select id="vdl2RegionSelect" onchange="updateVdl2MainFreqs()" style="width: 100%;">
|
||||||
|
<option value="na">North America</option>
|
||||||
|
<option value="eu">Europe</option>
|
||||||
|
<option value="ap">Asia-Pacific</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="vdl2MainFreqSelector" style="display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; font-size: 11px;">
|
||||||
|
<!-- Populated by JS -->
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Gain (dB, 0 = auto)</label>
|
||||||
|
<input type="number" id="vdl2GainInput" value="40" min="0" max="50" placeholder="0-50">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Status</h3>
|
||||||
|
<div id="vdl2StatusDisplay" class="info-text">
|
||||||
|
<p>Status: <span id="vdl2StatusText" style="color: var(--accent-yellow);">Standby</span></p>
|
||||||
|
<p>Messages: <span id="vdl2MessageCount">0</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Antenna Guide -->
|
||||||
|
<div class="section">
|
||||||
|
<h3>Antenna Guide</h3>
|
||||||
|
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||||
|
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
||||||
|
VHF Airband (~137 MHz) — stock SDR antenna may work at close range
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Simple Dipole</strong>
|
||||||
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||||
|
<li><strong style="color: var(--text-primary);">Element length:</strong> ~55 cm each (quarter-wave at 137 MHz)</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Material:</strong> Wire, coat hanger, or copper rod</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Orientation:</strong> Vertical (airband is vertically polarized)</li>
|
||||||
|
<li><strong style="color: var(--text-primary);">Placement:</strong> Outdoors, as high as possible with clear sky view</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||||
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
||||||
|
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Primary (worldwide)</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">136.975 MHz</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">55 cm</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Modulation</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">D8PSK 31.5 kbps</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Bandwidth</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">25 kHz</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Vertical</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="run-btn" id="startVdl2Btn" onclick="startVdl2Mode()">
|
||||||
|
Start VDL2
|
||||||
|
</button>
|
||||||
|
<button class="stop-btn" id="stopVdl2Btn" onclick="stopVdl2Mode()" style="display: none;">
|
||||||
|
Stop VDL2
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let vdl2MainEventSource = null;
|
||||||
|
let vdl2MainMsgCount = 0;
|
||||||
|
|
||||||
|
// VDL2 frequencies in Hz (as required by dumpvdl2)
|
||||||
|
const vdl2MainFrequencies = {
|
||||||
|
'na': ['136975000', '136100000', '136650000', '136700000', '136800000'],
|
||||||
|
'eu': ['136975000', '136675000', '136725000', '136775000', '136825000'],
|
||||||
|
'ap': ['136975000', '136900000']
|
||||||
|
};
|
||||||
|
|
||||||
|
// Display-friendly MHz labels
|
||||||
|
const vdl2FreqLabels = {
|
||||||
|
'136975000': '136.975',
|
||||||
|
'136100000': '136.100',
|
||||||
|
'136650000': '136.650',
|
||||||
|
'136700000': '136.700',
|
||||||
|
'136800000': '136.800',
|
||||||
|
'136675000': '136.675',
|
||||||
|
'136725000': '136.725',
|
||||||
|
'136775000': '136.775',
|
||||||
|
'136825000': '136.825',
|
||||||
|
'136900000': '136.900'
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateVdl2MainFreqs() {
|
||||||
|
const region = document.getElementById('vdl2RegionSelect').value;
|
||||||
|
const freqs = vdl2MainFrequencies[region] || vdl2MainFrequencies['na'];
|
||||||
|
const container = document.getElementById('vdl2MainFreqSelector');
|
||||||
|
|
||||||
|
const previouslyChecked = new Set();
|
||||||
|
container.querySelectorAll('input:checked').forEach(cb => previouslyChecked.add(cb.value));
|
||||||
|
|
||||||
|
container.innerHTML = freqs.map(freq => {
|
||||||
|
const checked = previouslyChecked.size === 0 || previouslyChecked.has(freq) ? 'checked' : '';
|
||||||
|
const label = vdl2FreqLabels[freq] || freq;
|
||||||
|
return `
|
||||||
|
<label style="display: flex; align-items: center; gap: 3px; padding: 2px 6px; background: var(--bg-secondary); border-radius: 3px; cursor: pointer;">
|
||||||
|
<input type="checkbox" class="vdl2-main-freq-cb" value="${freq}" ${checked} style="margin: 0; cursor: pointer;">
|
||||||
|
<span>${label}</span>
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVdl2MainSelectedFreqs() {
|
||||||
|
const checkboxes = document.querySelectorAll('.vdl2-main-freq-cb:checked');
|
||||||
|
const selected = Array.from(checkboxes).map(cb => cb.value);
|
||||||
|
if (selected.length === 0) {
|
||||||
|
const region = document.getElementById('vdl2RegionSelect').value;
|
||||||
|
return vdl2MainFrequencies[region] || vdl2MainFrequencies['na'];
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startVdl2Mode() {
|
||||||
|
const gain = document.getElementById('vdl2GainInput').value || '40';
|
||||||
|
const device = document.getElementById('deviceSelect')?.value || '0';
|
||||||
|
const frequencies = getVdl2MainSelectedFreqs();
|
||||||
|
|
||||||
|
fetch('/vdl2/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ device, gain, frequencies })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'started') {
|
||||||
|
document.getElementById('startVdl2Btn').style.display = 'none';
|
||||||
|
document.getElementById('stopVdl2Btn').style.display = 'block';
|
||||||
|
document.getElementById('vdl2StatusText').textContent = 'Listening';
|
||||||
|
document.getElementById('vdl2StatusText').style.color = 'var(--accent-green)';
|
||||||
|
vdl2MainMsgCount = 0;
|
||||||
|
startVdl2MainSSE();
|
||||||
|
} else {
|
||||||
|
alert(data.message || 'Failed to start VDL2');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => alert('Error: ' + err.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopVdl2Mode() {
|
||||||
|
fetch('/vdl2/stop', { method: 'POST' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => {
|
||||||
|
document.getElementById('startVdl2Btn').style.display = 'block';
|
||||||
|
document.getElementById('stopVdl2Btn').style.display = 'none';
|
||||||
|
document.getElementById('vdl2StatusText').textContent = 'Standby';
|
||||||
|
document.getElementById('vdl2StatusText').style.color = 'var(--accent-yellow)';
|
||||||
|
if (vdl2MainEventSource) {
|
||||||
|
vdl2MainEventSource.close();
|
||||||
|
vdl2MainEventSource = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startVdl2MainSSE() {
|
||||||
|
if (vdl2MainEventSource) vdl2MainEventSource.close();
|
||||||
|
|
||||||
|
vdl2MainEventSource = new EventSource('/vdl2/stream');
|
||||||
|
vdl2MainEventSource.onmessage = function(e) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data.type === 'vdl2') {
|
||||||
|
vdl2MainMsgCount++;
|
||||||
|
document.getElementById('vdl2MessageCount').textContent = vdl2MainMsgCount;
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
vdl2MainEventSource.onerror = function() {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (document.getElementById('stopVdl2Btn').style.display === 'block') {
|
||||||
|
startVdl2MainSSE();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check initial status
|
||||||
|
fetch('/vdl2/status')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.running) {
|
||||||
|
document.getElementById('startVdl2Btn').style.display = 'none';
|
||||||
|
document.getElementById('stopVdl2Btn').style.display = 'block';
|
||||||
|
document.getElementById('vdl2StatusText').textContent = 'Listening';
|
||||||
|
document.getElementById('vdl2StatusText').style.color = 'var(--accent-green)';
|
||||||
|
document.getElementById('vdl2MessageCount').textContent = data.message_count || 0;
|
||||||
|
vdl2MainMsgCount = data.message_count || 0;
|
||||||
|
startVdl2MainSSE();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
// Initialize frequency checkboxes
|
||||||
|
document.addEventListener('DOMContentLoaded', () => updateVdl2MainFreqs());
|
||||||
|
</script>
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
<strong style="color: var(--accent-cyan); font-size: 12px;">V-Dipole (Easiest — ~$5)</strong>
|
<strong style="color: var(--accent-cyan); font-size: 12px;">V-Dipole (Easiest — ~$5)</strong>
|
||||||
|
|
||||||
<div style="margin: 8px 0; padding: 8px; background: var(--bg-tertiary); border-radius: 3px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text-secondary); white-space: pre; line-height: 1.3; text-align: center;"> coax to SDR
|
<div style="margin: 8px 0; padding: 8px; background: var(--bg-tertiary); border-radius: 3px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 10px; color: var(--text-secondary); white-space: pre; line-height: 1.3; text-align: center;"> coax to SDR
|
||||||
|
|
|
|
||||||
===+=== feed point
|
===+=== feed point
|
||||||
/ \
|
/ \
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Turnstile / Crossed Dipole (~$10-15)</strong>
|
<strong style="color: var(--accent-cyan); font-size: 12px;">Turnstile / Crossed Dipole (~$10-15)</strong>
|
||||||
|
|
||||||
<div style="margin: 8px 0; padding: 8px; background: var(--bg-tertiary); border-radius: 3px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text-secondary); white-space: pre; line-height: 1.3; text-align: center;"> 53.4cm
|
<div style="margin: 8px 0; padding: 8px; background: var(--bg-tertiary); border-radius: 3px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 10px; color: var(--text-secondary); white-space: pre; line-height: 1.3; text-align: center;"> 53.4cm
|
||||||
<--------->
|
<--------->
|
||||||
====+==== dipole 1
|
====+==== dipole 1
|
||||||
|
|
|
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||||
<strong style="color: #00ff88; font-size: 12px;">QFH — Quadrifilar Helix (Best — ~$20-30)</strong>
|
<strong style="color: #00ff88; font-size: 12px;">QFH — Quadrifilar Helix (Best — ~$20-30)</strong>
|
||||||
|
|
||||||
<div style="margin: 8px 0; padding: 8px; background: var(--bg-tertiary); border-radius: 3px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text-secondary); white-space: pre; line-height: 1.3; text-align: center;"> ___
|
<div style="margin: 8px 0; padding: 8px; background: var(--bg-tertiary); border-radius: 3px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 10px; color: var(--text-secondary); white-space: pre; line-height: 1.3; text-align: center;"> ___
|
||||||
/ \ two helix loops
|
/ \ two helix loops
|
||||||
| | | twisted 90 deg
|
| | | twisted 90 deg
|
||||||
| | | around a mast
|
| | | around a mast
|
||||||
@@ -200,7 +200,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>File Path (server-side)</label>
|
<label>File Path (server-side)</label>
|
||||||
<input type="text" id="wxsatTestFilePath" value="data/weather_sat/samples/noaa_apt_argentina.wav" style="font-family: 'JetBrains Mono', monospace; font-size: 11px;">
|
<input type="text" id="wxsatTestFilePath" value="data/weather_sat/samples/noaa_apt_argentina.wav" style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 11px;">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Sample Rate</label>
|
<label>Sample Rate</label>
|
||||||
@@ -230,7 +230,7 @@
|
|||||||
Enable Auto-Capture
|
Enable Auto-Capture
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="wxsatSchedulerStatus" style="font-size: 11px; color: var(--text-dim); font-family: 'JetBrains Mono', monospace; margin-top: 4px;">
|
<div id="wxsatSchedulerStatus" style="font-size: 11px; color: var(--text-dim); font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; margin-top: 4px;">
|
||||||
Disabled
|
Disabled
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,6 +67,8 @@
|
|||||||
{{ 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('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('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('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('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>') }}
|
||||||
@@ -178,6 +180,8 @@
|
|||||||
{{ 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('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('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('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>') }}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<div class="settings-label">
|
<div class="settings-label">
|
||||||
<span class="settings-label-text">Web Fonts</span>
|
<span class="settings-label-text">Web Fonts</span>
|
||||||
<span class="settings-label-desc">Space Mono</span>
|
<span class="settings-label-desc">Roboto Condensed</span>
|
||||||
</div>
|
</div>
|
||||||
<select id="fontsSource" class="settings-select" onchange="Settings.setFontsSource(this.value)">
|
<select id="fontsSource" class="settings-select" onchange="Settings.setFontsSource(this.value)">
|
||||||
<option value="cdn">Google Fonts (Online)</option>
|
<option value="cdn">Google Fonts (Online)</option>
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
<span class="asset-badge checking" id="statusInter">Checking...</span>
|
<span class="asset-badge checking" id="statusInter">Checking...</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="asset-status-row">
|
<div class="asset-status-row">
|
||||||
<span class="asset-name">Space Mono</span>
|
<span class="asset-name">Roboto Condensed</span>
|
||||||
<span class="asset-badge checking" id="statusJetbrains">Checking...</span>
|
<span class="asset-badge checking" id="statusJetbrains">Checking...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
{% if offline_settings.fonts_source == 'local' %}
|
{% if offline_settings.fonts_source == 'local' %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||||||
{% else %}
|
{% else %}
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<!-- Leaflet.js - Conditional CDN/Local loading -->
|
<!-- Leaflet.js - Conditional CDN/Local loading -->
|
||||||
{% if offline_settings.assets_source == 'local' %}
|
{% if offline_settings.assets_source == 'local' %}
|
||||||
@@ -622,7 +622,7 @@
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(0, 212, 255, 0.4)';
|
ctx.fillStyle = 'rgba(0, 212, 255, 0.4)';
|
||||||
ctx.font = '10px Space Mono';
|
ctx.font = '10px Roboto Condensed';
|
||||||
ctx.fillText(el + '°', cx + 5, cy - r + 12);
|
ctx.fillText(el + '°', cx + 5, cy - r + 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -990,7 +990,7 @@
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(0, 212, 255, 0.4)';
|
ctx.fillStyle = 'rgba(0, 212, 255, 0.4)';
|
||||||
ctx.font = '10px Space Mono';
|
ctx.font = '10px Roboto Condensed';
|
||||||
ctx.fillText(elRing + '°', cx + 5, cy - r + 12);
|
ctx.fillText(elRing + '°', cx + 5, cy - r + 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1057,7 +1057,7 @@
|
|||||||
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 Space Mono';
|
ctx.font = '10px Roboto Condensed';
|
||||||
ctx.fillStyle = el > 0 ? '#00ff88' : '#ff4444';
|
ctx.fillStyle = el > 0 ? '#00ff88' : '#ff4444';
|
||||||
ctx.fillText(el.toFixed(1) + '°', x, y + 25);
|
ctx.fillText(el.toFixed(1) + '°', x, y + 25);
|
||||||
} else {
|
} else {
|
||||||
@@ -1106,7 +1106,7 @@
|
|||||||
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 Space Mono';
|
ctx.font = '10px Roboto Condensed';
|
||||||
ctx.fillStyle = el > 0 ? '#00ff88' : '#ff4444';
|
ctx.fillStyle = el > 0 ? '#00ff88' : '#ff4444';
|
||||||
ctx.fillText(el.toFixed(1) + '°', x, y + 25);
|
ctx.fillText(el.toFixed(1) + '°', x, y + 25);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -606,6 +606,12 @@ class DeviceAggregator:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def get_fingerprint_mac_count(self, fingerprint_id: str) -> int:
|
||||||
|
"""Return how many distinct device_ids share a fingerprint."""
|
||||||
|
with self._lock:
|
||||||
|
device_ids = self._fingerprint_to_devices.get(fingerprint_id)
|
||||||
|
return len(device_ids) if device_ids else 0
|
||||||
|
|
||||||
def prune_ring_buffer(self) -> int:
|
def prune_ring_buffer(self) -> int:
|
||||||
"""Prune old observations from ring buffer."""
|
"""Prune old observations from ring buffer."""
|
||||||
return self._ring_buffer.prune_old()
|
return self._ring_buffer.prune_old()
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ ADDRESS_TYPE_RANDOM = 'random'
|
|||||||
ADDRESS_TYPE_RANDOM_STATIC = 'random_static'
|
ADDRESS_TYPE_RANDOM_STATIC = 'random_static'
|
||||||
ADDRESS_TYPE_RPA = 'rpa' # Resolvable Private Address
|
ADDRESS_TYPE_RPA = 'rpa' # Resolvable Private Address
|
||||||
ADDRESS_TYPE_NRPA = 'nrpa' # Non-Resolvable Private Address
|
ADDRESS_TYPE_NRPA = 'nrpa' # Non-Resolvable Private Address
|
||||||
|
ADDRESS_TYPE_UUID = 'uuid' # CoreBluetooth platform UUID (macOS, no real MAC available)
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# PROTOCOL TYPES
|
# PROTOCOL TYPES
|
||||||
@@ -278,3 +279,59 @@ MINOR_WEARABLE = {
|
|||||||
0x04: 'Helmet',
|
0x04: 'Helmet',
|
||||||
0x05: 'Glasses',
|
0x05: 'Glasses',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BLE APPEARANCE CODES (GAP Appearance values)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
BLE_APPEARANCE_NAMES: dict[int, str] = {
|
||||||
|
0: 'Unknown',
|
||||||
|
64: 'Phone',
|
||||||
|
128: 'Computer',
|
||||||
|
192: 'Watch',
|
||||||
|
193: 'Sports Watch',
|
||||||
|
256: 'Clock',
|
||||||
|
320: 'Display',
|
||||||
|
384: 'Remote Control',
|
||||||
|
448: 'Eye Glasses',
|
||||||
|
512: 'Tag',
|
||||||
|
576: 'Keyring',
|
||||||
|
640: 'Media Player',
|
||||||
|
704: 'Barcode Scanner',
|
||||||
|
768: 'Thermometer',
|
||||||
|
832: 'Heart Rate Sensor',
|
||||||
|
896: 'Blood Pressure',
|
||||||
|
960: 'HID',
|
||||||
|
961: 'Keyboard',
|
||||||
|
962: 'Mouse',
|
||||||
|
963: 'Joystick',
|
||||||
|
964: 'Gamepad',
|
||||||
|
965: 'Digitizer Tablet',
|
||||||
|
966: 'Card Reader',
|
||||||
|
967: 'Digital Pen',
|
||||||
|
968: 'Barcode Scanner (HID)',
|
||||||
|
1024: 'Glucose Monitor',
|
||||||
|
1088: 'Running Speed Sensor',
|
||||||
|
1152: 'Cycling',
|
||||||
|
1216: 'Control Device',
|
||||||
|
1280: 'Network Device',
|
||||||
|
1344: 'Sensor',
|
||||||
|
1408: 'Light Fixture',
|
||||||
|
1472: 'Fan',
|
||||||
|
1536: 'HVAC',
|
||||||
|
1600: 'Access Control',
|
||||||
|
1664: 'Motorized Device',
|
||||||
|
1728: 'Power Device',
|
||||||
|
1792: 'Light Source',
|
||||||
|
3136: 'Pulse Oximeter',
|
||||||
|
3200: 'Weight Scale',
|
||||||
|
3264: 'Personal Mobility',
|
||||||
|
5184: 'Outdoor Sports Activity',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_appearance_name(code: int | None) -> str | None:
|
||||||
|
"""Look up a human-readable name for a BLE appearance code."""
|
||||||
|
if code is None:
|
||||||
|
return None
|
||||||
|
return BLE_APPEARANCE_NAMES.get(code)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from typing import Optional
|
|||||||
from .constants import (
|
from .constants import (
|
||||||
ADDRESS_TYPE_PUBLIC,
|
ADDRESS_TYPE_PUBLIC,
|
||||||
ADDRESS_TYPE_RANDOM_STATIC,
|
ADDRESS_TYPE_RANDOM_STATIC,
|
||||||
|
ADDRESS_TYPE_UUID,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -46,10 +47,14 @@ def generate_device_key(
|
|||||||
if identity_address:
|
if identity_address:
|
||||||
return f"id:{identity_address.upper()}"
|
return f"id:{identity_address.upper()}"
|
||||||
|
|
||||||
# Priority 2: Use public or random_static addresses directly
|
# Priority 2: Use public or random_static addresses directly (not platform UUIDs)
|
||||||
if address_type in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC):
|
if address_type in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC):
|
||||||
return f"mac:{address.upper()}"
|
return f"mac:{address.upper()}"
|
||||||
|
|
||||||
|
# Priority 2b: CoreBluetooth UUIDs are stable per-system, use as identifier
|
||||||
|
if address_type == ADDRESS_TYPE_UUID:
|
||||||
|
return f"uuid:{address.upper()}"
|
||||||
|
|
||||||
# Priority 3: Generate fingerprint hash for random addresses
|
# Priority 3: Generate fingerprint hash for random addresses
|
||||||
return _generate_fingerprint_key(address, name, manufacturer_id, service_uuids)
|
return _generate_fingerprint_key(address, name, manufacturer_id, service_uuids)
|
||||||
|
|
||||||
@@ -102,7 +107,7 @@ def is_randomized_mac(address_type: str) -> bool:
|
|||||||
Returns:
|
Returns:
|
||||||
True if the address is randomized, False otherwise.
|
True if the address is randomized, False otherwise.
|
||||||
"""
|
"""
|
||||||
return address_type not in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC)
|
return address_type not in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC, ADDRESS_TYPE_UUID)
|
||||||
|
|
||||||
|
|
||||||
def extract_key_type(device_key: str) -> str:
|
def extract_key_type(device_key: str) -> str:
|
||||||
|
|||||||
@@ -24,8 +24,12 @@ from .constants import (
|
|||||||
BLUETOOTHCTL_TIMEOUT,
|
BLUETOOTHCTL_TIMEOUT,
|
||||||
ADDRESS_TYPE_PUBLIC,
|
ADDRESS_TYPE_PUBLIC,
|
||||||
ADDRESS_TYPE_RANDOM,
|
ADDRESS_TYPE_RANDOM,
|
||||||
|
ADDRESS_TYPE_UUID,
|
||||||
MANUFACTURER_NAMES,
|
MANUFACTURER_NAMES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# CoreBluetooth UUID pattern: 8-4-4-4-12 hex digits
|
||||||
|
_CB_UUID_RE = re.compile(r'^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$')
|
||||||
from .models import BTObservation
|
from .models import BTObservation
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -132,7 +136,10 @@ class BleakScanner:
|
|||||||
"""Convert bleak device to BTObservation."""
|
"""Convert bleak device to BTObservation."""
|
||||||
# Determine address type from address format
|
# Determine address type from address format
|
||||||
address_type = ADDRESS_TYPE_PUBLIC
|
address_type = ADDRESS_TYPE_PUBLIC
|
||||||
if device.address and ':' in device.address:
|
if device.address and _CB_UUID_RE.match(device.address):
|
||||||
|
# macOS CoreBluetooth returns a platform UUID instead of a real MAC
|
||||||
|
address_type = ADDRESS_TYPE_UUID
|
||||||
|
elif device.address and ':' in device.address:
|
||||||
# Check if first byte indicates random address
|
# Check if first byte indicates random address
|
||||||
first_byte = int(device.address.split(':')[0], 16)
|
first_byte = int(device.address.split(':')[0], 16)
|
||||||
if (first_byte & 0xC0) == 0xC0: # Random static
|
if (first_byte & 0xC0) == 0xC0: # Random static
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from .constants import (
|
|||||||
RANGE_UNKNOWN,
|
RANGE_UNKNOWN,
|
||||||
PROTOCOL_BLE,
|
PROTOCOL_BLE,
|
||||||
PROXIMITY_UNKNOWN,
|
PROXIMITY_UNKNOWN,
|
||||||
|
get_appearance_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Import tracker types (will be available after tracker_signatures module loads)
|
# Import tracker types (will be available after tracker_signatures module loads)
|
||||||
@@ -148,10 +149,10 @@ class BTDeviceAggregate:
|
|||||||
is_strong_stable: bool = False
|
is_strong_stable: bool = False
|
||||||
has_random_address: bool = False
|
has_random_address: bool = False
|
||||||
|
|
||||||
# Baseline tracking
|
# Baseline tracking
|
||||||
in_baseline: bool = False
|
in_baseline: bool = False
|
||||||
baseline_id: Optional[int] = None
|
baseline_id: Optional[int] = None
|
||||||
seen_before: bool = False
|
seen_before: bool = False
|
||||||
|
|
||||||
# Tracker detection fields
|
# Tracker detection fields
|
||||||
is_tracker: bool = False
|
is_tracker: bool = False
|
||||||
@@ -165,6 +166,10 @@ class BTDeviceAggregate:
|
|||||||
risk_score: float = 0.0 # 0.0 to 1.0
|
risk_score: float = 0.0 # 0.0 to 1.0
|
||||||
risk_factors: list[str] = field(default_factory=list)
|
risk_factors: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
# IRK (Identity Resolving Key) from paired device database
|
||||||
|
irk_hex: Optional[str] = None # 32-char hex if known
|
||||||
|
irk_source_name: Optional[str] = None # Name from paired DB
|
||||||
|
|
||||||
# Payload fingerprint (survives MAC randomization)
|
# Payload fingerprint (survives MAC randomization)
|
||||||
payload_fingerprint_id: Optional[str] = None
|
payload_fingerprint_id: Optional[str] = None
|
||||||
payload_fingerprint_stability: float = 0.0
|
payload_fingerprint_stability: float = 0.0
|
||||||
@@ -275,10 +280,10 @@ class BTDeviceAggregate:
|
|||||||
},
|
},
|
||||||
'heuristic_flags': self.heuristic_flags,
|
'heuristic_flags': self.heuristic_flags,
|
||||||
|
|
||||||
# Baseline
|
# Baseline
|
||||||
'in_baseline': self.in_baseline,
|
'in_baseline': self.in_baseline,
|
||||||
'baseline_id': self.baseline_id,
|
'baseline_id': self.baseline_id,
|
||||||
'seen_before': self.seen_before,
|
'seen_before': self.seen_before,
|
||||||
|
|
||||||
# Tracker detection
|
# Tracker detection
|
||||||
'tracker': {
|
'tracker': {
|
||||||
@@ -296,6 +301,11 @@ class BTDeviceAggregate:
|
|||||||
'risk_factors': self.risk_factors,
|
'risk_factors': self.risk_factors,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
# IRK
|
||||||
|
'has_irk': self.irk_hex is not None,
|
||||||
|
'irk_hex': self.irk_hex,
|
||||||
|
'irk_source_name': self.irk_source_name,
|
||||||
|
|
||||||
# Fingerprint
|
# Fingerprint
|
||||||
'fingerprint': {
|
'fingerprint': {
|
||||||
'id': self.payload_fingerprint_id,
|
'id': self.payload_fingerprint_id,
|
||||||
@@ -319,24 +329,46 @@ class BTDeviceAggregate:
|
|||||||
'rssi_current': self.rssi_current,
|
'rssi_current': self.rssi_current,
|
||||||
'rssi_median': round(self.rssi_median, 1) if self.rssi_median else None,
|
'rssi_median': round(self.rssi_median, 1) if self.rssi_median else None,
|
||||||
'rssi_ema': round(self.rssi_ema, 1) if self.rssi_ema else None,
|
'rssi_ema': round(self.rssi_ema, 1) if self.rssi_ema else None,
|
||||||
|
'rssi_min': self.rssi_min,
|
||||||
|
'rssi_max': self.rssi_max,
|
||||||
|
'rssi_variance': round(self.rssi_variance, 2) if self.rssi_variance else None,
|
||||||
'range_band': self.range_band,
|
'range_band': self.range_band,
|
||||||
'proximity_band': self.proximity_band,
|
'proximity_band': self.proximity_band,
|
||||||
'estimated_distance_m': round(self.estimated_distance_m, 2) if self.estimated_distance_m else None,
|
'estimated_distance_m': round(self.estimated_distance_m, 2) if self.estimated_distance_m else None,
|
||||||
'distance_confidence': round(self.distance_confidence, 2),
|
'distance_confidence': round(self.distance_confidence, 2),
|
||||||
'is_randomized_mac': self.is_randomized_mac,
|
'is_randomized_mac': self.is_randomized_mac,
|
||||||
'last_seen': self.last_seen.isoformat(),
|
'last_seen': self.last_seen.isoformat(),
|
||||||
|
'first_seen': self.first_seen.isoformat(),
|
||||||
'age_seconds': self.age_seconds,
|
'age_seconds': self.age_seconds,
|
||||||
|
'duration_seconds': self.duration_seconds,
|
||||||
'seen_count': self.seen_count,
|
'seen_count': self.seen_count,
|
||||||
'heuristic_flags': self.heuristic_flags,
|
'seen_rate': round(self.seen_rate, 2),
|
||||||
'in_baseline': self.in_baseline,
|
'tx_power': self.tx_power,
|
||||||
'seen_before': self.seen_before,
|
'manufacturer_id': self.manufacturer_id,
|
||||||
# Tracker info for list view
|
'appearance': self.appearance,
|
||||||
'is_tracker': self.is_tracker,
|
'appearance_name': get_appearance_name(self.appearance),
|
||||||
|
'is_connectable': self.is_connectable,
|
||||||
|
'service_uuids': self.service_uuids,
|
||||||
|
'service_data': {k: v.hex() for k, v in self.service_data.items()},
|
||||||
|
'manufacturer_bytes': self.manufacturer_bytes.hex() if self.manufacturer_bytes else None,
|
||||||
|
'heuristic_flags': self.heuristic_flags,
|
||||||
|
'is_persistent': self.is_persistent,
|
||||||
|
'is_beacon_like': self.is_beacon_like,
|
||||||
|
'is_strong_stable': self.is_strong_stable,
|
||||||
|
'in_baseline': self.in_baseline,
|
||||||
|
'seen_before': self.seen_before,
|
||||||
|
# Tracker info for list view
|
||||||
|
'is_tracker': self.is_tracker,
|
||||||
'tracker_type': self.tracker_type,
|
'tracker_type': self.tracker_type,
|
||||||
'tracker_name': self.tracker_name,
|
'tracker_name': self.tracker_name,
|
||||||
'tracker_confidence': self.tracker_confidence,
|
'tracker_confidence': self.tracker_confidence,
|
||||||
'tracker_confidence_score': round(self.tracker_confidence_score, 2),
|
'tracker_confidence_score': round(self.tracker_confidence_score, 2),
|
||||||
|
'tracker_evidence': self.tracker_evidence,
|
||||||
'risk_score': round(self.risk_score, 2),
|
'risk_score': round(self.risk_score, 2),
|
||||||
|
'risk_factors': self.risk_factors,
|
||||||
|
'has_irk': self.irk_hex is not None,
|
||||||
|
'irk_hex': self.irk_hex,
|
||||||
|
'irk_source_name': self.irk_source_name,
|
||||||
'fingerprint_id': self.payload_fingerprint_id,
|
'fingerprint_id': self.payload_fingerprint_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ from .constants import (
|
|||||||
)
|
)
|
||||||
from .dbus_scanner import DBusScanner
|
from .dbus_scanner import DBusScanner
|
||||||
from .fallback_scanner import FallbackScanner
|
from .fallback_scanner import FallbackScanner
|
||||||
|
from .ubertooth_scanner import UbertoothScanner
|
||||||
from .heuristics import HeuristicsEngine
|
from .heuristics import HeuristicsEngine
|
||||||
|
from .irk_extractor import get_paired_irks
|
||||||
from .models import BTDeviceAggregate, BTObservation, ScanStatus, SystemCapabilities
|
from .models import BTDeviceAggregate, BTObservation, ScanStatus, SystemCapabilities
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -57,6 +59,7 @@ class BluetoothScanner:
|
|||||||
# Scanner backends
|
# Scanner backends
|
||||||
self._dbus_scanner: Optional[DBusScanner] = None
|
self._dbus_scanner: Optional[DBusScanner] = None
|
||||||
self._fallback_scanner: Optional[FallbackScanner] = None
|
self._fallback_scanner: Optional[FallbackScanner] = None
|
||||||
|
self._ubertooth_scanner: Optional[UbertoothScanner] = None
|
||||||
self._active_backend: Optional[str] = None
|
self._active_backend: Optional[str] = None
|
||||||
|
|
||||||
# Event queue for SSE streaming
|
# Event queue for SSE streaming
|
||||||
@@ -113,6 +116,8 @@ class BluetoothScanner:
|
|||||||
|
|
||||||
if mode == 'dbus':
|
if mode == 'dbus':
|
||||||
started, backend_used = self._start_dbus(adapter, transport, rssi_threshold)
|
started, backend_used = self._start_dbus(adapter, transport, rssi_threshold)
|
||||||
|
elif mode == 'ubertooth':
|
||||||
|
started, backend_used = self._start_ubertooth()
|
||||||
|
|
||||||
# Fallback: try non-DBus methods if DBus failed or wasn't requested
|
# Fallback: try non-DBus methods if DBus failed or wasn't requested
|
||||||
if not started and (original_mode == 'auto' or mode in ('bleak', 'hcitool', 'bluetoothctl')):
|
if not started and (original_mode == 'auto' or mode in ('bleak', 'hcitool', 'bluetoothctl')):
|
||||||
@@ -168,6 +173,18 @@ class BluetoothScanner:
|
|||||||
logger.warning(f"DBus scanner failed: {e}")
|
logger.warning(f"DBus scanner failed: {e}")
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
|
def _start_ubertooth(self) -> tuple[bool, Optional[str]]:
|
||||||
|
"""Start Ubertooth One scanner."""
|
||||||
|
try:
|
||||||
|
self._ubertooth_scanner = UbertoothScanner(
|
||||||
|
on_observation=self._handle_observation,
|
||||||
|
)
|
||||||
|
if self._ubertooth_scanner.start():
|
||||||
|
return True, 'ubertooth'
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Ubertooth scanner failed: {e}")
|
||||||
|
return False, None
|
||||||
|
|
||||||
def _start_fallback(self, adapter: str, preferred: str) -> tuple[bool, Optional[str]]:
|
def _start_fallback(self, adapter: str, preferred: str) -> tuple[bool, Optional[str]]:
|
||||||
"""Start fallback scanner."""
|
"""Start fallback scanner."""
|
||||||
try:
|
try:
|
||||||
@@ -204,6 +221,10 @@ class BluetoothScanner:
|
|||||||
self._fallback_scanner.stop()
|
self._fallback_scanner.stop()
|
||||||
self._fallback_scanner = None
|
self._fallback_scanner = None
|
||||||
|
|
||||||
|
if self._ubertooth_scanner:
|
||||||
|
self._ubertooth_scanner.stop()
|
||||||
|
self._ubertooth_scanner = None
|
||||||
|
|
||||||
# Update status
|
# Update status
|
||||||
self._status.is_scanning = False
|
self._status.is_scanning = False
|
||||||
self._active_backend = None
|
self._active_backend = None
|
||||||
@@ -216,6 +237,47 @@ class BluetoothScanner:
|
|||||||
|
|
||||||
logger.info("Bluetooth scan stopped")
|
logger.info("Bluetooth scan stopped")
|
||||||
|
|
||||||
|
def _match_irk(self, device: BTDeviceAggregate) -> None:
|
||||||
|
"""Check if a device address resolves against any paired IRK."""
|
||||||
|
if device.irk_hex is not None:
|
||||||
|
return # Already matched
|
||||||
|
|
||||||
|
address = device.address
|
||||||
|
if not address or len(address.replace(':', '').replace('-', '')) not in (12, 32):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Only attempt RPA resolution on 6-byte addresses
|
||||||
|
addr_clean = address.replace(':', '').replace('-', '')
|
||||||
|
if len(addr_clean) != 12:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
paired = get_paired_irks()
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not paired:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from utils.bt_locate import resolve_rpa
|
||||||
|
except ImportError:
|
||||||
|
return
|
||||||
|
|
||||||
|
for entry in paired:
|
||||||
|
irk_hex = entry.get('irk_hex', '')
|
||||||
|
if not irk_hex or len(irk_hex) != 32:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
irk = bytes.fromhex(irk_hex)
|
||||||
|
if resolve_rpa(irk, address):
|
||||||
|
device.irk_hex = irk_hex
|
||||||
|
device.irk_source_name = entry.get('name')
|
||||||
|
logger.debug(f"IRK match for {address}: {entry.get('name', 'unnamed')}")
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
def _handle_observation(self, observation: BTObservation) -> None:
|
def _handle_observation(self, observation: BTObservation) -> None:
|
||||||
"""Handle incoming observation from scanner backend."""
|
"""Handle incoming observation from scanner backend."""
|
||||||
try:
|
try:
|
||||||
@@ -225,15 +287,27 @@ class BluetoothScanner:
|
|||||||
# Evaluate heuristics
|
# Evaluate heuristics
|
||||||
self._heuristics.evaluate(device)
|
self._heuristics.evaluate(device)
|
||||||
|
|
||||||
|
# Check for IRK match
|
||||||
|
self._match_irk(device)
|
||||||
|
|
||||||
# Update device count
|
# Update device count
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._status.devices_found = self._aggregator.device_count
|
self._status.devices_found = self._aggregator.device_count
|
||||||
|
|
||||||
|
# Build summary with MAC cluster count
|
||||||
|
summary = device.to_summary_dict()
|
||||||
|
if device.payload_fingerprint_id:
|
||||||
|
summary['mac_cluster_count'] = self._aggregator.get_fingerprint_mac_count(
|
||||||
|
device.payload_fingerprint_id
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
summary['mac_cluster_count'] = 0
|
||||||
|
|
||||||
# Queue event
|
# Queue event
|
||||||
self._queue_event({
|
self._queue_event({
|
||||||
'type': 'device',
|
'type': 'device',
|
||||||
'action': 'update',
|
'action': 'update',
|
||||||
'device': device.to_summary_dict(),
|
'device': summary,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Callbacks
|
# Callbacks
|
||||||
@@ -398,6 +472,7 @@ class BluetoothScanner:
|
|||||||
backend_alive = (
|
backend_alive = (
|
||||||
(self._dbus_scanner and self._dbus_scanner.is_scanning)
|
(self._dbus_scanner and self._dbus_scanner.is_scanning)
|
||||||
or (self._fallback_scanner and self._fallback_scanner.is_scanning)
|
or (self._fallback_scanner and self._fallback_scanner.is_scanning)
|
||||||
|
or (self._ubertooth_scanner and self._ubertooth_scanner.is_scanning)
|
||||||
)
|
)
|
||||||
if not backend_alive:
|
if not backend_alive:
|
||||||
self._status.is_scanning = False
|
self._status.is_scanning = False
|
||||||
|
|||||||
@@ -318,6 +318,8 @@ class GPSDClient:
|
|||||||
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
logger.debug(f"Invalid JSON from gpsd: {line[:50]}")
|
logger.debug(f"Invalid JSON from gpsd: {line[:50]}")
|
||||||
|
except Exception as parse_err:
|
||||||
|
logger.error(f"Error handling gpsd {msg_class} message: {parse_err}")
|
||||||
|
|
||||||
except socket.timeout:
|
except socket.timeout:
|
||||||
continue
|
continue
|
||||||
@@ -371,19 +373,33 @@ class GPSDClient:
|
|||||||
self._update_position(position)
|
self._update_position(position)
|
||||||
|
|
||||||
def _handle_sky(self, msg: dict) -> None:
|
def _handle_sky(self, msg: dict) -> None:
|
||||||
"""Handle SKY (satellite sky view) message from gpsd."""
|
"""Handle SKY (satellite sky view) message from gpsd.
|
||||||
sats = []
|
|
||||||
for sat in msg.get('satellites', []):
|
gpsd sends multiple SKY messages per cycle: some contain only DOP
|
||||||
prn = sat.get('PRN', 0)
|
values while others include the full satellites array. When a
|
||||||
gnssid = sat.get('gnssid')
|
DOP-only SKY arrives, preserve the most recent satellite list
|
||||||
sats.append(GPSSatellite(
|
instead of overwriting it with an empty one.
|
||||||
prn=prn,
|
"""
|
||||||
elevation=sat.get('el'),
|
raw_sats = msg.get('satellites', [])
|
||||||
azimuth=sat.get('az'),
|
has_satellites = len(raw_sats) > 0
|
||||||
snr=sat.get('ss'),
|
|
||||||
used=sat.get('used', False),
|
if has_satellites:
|
||||||
constellation=_classify_constellation(prn, gnssid),
|
sats = []
|
||||||
))
|
for sat in raw_sats:
|
||||||
|
prn = sat.get('PRN', 0)
|
||||||
|
gnssid = sat.get('gnssid')
|
||||||
|
sats.append(GPSSatellite(
|
||||||
|
prn=prn,
|
||||||
|
elevation=sat.get('el'),
|
||||||
|
azimuth=sat.get('az'),
|
||||||
|
snr=sat.get('ss'),
|
||||||
|
used=sat.get('used', False),
|
||||||
|
constellation=_classify_constellation(prn, gnssid),
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# DOP-only SKY message — keep existing satellites
|
||||||
|
with self._lock:
|
||||||
|
sats = list(self._sky.satellites) if self._sky else []
|
||||||
|
|
||||||
sky_data = GPSSkyData(
|
sky_data = GPSSkyData(
|
||||||
satellites=sats,
|
satellites=sats,
|
||||||
@@ -483,3 +499,181 @@ def get_current_position() -> GPSPosition | None:
|
|||||||
if client:
|
if client:
|
||||||
return client.position
|
return client.position
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# GPS device detection and gpsd auto-start
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
_gpsd_process: 'subprocess.Popen | None' = None
|
||||||
|
_gpsd_process_lock = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
|
def detect_gps_devices() -> list[dict]:
|
||||||
|
"""
|
||||||
|
Detect connected GPS serial devices.
|
||||||
|
|
||||||
|
Returns list of dicts with 'path' and 'description' keys.
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
|
||||||
|
devices: list[dict] = []
|
||||||
|
system = platform.system()
|
||||||
|
|
||||||
|
if system == 'Linux':
|
||||||
|
# Common USB GPS device paths
|
||||||
|
patterns = ['/dev/ttyUSB*', '/dev/ttyACM*']
|
||||||
|
for pattern in patterns:
|
||||||
|
for path in sorted(glob.glob(pattern)):
|
||||||
|
desc = _describe_device_linux(path)
|
||||||
|
devices.append({'path': path, 'description': desc})
|
||||||
|
|
||||||
|
# Also check /dev/serial/by-id for descriptive names
|
||||||
|
serial_dir = '/dev/serial/by-id'
|
||||||
|
if os.path.isdir(serial_dir):
|
||||||
|
for name in sorted(os.listdir(serial_dir)):
|
||||||
|
full = os.path.join(serial_dir, name)
|
||||||
|
real = os.path.realpath(full)
|
||||||
|
# Skip if we already found this device
|
||||||
|
if any(d['path'] == real for d in devices):
|
||||||
|
# Update description with the more descriptive name
|
||||||
|
for d in devices:
|
||||||
|
if d['path'] == real:
|
||||||
|
d['description'] = name
|
||||||
|
continue
|
||||||
|
devices.append({'path': real, 'description': name})
|
||||||
|
|
||||||
|
elif system == 'Darwin':
|
||||||
|
# macOS: USB serial devices (prefer cu. over tty. for outgoing)
|
||||||
|
patterns = ['/dev/cu.usbmodem*', '/dev/cu.usbserial*']
|
||||||
|
for pattern in patterns:
|
||||||
|
for path in sorted(glob.glob(pattern)):
|
||||||
|
desc = _describe_device_macos(path)
|
||||||
|
devices.append({'path': path, 'description': desc})
|
||||||
|
|
||||||
|
# Sort: devices with GPS-related descriptions first
|
||||||
|
gps_keywords = ('gps', 'gnss', 'u-blox', 'ublox', 'nmea', 'sirf', 'navigation')
|
||||||
|
devices.sort(key=lambda d: (
|
||||||
|
0 if any(k in d['description'].lower() for k in gps_keywords) else 1
|
||||||
|
))
|
||||||
|
|
||||||
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
def _describe_device_linux(path: str) -> str:
|
||||||
|
"""Get a human-readable description of a Linux serial device."""
|
||||||
|
import os
|
||||||
|
basename = os.path.basename(path)
|
||||||
|
# Try to read from sysfs
|
||||||
|
try:
|
||||||
|
# /sys/class/tty/ttyUSB0/device/../product
|
||||||
|
sysfs = f'/sys/class/tty/{basename}/device/../product'
|
||||||
|
if os.path.exists(sysfs):
|
||||||
|
with open(sysfs) as f:
|
||||||
|
return f.read().strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return basename
|
||||||
|
|
||||||
|
|
||||||
|
def _describe_device_macos(path: str) -> str:
|
||||||
|
"""Get a description of a macOS serial device."""
|
||||||
|
import os
|
||||||
|
return os.path.basename(path)
|
||||||
|
|
||||||
|
|
||||||
|
def is_gpsd_running(host: str = 'localhost', port: int = 2947) -> bool:
|
||||||
|
"""Check if gpsd is reachable."""
|
||||||
|
import socket
|
||||||
|
try:
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.settimeout(1.0)
|
||||||
|
sock.connect((host, port))
|
||||||
|
sock.close()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def start_gpsd_daemon(device_path: str, host: str = 'localhost',
|
||||||
|
port: int = 2947) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Start gpsd daemon pointing at the given device.
|
||||||
|
|
||||||
|
Returns (success, message) tuple.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
global _gpsd_process
|
||||||
|
|
||||||
|
with _gpsd_process_lock:
|
||||||
|
# Already running?
|
||||||
|
if is_gpsd_running(host, port):
|
||||||
|
return True, 'gpsd already running'
|
||||||
|
|
||||||
|
gpsd_bin = shutil.which('gpsd')
|
||||||
|
if not gpsd_bin:
|
||||||
|
return False, 'gpsd not installed'
|
||||||
|
|
||||||
|
# Stop any existing managed process
|
||||||
|
stop_gpsd_daemon()
|
||||||
|
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
if not os.path.exists(device_path):
|
||||||
|
return False, f'Device {device_path} not found'
|
||||||
|
|
||||||
|
cmd = [gpsd_bin, '-N', '-n', '-S', str(port), device_path]
|
||||||
|
logger.info(f"Starting gpsd: {' '.join(cmd)}")
|
||||||
|
print(f"[GPS] Starting gpsd: {' '.join(cmd)}", flush=True)
|
||||||
|
|
||||||
|
_gpsd_process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Give gpsd a moment to start
|
||||||
|
import time
|
||||||
|
time.sleep(1.5)
|
||||||
|
|
||||||
|
if _gpsd_process.poll() is not None:
|
||||||
|
stderr = ''
|
||||||
|
if _gpsd_process.stderr:
|
||||||
|
stderr = _gpsd_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
||||||
|
msg = f'gpsd exited with code {_gpsd_process.returncode}'
|
||||||
|
if stderr:
|
||||||
|
msg += f': {stderr}'
|
||||||
|
return False, msg
|
||||||
|
|
||||||
|
# Verify it's listening
|
||||||
|
if is_gpsd_running(host, port):
|
||||||
|
return True, f'gpsd started on {device_path}'
|
||||||
|
else:
|
||||||
|
return False, 'gpsd started but not accepting connections'
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start gpsd: {e}")
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_gpsd_daemon() -> None:
|
||||||
|
"""Stop the managed gpsd daemon process."""
|
||||||
|
global _gpsd_process
|
||||||
|
|
||||||
|
with _gpsd_process_lock:
|
||||||
|
if _gpsd_process and _gpsd_process.poll() is None:
|
||||||
|
try:
|
||||||
|
_gpsd_process.terminate()
|
||||||
|
_gpsd_process.wait(timeout=3.0)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
_gpsd_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.info("Stopped gpsd daemon")
|
||||||
|
print("[GPS] Stopped gpsd daemon", flush=True)
|
||||||
|
_gpsd_process = None
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import atexit
|
import atexit
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from .dependencies import check_tool
|
from .dependencies import check_tool
|
||||||
@@ -117,6 +120,93 @@ def cleanup_stale_processes() -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_DUMP1090_PID_FILE = Path(__file__).resolve().parent.parent / 'instance' / 'dump1090.pid'
|
||||||
|
|
||||||
|
|
||||||
|
def write_dump1090_pid(pid: int) -> None:
|
||||||
|
"""Write the PID of an app-spawned dump1090 process to a PID file."""
|
||||||
|
try:
|
||||||
|
_DUMP1090_PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
_DUMP1090_PID_FILE.write_text(str(pid))
|
||||||
|
logger.debug(f"Wrote dump1090 PID file: {pid}")
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning(f"Failed to write dump1090 PID file: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def clear_dump1090_pid() -> None:
|
||||||
|
"""Remove the dump1090 PID file."""
|
||||||
|
try:
|
||||||
|
_DUMP1090_PID_FILE.unlink(missing_ok=True)
|
||||||
|
logger.debug("Cleared dump1090 PID file")
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning(f"Failed to clear dump1090 PID file: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_dump1090_process(pid: int) -> bool:
|
||||||
|
"""Check if the given PID is actually a dump1090/readsb process."""
|
||||||
|
try:
|
||||||
|
if platform.system() == 'Linux':
|
||||||
|
cmdline_path = Path(f'/proc/{pid}/cmdline')
|
||||||
|
if cmdline_path.exists():
|
||||||
|
cmdline = cmdline_path.read_bytes().replace(b'\x00', b' ').decode('utf-8', errors='ignore')
|
||||||
|
return 'dump1090' in cmdline or 'readsb' in cmdline
|
||||||
|
# macOS or fallback
|
||||||
|
result = subprocess.run(
|
||||||
|
['ps', '-p', str(pid), '-o', 'comm='],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
comm = result.stdout.strip()
|
||||||
|
return 'dump1090' in comm or 'readsb' in comm
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_stale_dump1090() -> None:
|
||||||
|
"""Kill a stale app-spawned dump1090 using the PID file.
|
||||||
|
|
||||||
|
Safe no-op if no PID file exists, process is dead, or PID was reused
|
||||||
|
by another program.
|
||||||
|
"""
|
||||||
|
if not _DUMP1090_PID_FILE.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
pid = int(_DUMP1090_PID_FILE.read_text().strip())
|
||||||
|
except (ValueError, OSError) as e:
|
||||||
|
logger.warning(f"Invalid dump1090 PID file: {e}")
|
||||||
|
clear_dump1090_pid()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Verify this PID is still a dump1090/readsb process
|
||||||
|
if not _is_dump1090_process(pid):
|
||||||
|
logger.debug(f"PID {pid} is not dump1090/readsb (dead or reused), removing stale PID file")
|
||||||
|
clear_dump1090_pid()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Kill the process group
|
||||||
|
logger.info(f"Killing stale app-spawned dump1090 (PID {pid})")
|
||||||
|
try:
|
||||||
|
pgid = os.getpgid(pid)
|
||||||
|
os.killpg(pgid, signal.SIGTERM)
|
||||||
|
# Brief wait for graceful shutdown
|
||||||
|
for _ in range(10):
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0) # Check if still alive
|
||||||
|
time.sleep(0.2)
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Still alive, force kill
|
||||||
|
try:
|
||||||
|
os.killpg(pgid, signal.SIGKILL)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except OSError as e:
|
||||||
|
logger.debug(f"Error killing stale dump1090 PID {pid}: {e}")
|
||||||
|
|
||||||
|
clear_dump1090_pid()
|
||||||
|
|
||||||
|
|
||||||
def is_valid_mac(mac: str | None) -> bool:
|
def is_valid_mac(mac: str | None) -> bool:
|
||||||
"""Validate MAC address format."""
|
"""Validate MAC address format."""
|
||||||
if not mac:
|
if not mac:
|
||||||
|
|||||||