Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16239c1d31 | |||
| cae7a0586f | |||
| 23f28a8102 | |||
| 34ecec3800 | |||
| d40bd37406 | |||
| 4ed41434e2 | |||
| 6a0b54fa0e | |||
| b83ecfcc19 | |||
| 671bf38083 | |||
| 0f5a414a09 | |||
| 831426948f | |||
| df2c0a0d25 | |||
| d427f69dcd | |||
| cab04e6e2c | |||
| 8969fefe2e | |||
| 5e9fcc5c49 | |||
| 53b23fc2f7 | |||
| eeb3a29ecf | |||
| 4cdfa98a4e | |||
| 9fcec6cbb8 | |||
| a527ac191a | |||
| 8cd3aafd10 | |||
| 5c76a423af | |||
| c80bf99b91 | |||
| 6e5cb0a23e | |||
| ffb98425f1 | |||
| 533e92c711 | |||
| 9f32b05719 | |||
| 2a05aaa4d8 | |||
| 6529febcfa | |||
| bd87d4b4c6 | |||
| 5a0589dd69 | |||
| 5605ae0359 | |||
| 2b3f351ff0 | |||
| 126b9ba2ee | |||
| c0498ebe68 |
@@ -95,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 \
|
||||||
@@ -137,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
|
||||||
@@ -47,6 +48,7 @@ Support the developer of this open-source project
|
|||||||
- **GPS** - Real-time GPS position tracking with live map, speed, altitude, and satellite info
|
- **GPS** - Real-time GPS position tracking with live map, speed, altitude, and satellite info
|
||||||
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
|
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
|
||||||
- **Meshtastic** - LoRa mesh network integration
|
- **Meshtastic** - LoRa mesh network integration
|
||||||
|
- **Space Weather** - Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL (no SDR required)
|
||||||
- **Spy Stations** - Number stations and diplomatic HF network database
|
- **Spy Stations** - Number stations and diplomatic HF network database
|
||||||
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
||||||
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
||||||
@@ -244,6 +246,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) |
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -680,6 +685,7 @@ 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),
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -7,10 +7,34 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "2.18.0"
|
VERSION = "2.20.0"
|
||||||
|
|
||||||
# Changelog - latest release notes (shown on welcome screen)
|
# Changelog - latest release notes (shown on welcome screen)
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "2.20.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Space Weather mode: real-time solar and geomagnetic monitoring from NOAA SWPC, NASA SDO, and HamQSL",
|
||||||
|
"Kp index, solar wind, X-ray flux charts with Chart.js visualization",
|
||||||
|
"HF band conditions, D-RAP absorption maps, aurora forecast, and solar imagery",
|
||||||
|
"NOAA Space Weather Scales (G/S/R), flare probability, and active solar regions",
|
||||||
|
"No SDR hardware required — all data from public APIs with server-side caching",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.19.0",
|
||||||
|
"date": "February 2026",
|
||||||
|
"highlights": [
|
||||||
|
"VDL2 mode with modal message viewer, consolidated into ADS-B dashboard",
|
||||||
|
"ADS-B: trails enabled by default, radar modes removed, CSV export added",
|
||||||
|
"Bundled Roboto Condensed font for offline mode with SVG icon overhaul",
|
||||||
|
"Help modal updated with all modes and correct SVG icons",
|
||||||
|
"Setup script overhauled for reliability and macOS compatibility",
|
||||||
|
"GPS fix for preserving satellites across DOP-only SKY messages",
|
||||||
|
"Fix gpsd deadlock causing GPS connect to hang",
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "2.18.0",
|
"version": "2.18.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
|
||||||
|
|
||||||
@@ -141,6 +165,22 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
|
|||||||
- **Real-time JSON output** with meter ID, consumption, and signal data
|
- **Real-time JSON output** with meter ID, consumption, and signal data
|
||||||
- **Multiple meter protocol support** via rtl_tcp integration
|
- **Multiple meter protocol support** via rtl_tcp integration
|
||||||
|
|
||||||
|
## Space Weather
|
||||||
|
|
||||||
|
- **Real-time solar indices** - Solar Flux Index (SFI), Kp index, A-index, sunspot number
|
||||||
|
- **NOAA Space Weather Scales** - Geomagnetic storms (G), solar radiation (S), radio blackouts (R)
|
||||||
|
- **HF band conditions** - Day/night propagation from HamQSL for 80m through 10m bands
|
||||||
|
- **Solar wind monitoring** - Speed, density, and IMF Bz from DSCOVR satellite
|
||||||
|
- **X-ray flux chart** - GOES X-ray data with flare class scale (A/B/C/M/X)
|
||||||
|
- **Flare probability** - 1-day and 3-day C/M/X-class flare forecasts
|
||||||
|
- **Solar imagery** - NASA SDO 193A, 304A, and magnetogram images
|
||||||
|
- **D-RAP absorption maps** - HF radio absorption at 5-30 MHz frequency bands
|
||||||
|
- **Aurora forecast** - OVATION aurora oval visualization
|
||||||
|
- **SWPC alerts** - Real-time space weather alerts and warnings
|
||||||
|
- **Active solar regions** - Current sunspot region data with location and area
|
||||||
|
- **Auto-refresh** - 5-minute polling with manual refresh option
|
||||||
|
- **No SDR required** - Data fetched from NOAA SWPC, NASA SDO, and HamQSL public APIs
|
||||||
|
|
||||||
## Satellite Tracking
|
## Satellite Tracking
|
||||||
|
|
||||||
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
||||||
|
|||||||
@@ -206,14 +206,23 @@ Extended base for full-screen dashboards (maps, visualizations).
|
|||||||
| `listening` | Listening post |
|
| `listening` | Listening post |
|
||||||
| `spystations` | Spy stations |
|
| `spystations` | Spy stations |
|
||||||
| `meshtastic` | Mesh networking |
|
| `meshtastic` | Mesh networking |
|
||||||
|
| `weathersat` | Weather satellites |
|
||||||
|
| `sstv_general` | HF SSTV |
|
||||||
|
| `gps` | GPS tracking |
|
||||||
|
| `websdr` | WebSDR |
|
||||||
|
| `subghz` | Sub-GHz analyzer |
|
||||||
|
| `bt_locate` | BT Locate |
|
||||||
|
| `analytics` | Analytics dashboard |
|
||||||
|
| `spaceweather` | Space weather |
|
||||||
|
| `dmr` | DMR/P25 digital voice |
|
||||||
|
|
||||||
### Navigation Groups
|
### Navigation Groups
|
||||||
|
|
||||||
The navigation is organized into groups:
|
The navigation is organized into groups:
|
||||||
- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic
|
- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic, WebSDR, SubGHz
|
||||||
- **Wireless**: WiFi, Bluetooth
|
- **Wireless**: WiFi, Bluetooth, BT Locate
|
||||||
- **Security**: TSCM
|
- **Security**: TSCM, Analytics
|
||||||
- **Space**: Satellite, ISS SSTV
|
- **Space**: Satellite, ISS SSTV, Weather Sat, HF SSTV, GPS, Space Weather
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -206,6 +239,25 @@ Enable the auto-scheduler to automatically capture passes:
|
|||||||
- Starts SatDump at the correct time and frequency
|
- Starts SatDump at the correct time and frequency
|
||||||
- Decoded images are saved with timestamps
|
- Decoded images are saved with timestamps
|
||||||
|
|
||||||
|
## Space Weather
|
||||||
|
|
||||||
|
1. **Switch to Space Weather mode** - Select "Space Weather" from the Space navigation group
|
||||||
|
2. **View Dashboard** - Solar indices, NOAA scales, band conditions, and charts load automatically
|
||||||
|
3. **Solar Imagery** - Toggle between SDO 193A, 304A, and Magnetogram views
|
||||||
|
4. **D-RAP Maps** - Select frequency (5-30 MHz) to view HF radio absorption maps
|
||||||
|
5. **Aurora Forecast** - View the OVATION aurora oval for the northern hemisphere
|
||||||
|
6. **Alerts** - Review current SWPC space weather alerts and warnings
|
||||||
|
7. **Active Regions** - View solar active region data (number, location, area)
|
||||||
|
8. **Refresh** - Data auto-refreshes every 5 minutes, or click "Refresh Now"
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- No SDR hardware required — all data comes from public APIs (NOAA SWPC, NASA SDO, HamQSL)
|
||||||
|
- Check HF band conditions before operating on shortwave frequencies
|
||||||
|
- Kp >= 5 indicates geomagnetic storm conditions that may affect HF propagation
|
||||||
|
- D-RAP maps show where HF absorption is highest — useful for path planning
|
||||||
|
- Solar imagery updates approximately every 15 minutes from NASA SDO
|
||||||
|
|
||||||
## AIS Vessel Tracking
|
## AIS Vessel Tracking
|
||||||
|
|
||||||
1. **Select Hardware** - Choose your SDR type
|
1. **Select Hardware** - Choose your SDR type
|
||||||
@@ -221,6 +273,61 @@ Digital Selective Calling monitoring runs alongside AIS:
|
|||||||
- Distress positions plotted with pulsing alert markers
|
- 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 +390,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 +438,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,148 @@
|
|||||||
<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="space">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></div>
|
||||||
|
<h3>Space Weather</h3>
|
||||||
|
<p>Real-time solar and geomagnetic monitoring. Kp index, HF band conditions, solar imagery, D-RAP maps, and aurora forecasts from NOAA SWPC.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="wireless">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/></svg></div>
|
||||||
|
<h3>WiFi Scanning</h3>
|
||||||
|
<p>Monitor mode reconnaissance via aircrack-ng. Network discovery, client tracking, and handshake capture.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="wireless">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="6"/><path d="M12 16v5"/><path d="M8 21h8"/><path d="M9.5 7.5L12 10l2.5-2.5"/></svg></div>
|
||||||
|
<h3>Bluetooth Scanning</h3>
|
||||||
|
<p>Device discovery with tracker detection for AirTags, Tile, Samsung SmartTag, and other Bluetooth devices.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="wireless">
|
||||||
|
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="7" stroke-dasharray="4 2"/><circle cx="12" cy="12" r="11" stroke-dasharray="2 3"/><line x1="12" y1="1" x2="12" y2="3"/></svg></div>
|
||||||
|
<h3>BT Locate</h3>
|
||||||
|
<p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card" data-category="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 +250,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 +362,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 +402,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 +452,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.18.0"
|
version = "2.20.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
|
||||||
@@ -34,6 +35,8 @@ def register_blueprints(app):
|
|||||||
from .recordings import recordings_bp
|
from .recordings import recordings_bp
|
||||||
from .subghz import subghz_bp
|
from .subghz import subghz_bp
|
||||||
from .bt_locate import bt_locate_bp
|
from .bt_locate import bt_locate_bp
|
||||||
|
from .analytics import analytics_bp
|
||||||
|
from .space_weather import space_weather_bp
|
||||||
|
|
||||||
app.register_blueprint(pager_bp)
|
app.register_blueprint(pager_bp)
|
||||||
app.register_blueprint(sensor_bp)
|
app.register_blueprint(sensor_bp)
|
||||||
@@ -46,6 +49,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)
|
||||||
@@ -67,6 +71,8 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(recordings_bp) # Session recordings
|
app.register_blueprint(recordings_bp) # Session recordings
|
||||||
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
||||||
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
||||||
|
app.register_blueprint(analytics_bp) # Cross-mode analytics dashboard
|
||||||
|
app.register_blueprint(space_weather_bp) # Space weather monitoring
|
||||||
|
|
||||||
# Initialize TSCM state with queue and lock from app
|
# Initialize TSCM state with queue and lock from app
|
||||||
import app as app_module
|
import app as app_module
|
||||||
|
|||||||
@@ -35,11 +35,8 @@ acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
|
|||||||
|
|
||||||
# Default VHF ACARS frequencies (MHz) - common worldwide
|
# Default VHF ACARS frequencies (MHz) - common worldwide
|
||||||
DEFAULT_ACARS_FREQUENCIES = [
|
DEFAULT_ACARS_FREQUENCIES = [
|
||||||
'131.550', # Primary worldwide
|
'131.725', # North America
|
||||||
'130.025', # Secondary USA/Canada
|
'131.825', # North America
|
||||||
'129.125', # USA
|
|
||||||
'131.525', # Europe
|
|
||||||
'131.725', # Europe secondary
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Message counter for statistics
|
# Message counter for statistics
|
||||||
@@ -129,6 +126,13 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
|||||||
|
|
||||||
app_module.acars_queue.put(data)
|
app_module.acars_queue.put(data)
|
||||||
|
|
||||||
|
# Feed flight correlator
|
||||||
|
try:
|
||||||
|
from utils.flight_correlator import get_flight_correlator
|
||||||
|
get_flight_correlator().add_acars_message(data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Log if enabled
|
# Log if enabled
|
||||||
if app_module.logging_enabled:
|
if app_module.logging_enabled:
|
||||||
try:
|
try:
|
||||||
@@ -440,7 +444,7 @@ def get_frequencies() -> Response:
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
'default': DEFAULT_ACARS_FREQUENCIES,
|
'default': DEFAULT_ACARS_FREQUENCIES,
|
||||||
'regions': {
|
'regions': {
|
||||||
'north_america': ['129.125', '130.025', '130.450', '131.550'],
|
'north_america': ['131.725', '131.825'],
|
||||||
'europe': ['131.525', '131.725', '131.550'],
|
'europe': ['131.525', '131.725', '131.550'],
|
||||||
'asia_pacific': ['131.550', '131.450'],
|
'asia_pacific': ['131.550', '131.450'],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -439,6 +439,12 @@ def parse_sbs_stream(service_addr):
|
|||||||
if parts[16]:
|
if parts[16]:
|
||||||
try:
|
try:
|
||||||
aircraft['vertical_rate'] = int(float(parts[16]))
|
aircraft['vertical_rate'] = int(float(parts[16]))
|
||||||
|
if abs(aircraft['vertical_rate']) > 4000:
|
||||||
|
process_event('adsb', {
|
||||||
|
'type': 'vertical_rate_anomaly', 'icao': icao,
|
||||||
|
'callsign': aircraft.get('callsign', ''),
|
||||||
|
'vertical_rate': aircraft['vertical_rate'],
|
||||||
|
}, 'vertical_rate_anomaly')
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -456,6 +462,14 @@ def parse_sbs_stream(service_addr):
|
|||||||
elif msg_type == '6' and len(parts) > 17:
|
elif msg_type == '6' and len(parts) > 17:
|
||||||
if parts[17]:
|
if parts[17]:
|
||||||
aircraft['squawk'] = parts[17]
|
aircraft['squawk'] = parts[17]
|
||||||
|
sq = parts[17].strip()
|
||||||
|
_EMERGENCY_SQUAWKS = {'7700': 'General Emergency', '7600': 'Comms Failure', '7500': 'Hijack'}
|
||||||
|
if sq in _EMERGENCY_SQUAWKS:
|
||||||
|
process_event('adsb', {
|
||||||
|
'type': 'squawk_emergency', 'icao': icao,
|
||||||
|
'callsign': aircraft.get('callsign', ''),
|
||||||
|
'squawk': sq, 'meaning': _EMERGENCY_SQUAWKS[sq],
|
||||||
|
}, 'squawk_emergency')
|
||||||
|
|
||||||
app_module.adsb_aircraft.set(icao, aircraft)
|
app_module.adsb_aircraft.set(icao, aircraft)
|
||||||
pending_updates.add(icao)
|
pending_updates.add(icao)
|
||||||
@@ -488,6 +502,19 @@ def parse_sbs_stream(service_addr):
|
|||||||
'source_host': service_addr,
|
'source_host': service_addr,
|
||||||
'snapshot': snapshot,
|
'snapshot': snapshot,
|
||||||
})
|
})
|
||||||
|
# Geofence check
|
||||||
|
_gf_lat = snapshot.get('lat')
|
||||||
|
_gf_lon = snapshot.get('lon')
|
||||||
|
if _gf_lat and _gf_lon:
|
||||||
|
try:
|
||||||
|
from utils.geofence import get_geofence_manager
|
||||||
|
for _gf_evt in get_geofence_manager().check_position(
|
||||||
|
update_icao, 'aircraft', _gf_lat, _gf_lon,
|
||||||
|
{'callsign': snapshot.get('callsign'), 'altitude': snapshot.get('altitude')}
|
||||||
|
):
|
||||||
|
process_event('adsb', _gf_evt, 'geofence')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
pending_updates.clear()
|
pending_updates.clear()
|
||||||
last_update = now
|
last_update = now
|
||||||
|
|
||||||
@@ -1103,3 +1130,17 @@ def aircraft_photo(registration: str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Error fetching aircraft photo: {e}")
|
logger.debug(f"Error fetching aircraft photo: {e}")
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@adsb_bp.route('/aircraft/<icao>/messages')
|
||||||
|
def get_aircraft_messages(icao: str):
|
||||||
|
"""Get correlated ACARS/VDL2 messages for an aircraft."""
|
||||||
|
if not icao or not all(c in '0123456789ABCDEFabcdef' for c in icao):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid ICAO'}), 400
|
||||||
|
|
||||||
|
aircraft = app_module.adsb_aircraft.get(icao.upper())
|
||||||
|
callsign = aircraft.get('callsign') if aircraft else None
|
||||||
|
|
||||||
|
from utils.flight_correlator import get_flight_correlator
|
||||||
|
messages = get_flight_correlator().get_messages_for_aircraft(icao=icao.upper(), callsign=callsign)
|
||||||
|
return jsonify({'status': 'success', 'icao': icao.upper(), **messages})
|
||||||
|
|||||||
@@ -124,13 +124,27 @@ def parse_ais_stream(port: int):
|
|||||||
if now - last_update >= AIS_UPDATE_INTERVAL:
|
if now - last_update >= AIS_UPDATE_INTERVAL:
|
||||||
for mmsi in pending_updates:
|
for mmsi in pending_updates:
|
||||||
if mmsi in app_module.ais_vessels:
|
if mmsi in app_module.ais_vessels:
|
||||||
|
_vessel_snap = app_module.ais_vessels[mmsi]
|
||||||
try:
|
try:
|
||||||
app_module.ais_queue.put_nowait({
|
app_module.ais_queue.put_nowait({
|
||||||
'type': 'vessel',
|
'type': 'vessel',
|
||||||
**app_module.ais_vessels[mmsi]
|
**_vessel_snap
|
||||||
})
|
})
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
pass
|
pass
|
||||||
|
# Geofence check
|
||||||
|
_v_lat = _vessel_snap.get('lat')
|
||||||
|
_v_lon = _vessel_snap.get('lon')
|
||||||
|
if _v_lat and _v_lon:
|
||||||
|
try:
|
||||||
|
from utils.geofence import get_geofence_manager
|
||||||
|
for _gf_evt in get_geofence_manager().check_position(
|
||||||
|
mmsi, 'vessel', _v_lat, _v_lon,
|
||||||
|
{'name': _vessel_snap.get('name'), 'ship_type': _vessel_snap.get('ship_type_text')}
|
||||||
|
):
|
||||||
|
process_event('ais', _gf_evt, 'geofence')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
pending_updates.clear()
|
pending_updates.clear()
|
||||||
last_update = now
|
last_update = now
|
||||||
|
|
||||||
@@ -282,6 +296,16 @@ def process_ais_message(msg: dict) -> dict | None:
|
|||||||
# Timestamp
|
# Timestamp
|
||||||
vessel['last_seen'] = time.time()
|
vessel['last_seen'] = time.time()
|
||||||
|
|
||||||
|
# Check for DSC DISTRESS matching this MMSI
|
||||||
|
try:
|
||||||
|
for _dsc_key, _dsc_msg in app_module.dsc_messages.items():
|
||||||
|
if (str(_dsc_msg.get('source_mmsi', '')) == mmsi
|
||||||
|
and _dsc_msg.get('category', '').upper() == 'DISTRESS'):
|
||||||
|
vessel['dsc_distress'] = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return vessel
|
return vessel
|
||||||
|
|
||||||
|
|
||||||
@@ -502,6 +526,23 @@ def stream_ais():
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@ais_bp.route('/vessel/<mmsi>/dsc')
|
||||||
|
def get_vessel_dsc(mmsi: str):
|
||||||
|
"""Get DSC messages associated with a vessel MMSI."""
|
||||||
|
if not mmsi or not mmsi.isdigit():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid MMSI'}), 400
|
||||||
|
|
||||||
|
matches = []
|
||||||
|
try:
|
||||||
|
for key, msg in app_module.dsc_messages.items():
|
||||||
|
if str(msg.get('source_mmsi', '')) == mmsi:
|
||||||
|
matches.append(dict(msg))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify({'status': 'success', 'mmsi': mmsi, 'dsc_messages': matches})
|
||||||
|
|
||||||
|
|
||||||
@ais_bp.route('/dashboard')
|
@ais_bp.route('/dashboard')
|
||||||
def ais_dashboard():
|
def ais_dashboard():
|
||||||
"""Popout AIS dashboard."""
|
"""Popout AIS dashboard."""
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""Analytics dashboard: cross-mode summary, activity sparklines, export, geofence CRUD."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
|
import app as app_module
|
||||||
|
from utils.analytics import (
|
||||||
|
get_activity_tracker,
|
||||||
|
get_cross_mode_summary,
|
||||||
|
get_emergency_squawks,
|
||||||
|
get_mode_health,
|
||||||
|
)
|
||||||
|
from utils.flight_correlator import get_flight_correlator
|
||||||
|
from utils.geofence import get_geofence_manager
|
||||||
|
from utils.temporal_patterns import get_pattern_detector
|
||||||
|
|
||||||
|
analytics_bp = Blueprint('analytics', __name__, url_prefix='/analytics')
|
||||||
|
|
||||||
|
|
||||||
|
# Map mode names to DataStore attribute(s)
|
||||||
|
MODE_STORES: dict[str, list[str]] = {
|
||||||
|
'adsb': ['adsb_aircraft'],
|
||||||
|
'ais': ['ais_vessels'],
|
||||||
|
'wifi': ['wifi_networks', 'wifi_clients'],
|
||||||
|
'bluetooth': ['bt_devices'],
|
||||||
|
'dsc': ['dsc_messages'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@analytics_bp.route('/summary')
|
||||||
|
def analytics_summary():
|
||||||
|
"""Return cross-mode counts, health, and emergency squawks."""
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'counts': get_cross_mode_summary(),
|
||||||
|
'health': get_mode_health(),
|
||||||
|
'squawks': get_emergency_squawks(),
|
||||||
|
'flight_messages': {
|
||||||
|
'acars': get_flight_correlator().acars_count,
|
||||||
|
'vdl2': get_flight_correlator().vdl2_count,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@analytics_bp.route('/activity')
|
||||||
|
def analytics_activity():
|
||||||
|
"""Return sparkline arrays for each mode."""
|
||||||
|
tracker = get_activity_tracker()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'sparklines': tracker.get_all_sparklines(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@analytics_bp.route('/squawks')
|
||||||
|
def analytics_squawks():
|
||||||
|
"""Return current emergency squawk codes from ADS-B."""
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'squawks': get_emergency_squawks(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@analytics_bp.route('/patterns')
|
||||||
|
def analytics_patterns():
|
||||||
|
"""Return detected temporal patterns."""
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'patterns': get_pattern_detector().get_all_patterns(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@analytics_bp.route('/export/<mode>')
|
||||||
|
def analytics_export(mode: str):
|
||||||
|
"""Export current DataStore contents as JSON or CSV."""
|
||||||
|
fmt = request.args.get('format', 'json').lower()
|
||||||
|
|
||||||
|
if mode == 'sensor':
|
||||||
|
# Sensor doesn't use DataStore; return recent queue-based data
|
||||||
|
return jsonify({'status': 'success', 'data': [], 'message': 'Sensor data is stream-only'})
|
||||||
|
|
||||||
|
store_names = MODE_STORES.get(mode)
|
||||||
|
if not store_names:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Unknown mode: {mode}'}), 400
|
||||||
|
|
||||||
|
all_items: list[dict] = []
|
||||||
|
|
||||||
|
# Try v2 scanners first for wifi/bluetooth
|
||||||
|
if mode == 'wifi':
|
||||||
|
try:
|
||||||
|
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
||||||
|
if wifi_scanner is not None:
|
||||||
|
for ap in wifi_scanner.access_points:
|
||||||
|
all_items.append(ap.to_dict())
|
||||||
|
for client in wifi_scanner.clients:
|
||||||
|
item = client.to_dict()
|
||||||
|
item['_store'] = 'wifi_clients'
|
||||||
|
all_items.append(item)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif mode == 'bluetooth':
|
||||||
|
try:
|
||||||
|
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
||||||
|
if bt_scanner is not None:
|
||||||
|
for dev in bt_scanner.get_devices():
|
||||||
|
all_items.append(dev.to_dict())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fall back to legacy DataStores if v2 scanners yielded nothing
|
||||||
|
if not all_items:
|
||||||
|
for store_name in store_names:
|
||||||
|
store = getattr(app_module, store_name, None)
|
||||||
|
if store is None:
|
||||||
|
continue
|
||||||
|
for key, value in store.items():
|
||||||
|
item = dict(value) if isinstance(value, dict) else {'id': key, 'value': value}
|
||||||
|
item.setdefault('_store', store_name)
|
||||||
|
all_items.append(item)
|
||||||
|
|
||||||
|
if fmt == 'csv':
|
||||||
|
if not all_items:
|
||||||
|
output = ''
|
||||||
|
else:
|
||||||
|
# Collect all keys across items
|
||||||
|
fieldnames: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in all_items:
|
||||||
|
for k in item:
|
||||||
|
if k not in seen:
|
||||||
|
fieldnames.append(k)
|
||||||
|
seen.add(k)
|
||||||
|
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction='ignore')
|
||||||
|
writer.writeheader()
|
||||||
|
for item in all_items:
|
||||||
|
# Serialize non-scalar values
|
||||||
|
row = {}
|
||||||
|
for k in fieldnames:
|
||||||
|
v = item.get(k)
|
||||||
|
if isinstance(v, (dict, list)):
|
||||||
|
row[k] = json.dumps(v)
|
||||||
|
else:
|
||||||
|
row[k] = v
|
||||||
|
writer.writerow(row)
|
||||||
|
output = buf.getvalue()
|
||||||
|
|
||||||
|
response = Response(output, mimetype='text/csv')
|
||||||
|
response.headers['Content-Disposition'] = f'attachment; filename={mode}_export.csv'
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Default: JSON
|
||||||
|
return jsonify({'status': 'success', 'mode': mode, 'count': len(all_items), 'data': all_items})
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Geofence CRUD
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@analytics_bp.route('/geofences')
|
||||||
|
def list_geofences():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'zones': get_geofence_manager().list_zones(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@analytics_bp.route('/geofences', methods=['POST'])
|
||||||
|
def create_geofence():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
name = data.get('name')
|
||||||
|
lat = data.get('lat')
|
||||||
|
lon = data.get('lon')
|
||||||
|
radius_m = data.get('radius_m')
|
||||||
|
|
||||||
|
if not all([name, lat is not None, lon is not None, radius_m is not None]):
|
||||||
|
return jsonify({'status': 'error', 'message': 'name, lat, lon, radius_m are required'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
lat = float(lat)
|
||||||
|
lon = float(lon)
|
||||||
|
radius_m = float(radius_m)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({'status': 'error', 'message': 'lat, lon, radius_m must be numbers'}), 400
|
||||||
|
|
||||||
|
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400
|
||||||
|
if radius_m <= 0:
|
||||||
|
return jsonify({'status': 'error', 'message': 'radius_m must be positive'}), 400
|
||||||
|
|
||||||
|
alert_on = data.get('alert_on', 'enter_exit')
|
||||||
|
zone_id = get_geofence_manager().add_zone(name, lat, lon, radius_m, alert_on)
|
||||||
|
return jsonify({'status': 'success', 'zone_id': zone_id})
|
||||||
|
|
||||||
|
|
||||||
|
@analytics_bp.route('/geofences/<int:zone_id>', methods=['DELETE'])
|
||||||
|
def delete_geofence(zone_id: int):
|
||||||
|
ok = get_geofence_manager().delete_zone(zone_id)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Zone not found'}), 404
|
||||||
|
return jsonify({'status': 'success'})
|
||||||
@@ -19,16 +19,16 @@ from typing import Generator, Optional
|
|||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.logging import sensor_logger as logger
|
from utils.logging import sensor_logger as logger
|
||||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.event_pipeline import process_event
|
from utils.event_pipeline import process_event
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
from utils.constants import (
|
from utils.constants import (
|
||||||
PROCESS_TERMINATE_TIMEOUT,
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
SSE_KEEPALIVE_INTERVAL,
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
SSE_QUEUE_TIMEOUT,
|
SSE_QUEUE_TIMEOUT,
|
||||||
PROCESS_START_WAIT,
|
PROCESS_START_WAIT,
|
||||||
)
|
)
|
||||||
|
|
||||||
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
||||||
@@ -47,6 +47,7 @@ APRS_FREQUENCIES = {
|
|||||||
'brazil': '145.570',
|
'brazil': '145.570',
|
||||||
'japan': '144.640',
|
'japan': '144.640',
|
||||||
'china': '144.640',
|
'china': '144.640',
|
||||||
|
'iss': '145.825',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Statistics
|
# Statistics
|
||||||
@@ -73,19 +74,19 @@ def find_multimon_ng() -> Optional[str]:
|
|||||||
return shutil.which('multimon-ng')
|
return shutil.which('multimon-ng')
|
||||||
|
|
||||||
|
|
||||||
def find_rtl_fm() -> Optional[str]:
|
def find_rtl_fm() -> Optional[str]:
|
||||||
"""Find rtl_fm binary."""
|
"""Find rtl_fm binary."""
|
||||||
return shutil.which('rtl_fm')
|
return shutil.which('rtl_fm')
|
||||||
|
|
||||||
|
|
||||||
def find_rx_fm() -> Optional[str]:
|
def find_rx_fm() -> Optional[str]:
|
||||||
"""Find SoapySDR rx_fm binary."""
|
"""Find SoapySDR rx_fm binary."""
|
||||||
return shutil.which('rx_fm')
|
return shutil.which('rx_fm')
|
||||||
|
|
||||||
|
|
||||||
def find_rtl_power() -> Optional[str]:
|
def find_rtl_power() -> Optional[str]:
|
||||||
"""Find rtl_power binary for spectrum scanning."""
|
"""Find rtl_power binary for spectrum scanning."""
|
||||||
return shutil.which('rtl_power')
|
return shutil.which('rtl_power')
|
||||||
|
|
||||||
|
|
||||||
# Path to direwolf config file
|
# Path to direwolf config file
|
||||||
@@ -1378,6 +1379,19 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
|||||||
'last_seen': packet.get('timestamp'),
|
'last_seen': packet.get('timestamp'),
|
||||||
'packet_type': packet.get('packet_type'),
|
'packet_type': packet.get('packet_type'),
|
||||||
}
|
}
|
||||||
|
# Geofence check
|
||||||
|
_aprs_lat = packet.get('lat')
|
||||||
|
_aprs_lon = packet.get('lon')
|
||||||
|
if _aprs_lat and _aprs_lon:
|
||||||
|
try:
|
||||||
|
from utils.geofence import get_geofence_manager
|
||||||
|
for _gf_evt in get_geofence_manager().check_position(
|
||||||
|
callsign, 'aprs_station', _aprs_lat, _aprs_lon,
|
||||||
|
{'callsign': callsign}
|
||||||
|
):
|
||||||
|
process_event('aprs', _gf_evt, 'geofence')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# Evict oldest stations when limit is exceeded
|
# Evict oldest stations when limit is exceeded
|
||||||
if len(aprs_stations) > APRS_MAX_STATIONS:
|
if len(aprs_stations) > APRS_MAX_STATIONS:
|
||||||
oldest = min(
|
oldest = min(
|
||||||
@@ -1420,22 +1434,22 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
|||||||
|
|
||||||
|
|
||||||
@aprs_bp.route('/tools')
|
@aprs_bp.route('/tools')
|
||||||
def check_aprs_tools() -> Response:
|
def check_aprs_tools() -> Response:
|
||||||
"""Check for APRS decoding tools."""
|
"""Check for APRS decoding tools."""
|
||||||
has_rtl_fm = find_rtl_fm() is not None
|
has_rtl_fm = find_rtl_fm() is not None
|
||||||
has_rx_fm = find_rx_fm() is not None
|
has_rx_fm = find_rx_fm() is not None
|
||||||
has_direwolf = find_direwolf() is not None
|
has_direwolf = find_direwolf() is not None
|
||||||
has_multimon = find_multimon_ng() is not None
|
has_multimon = find_multimon_ng() is not None
|
||||||
has_fm_demod = has_rtl_fm or has_rx_fm
|
has_fm_demod = has_rtl_fm or has_rx_fm
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'rtl_fm': has_rtl_fm,
|
'rtl_fm': has_rtl_fm,
|
||||||
'rx_fm': has_rx_fm,
|
'rx_fm': has_rx_fm,
|
||||||
'direwolf': has_direwolf,
|
'direwolf': has_direwolf,
|
||||||
'multimon_ng': has_multimon,
|
'multimon_ng': has_multimon,
|
||||||
'ready': has_fm_demod and (has_direwolf or has_multimon),
|
'ready': has_fm_demod and (has_direwolf or has_multimon),
|
||||||
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
|
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@aprs_bp.route('/status')
|
@aprs_bp.route('/status')
|
||||||
@@ -1476,12 +1490,12 @@ def start_aprs() -> Response:
|
|||||||
'message': 'APRS decoder already running'
|
'message': 'APRS decoder already running'
|
||||||
}), 409
|
}), 409
|
||||||
|
|
||||||
# Check for decoder (prefer direwolf, fallback to multimon-ng)
|
# Check for decoder (prefer direwolf, fallback to multimon-ng)
|
||||||
direwolf_path = find_direwolf()
|
direwolf_path = find_direwolf()
|
||||||
multimon_path = find_multimon_ng()
|
multimon_path = find_multimon_ng()
|
||||||
|
|
||||||
if not direwolf_path and not multimon_path:
|
if not direwolf_path and not multimon_path:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'No APRS decoder found. Install direwolf or multimon-ng'
|
'message': 'No APRS decoder found. Install direwolf or multimon-ng'
|
||||||
}), 400
|
}), 400
|
||||||
@@ -1489,31 +1503,31 @@ def start_aprs() -> Response:
|
|||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
# Validate inputs
|
# Validate inputs
|
||||||
try:
|
try:
|
||||||
device = validate_device_index(data.get('device', '0'))
|
device = validate_device_index(data.get('device', '0'))
|
||||||
gain = validate_gain(data.get('gain', '40'))
|
gain = validate_gain(data.get('gain', '40'))
|
||||||
ppm = validate_ppm(data.get('ppm', '0'))
|
ppm = validate_ppm(data.get('ppm', '0'))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||||
try:
|
try:
|
||||||
sdr_type = SDRType(sdr_type_str)
|
sdr_type = SDRType(sdr_type_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
sdr_type = SDRType.RTL_SDR
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
if sdr_type == SDRType.RTL_SDR:
|
if sdr_type == SDRType.RTL_SDR:
|
||||||
if find_rtl_fm() is None:
|
if find_rtl_fm() is None:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
|
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
|
||||||
}), 400
|
}), 400
|
||||||
else:
|
else:
|
||||||
if find_rx_fm() is None:
|
if find_rx_fm() is None:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
|
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
# Reserve SDR device to prevent conflicts with other modes
|
# Reserve SDR device to prevent conflicts with other modes
|
||||||
error = app_module.claim_sdr_device(device, 'aprs')
|
error = app_module.claim_sdr_device(device, 'aprs')
|
||||||
@@ -1545,29 +1559,29 @@ def start_aprs() -> Response:
|
|||||||
aprs_last_packet_time = None
|
aprs_last_packet_time = None
|
||||||
aprs_stations = {}
|
aprs_stations = {}
|
||||||
|
|
||||||
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
|
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
|
||||||
try:
|
try:
|
||||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||||
builder = SDRFactory.get_builder(sdr_type)
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
rtl_cmd = builder.build_fm_demod_command(
|
rtl_cmd = builder.build_fm_demod_command(
|
||||||
device=sdr_device,
|
device=sdr_device,
|
||||||
frequency_mhz=float(frequency),
|
frequency_mhz=float(frequency),
|
||||||
sample_rate=22050,
|
sample_rate=22050,
|
||||||
gain=float(gain) if gain and str(gain) != '0' else None,
|
gain=float(gain) if gain and str(gain) != '0' else None,
|
||||||
ppm=int(ppm) if ppm and str(ppm) != '0' else None,
|
ppm=int(ppm) if ppm and str(ppm) != '0' else None,
|
||||||
modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm',
|
modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm',
|
||||||
squelch=None,
|
squelch=None,
|
||||||
bias_t=bool(data.get('bias_t', False)),
|
bias_t=bool(data.get('bias_t', False)),
|
||||||
)
|
)
|
||||||
|
|
||||||
if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-':
|
if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-':
|
||||||
# APRS benefits from DC blocking + fast AGC on rtl_fm.
|
# APRS benefits from DC blocking + fast AGC on rtl_fm.
|
||||||
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
|
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if aprs_active_device is not None:
|
if aprs_active_device is not None:
|
||||||
app_module.release_sdr_device(aprs_active_device)
|
app_module.release_sdr_device(aprs_active_device)
|
||||||
aprs_active_device = None
|
aprs_active_device = None
|
||||||
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
||||||
|
|
||||||
# Build decoder command
|
# Build decoder command
|
||||||
if direwolf_path:
|
if direwolf_path:
|
||||||
@@ -1690,14 +1704,14 @@ def start_aprs() -> Response:
|
|||||||
)
|
)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
'frequency': frequency,
|
'frequency': frequency,
|
||||||
'region': region,
|
'region': region,
|
||||||
'device': device,
|
'device': device,
|
||||||
'sdr_type': sdr_type.value,
|
'sdr_type': sdr_type.value,
|
||||||
'decoder': decoder_name
|
'decoder': decoder_name
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to start APRS decoder: {e}")
|
logger.error(f"Failed to start APRS decoder: {e}")
|
||||||
|
|||||||
@@ -1051,3 +1051,19 @@ def request_store_forward():
|
|||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': error or 'Failed to request S&F history'
|
'message': error or 'Failed to request S&F history'
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@meshtastic_bp.route('/topology')
|
||||||
|
def mesh_topology():
|
||||||
|
"""Return mesh network topology graph."""
|
||||||
|
if not is_meshtastic_available():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Meshtastic SDK not installed'}), 400
|
||||||
|
|
||||||
|
client = get_meshtastic_client()
|
||||||
|
if not client or not client.is_running:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Not connected'}), 400
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'topology': client.get_topology(),
|
||||||
|
})
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ sensor_bp = Blueprint('sensor', __name__)
|
|||||||
# Track which device is being used
|
# Track which device is being used
|
||||||
sensor_active_device: int | None = None
|
sensor_active_device: int | None = None
|
||||||
|
|
||||||
|
# RSSI history per device (model_id -> list of (timestamp, rssi))
|
||||||
|
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
|
||||||
|
_MAX_RSSI_HISTORY = 60
|
||||||
|
|
||||||
|
|
||||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||||
"""Stream rtl_433 JSON output to queue."""
|
"""Stream rtl_433 JSON output to queue."""
|
||||||
@@ -45,6 +49,17 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
|||||||
data['type'] = 'sensor'
|
data['type'] = 'sensor'
|
||||||
app_module.sensor_queue.put(data)
|
app_module.sensor_queue.put(data)
|
||||||
|
|
||||||
|
# Track RSSI history per device
|
||||||
|
_model = data.get('model', '')
|
||||||
|
_dev_id = data.get('id', '')
|
||||||
|
_rssi_val = data.get('rssi')
|
||||||
|
if _rssi_val is not None and _model:
|
||||||
|
_hist_key = f"{_model}_{_dev_id}"
|
||||||
|
hist = sensor_rssi_history.setdefault(_hist_key, [])
|
||||||
|
hist.append((time.time(), float(_rssi_val)))
|
||||||
|
if len(hist) > _MAX_RSSI_HISTORY:
|
||||||
|
del hist[: len(hist) - _MAX_RSSI_HISTORY]
|
||||||
|
|
||||||
# Push scope event when signal level data is present
|
# Push scope event when signal level data is present
|
||||||
rssi = data.get('rssi')
|
rssi = data.get('rssi')
|
||||||
snr = data.get('snr')
|
snr = data.get('snr')
|
||||||
@@ -283,3 +298,12 @@ def stream_sensor() -> Response:
|
|||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@sensor_bp.route('/sensor/rssi_history')
|
||||||
|
def get_rssi_history() -> Response:
|
||||||
|
"""Return RSSI history for all tracked sensor devices."""
|
||||||
|
result = {}
|
||||||
|
for key, entries in sensor_rssi_history.items():
|
||||||
|
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
|
||||||
|
return jsonify({'status': 'success', 'devices': result})
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""Space Weather routes - proxies NOAA SWPC, NASA SDO, and HamQSL data."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, jsonify
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('intercept.space_weather')
|
||||||
|
|
||||||
|
space_weather_bp = Blueprint('space_weather', __name__, url_prefix='/space-weather')
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TTL Cache
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_cache: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
# Cache TTLs in seconds
|
||||||
|
TTL_REALTIME = 300 # 5 min for real-time data
|
||||||
|
TTL_FORECAST = 1800 # 30 min for forecasts
|
||||||
|
TTL_DAILY = 3600 # 1 hr for daily summaries
|
||||||
|
TTL_IMAGE = 600 # 10 min for images
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_get(key: str) -> Any | None:
|
||||||
|
entry = _cache.get(key)
|
||||||
|
if entry and time.time() < entry['expires']:
|
||||||
|
return entry['data']
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_set(key: str, data: Any, ttl: int) -> None:
|
||||||
|
_cache[key] = {'data': data, 'expires': time.time() + ttl}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HTTP helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_TIMEOUT = 15 # seconds
|
||||||
|
|
||||||
|
SWPC_BASE = 'https://services.swpc.noaa.gov'
|
||||||
|
SWPC_JSON = f'{SWPC_BASE}/products'
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_json(url: str, timeout: int = _TIMEOUT) -> Any | None:
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'})
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('Failed to fetch %s: %s', url, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_text(url: str, timeout: int = _TIMEOUT) -> str | None:
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'})
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return resp.read().decode()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('Failed to fetch %s: %s', url, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_bytes(url: str, timeout: int = _TIMEOUT) -> bytes | None:
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'})
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
return resp.read()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning('Failed to fetch %s: %s', url, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data source fetchers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _fetch_cached_json(cache_key: str, url: str, ttl: int) -> Any | None:
|
||||||
|
cached = _cache_get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
data = _fetch_json(url)
|
||||||
|
if data is not None:
|
||||||
|
_cache_set(cache_key, data, ttl)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_kp_index() -> Any | None:
|
||||||
|
return _fetch_cached_json('kp_index', f'{SWPC_JSON}/noaa-planetary-k-index.json', TTL_REALTIME)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_kp_forecast() -> Any | None:
|
||||||
|
return _fetch_cached_json('kp_forecast', f'{SWPC_JSON}/noaa-planetary-k-index-forecast.json', TTL_FORECAST)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_scales() -> Any | None:
|
||||||
|
return _fetch_cached_json('scales', f'{SWPC_JSON}/noaa-scales.json', TTL_REALTIME)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_flux() -> Any | None:
|
||||||
|
return _fetch_cached_json('flux', f'{SWPC_JSON}/10cm-flux-30-day.json', TTL_DAILY)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_alerts() -> Any | None:
|
||||||
|
return _fetch_cached_json('alerts', f'{SWPC_JSON}/alerts.json', TTL_REALTIME)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_solar_wind_plasma() -> Any | None:
|
||||||
|
return _fetch_cached_json('sw_plasma', f'{SWPC_JSON}/solar-wind/plasma-6-hour.json', TTL_REALTIME)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_solar_wind_mag() -> Any | None:
|
||||||
|
return _fetch_cached_json('sw_mag', f'{SWPC_JSON}/solar-wind/mag-6-hour.json', TTL_REALTIME)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_xrays() -> Any | None:
|
||||||
|
return _fetch_cached_json('xrays', f'{SWPC_BASE}/json/goes/primary/xrays-1-day.json', TTL_REALTIME)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_xray_flares() -> Any | None:
|
||||||
|
return _fetch_cached_json('xray_flares', f'{SWPC_BASE}/json/goes/primary/xray-flares-7-day.json', TTL_REALTIME)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_flare_probability() -> Any | None:
|
||||||
|
return _fetch_cached_json('flare_prob', f'{SWPC_BASE}/json/solar_probabilities.json', TTL_FORECAST)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_solar_regions() -> Any | None:
|
||||||
|
return _fetch_cached_json('solar_regions', f'{SWPC_BASE}/json/solar_regions.json', TTL_DAILY)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_sunspot_report() -> Any | None:
|
||||||
|
return _fetch_cached_json('sunspot_report', f'{SWPC_BASE}/json/sunspot_report.json', TTL_DAILY)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_hamqsl_xml(xml_text: str) -> dict[str, Any] | None:
|
||||||
|
"""Parse HamQSL solar XML into a dict of band conditions."""
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(xml_text)
|
||||||
|
solar = root.find('.//solardata')
|
||||||
|
if solar is None:
|
||||||
|
return None
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
# Scalar fields
|
||||||
|
for tag in ('sfi', 'aindex', 'kindex', 'kindexnt', 'xray', 'sunspots',
|
||||||
|
'heliumline', 'protonflux', 'electonflux', 'aurora',
|
||||||
|
'normalization', 'latdegree', 'solarwind', 'magneticfield',
|
||||||
|
'calculatedconditions', 'calculatedvhfconditions',
|
||||||
|
'geomagfield', 'signalnoise', 'fof2', 'muffactor', 'muf'):
|
||||||
|
el = solar.find(tag)
|
||||||
|
if el is not None and el.text:
|
||||||
|
result[tag] = el.text.strip()
|
||||||
|
# Band conditions
|
||||||
|
bands: list[dict[str, str]] = []
|
||||||
|
for band_el in solar.findall('.//calculatedconditions/band'):
|
||||||
|
bands.append({
|
||||||
|
'name': band_el.get('name', ''),
|
||||||
|
'time': band_el.get('time', ''),
|
||||||
|
'condition': band_el.text.strip() if band_el.text else ''
|
||||||
|
})
|
||||||
|
result['bands'] = bands
|
||||||
|
# VHF conditions
|
||||||
|
vhf: list[dict[str, str]] = []
|
||||||
|
for phen_el in solar.findall('.//calculatedvhfconditions/phenomenon'):
|
||||||
|
vhf.append({
|
||||||
|
'name': phen_el.get('name', ''),
|
||||||
|
'location': phen_el.get('location', ''),
|
||||||
|
'condition': phen_el.text.strip() if phen_el.text else ''
|
||||||
|
})
|
||||||
|
result['vhf'] = vhf
|
||||||
|
return result
|
||||||
|
except ET.ParseError as exc:
|
||||||
|
logger.warning('Failed to parse HamQSL XML: %s', exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_band_conditions() -> dict[str, Any] | None:
|
||||||
|
cached = _cache_get('band_conditions')
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
xml_text = _fetch_text('https://www.hamqsl.com/solarxml.php')
|
||||||
|
if xml_text is None:
|
||||||
|
return None
|
||||||
|
data = _parse_hamqsl_xml(xml_text)
|
||||||
|
if data is not None:
|
||||||
|
_cache_set('band_conditions', data, TTL_FORECAST)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Image proxy whitelist
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
IMAGE_WHITELIST: dict[str, dict[str, str]] = {
|
||||||
|
# D-RAP absorption maps
|
||||||
|
'drap_global': {
|
||||||
|
'url': f'{SWPC_BASE}/images/animations/d-rap/global/latest.png',
|
||||||
|
'content_type': 'image/png',
|
||||||
|
},
|
||||||
|
'drap_5': {
|
||||||
|
'url': f'{SWPC_BASE}/images/d-rap/global_f05.png',
|
||||||
|
'content_type': 'image/png',
|
||||||
|
},
|
||||||
|
'drap_10': {
|
||||||
|
'url': f'{SWPC_BASE}/images/d-rap/global_f10.png',
|
||||||
|
'content_type': 'image/png',
|
||||||
|
},
|
||||||
|
'drap_15': {
|
||||||
|
'url': f'{SWPC_BASE}/images/d-rap/global_f15.png',
|
||||||
|
'content_type': 'image/png',
|
||||||
|
},
|
||||||
|
'drap_20': {
|
||||||
|
'url': f'{SWPC_BASE}/images/d-rap/global_f20.png',
|
||||||
|
'content_type': 'image/png',
|
||||||
|
},
|
||||||
|
'drap_25': {
|
||||||
|
'url': f'{SWPC_BASE}/images/d-rap/global_f25.png',
|
||||||
|
'content_type': 'image/png',
|
||||||
|
},
|
||||||
|
'drap_30': {
|
||||||
|
'url': f'{SWPC_BASE}/images/d-rap/global_f30.png',
|
||||||
|
'content_type': 'image/png',
|
||||||
|
},
|
||||||
|
# Aurora forecast
|
||||||
|
'aurora_north': {
|
||||||
|
'url': f'{SWPC_BASE}/images/animations/ovation/north/latest.jpg',
|
||||||
|
'content_type': 'image/jpeg',
|
||||||
|
},
|
||||||
|
# SDO solar imagery
|
||||||
|
'sdo_193': {
|
||||||
|
'url': 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0193.jpg',
|
||||||
|
'content_type': 'image/jpeg',
|
||||||
|
},
|
||||||
|
'sdo_304': {
|
||||||
|
'url': 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0304.jpg',
|
||||||
|
'content_type': 'image/jpeg',
|
||||||
|
},
|
||||||
|
'sdo_magnetogram': {
|
||||||
|
'url': 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_HMIBC.jpg',
|
||||||
|
'content_type': 'image/jpeg',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@space_weather_bp.route('/data')
|
||||||
|
def get_data():
|
||||||
|
"""Return aggregated space weather data from all sources."""
|
||||||
|
data = {
|
||||||
|
'kp_index': _fetch_kp_index(),
|
||||||
|
'kp_forecast': _fetch_kp_forecast(),
|
||||||
|
'scales': _fetch_scales(),
|
||||||
|
'flux': _fetch_flux(),
|
||||||
|
'alerts': _fetch_alerts(),
|
||||||
|
'solar_wind_plasma': _fetch_solar_wind_plasma(),
|
||||||
|
'solar_wind_mag': _fetch_solar_wind_mag(),
|
||||||
|
'xrays': _fetch_xrays(),
|
||||||
|
'xray_flares': _fetch_xray_flares(),
|
||||||
|
'flare_probability': _fetch_flare_probability(),
|
||||||
|
'solar_regions': _fetch_solar_regions(),
|
||||||
|
'sunspot_report': _fetch_sunspot_report(),
|
||||||
|
'band_conditions': _fetch_band_conditions(),
|
||||||
|
'timestamp': time.time(),
|
||||||
|
}
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
|
@space_weather_bp.route('/image/<key>')
|
||||||
|
def get_image(key: str):
|
||||||
|
"""Proxy and cache whitelisted space weather images."""
|
||||||
|
entry = IMAGE_WHITELIST.get(key)
|
||||||
|
if not entry:
|
||||||
|
return jsonify({'error': 'Unknown image key'}), 404
|
||||||
|
|
||||||
|
cache_key = f'img_{key}'
|
||||||
|
cached = _cache_get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return Response(cached, content_type=entry['content_type'],
|
||||||
|
headers={'Cache-Control': 'public, max-age=300'})
|
||||||
|
|
||||||
|
img_data = _fetch_bytes(entry['url'])
|
||||||
|
if img_data is None:
|
||||||
|
return jsonify({'error': 'Failed to fetch image'}), 502
|
||||||
|
|
||||||
|
_cache_set(cache_key, img_data, TTL_IMAGE)
|
||||||
|
return Response(img_data, content_type=entry['content_type'],
|
||||||
|
headers={'Cache-Control': 'public, max-age=300'})
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
"""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)
|
||||||
|
|
||||||
|
# Feed flight correlator
|
||||||
|
try:
|
||||||
|
from utils.flight_correlator import get_flight_correlator
|
||||||
|
get_flight_correlator().add_vdl2_message(data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Log if enabled
|
||||||
|
if app_module.logging_enabled:
|
||||||
|
try:
|
||||||
|
with open(app_module.log_file_path, 'a') as f:
|
||||||
|
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
f.write(f"{ts} | VDL2 | {json.dumps(data)}\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Not JSON - could be status message
|
||||||
|
if line:
|
||||||
|
logger.debug(f"dumpvdl2 non-JSON: {line[:100]}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"VDL2 stream error: {e}")
|
||||||
|
app_module.vdl2_queue.put({'type': 'error', 'message': str(e)})
|
||||||
|
finally:
|
||||||
|
global vdl2_active_device
|
||||||
|
# Ensure process is terminated
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
process.wait(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
unregister_process(process)
|
||||||
|
app_module.vdl2_queue.put({'type': 'status', 'status': 'stopped'})
|
||||||
|
with app_module.vdl2_lock:
|
||||||
|
app_module.vdl2_process = None
|
||||||
|
# Release SDR device
|
||||||
|
if vdl2_active_device is not None:
|
||||||
|
app_module.release_sdr_device(vdl2_active_device)
|
||||||
|
vdl2_active_device = None
|
||||||
|
|
||||||
|
|
||||||
|
@vdl2_bp.route('/tools')
|
||||||
|
def check_vdl2_tools() -> Response:
|
||||||
|
"""Check for VDL2 decoding tools."""
|
||||||
|
has_dumpvdl2 = find_dumpvdl2() is not None
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'dumpvdl2': has_dumpvdl2,
|
||||||
|
'ready': has_dumpvdl2
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@vdl2_bp.route('/status')
|
||||||
|
def vdl2_status() -> Response:
|
||||||
|
"""Get VDL2 decoder status."""
|
||||||
|
running = False
|
||||||
|
if app_module.vdl2_process:
|
||||||
|
running = app_module.vdl2_process.poll() is None
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'running': running,
|
||||||
|
'message_count': vdl2_message_count,
|
||||||
|
'last_message_time': vdl2_last_message_time,
|
||||||
|
'queue_size': app_module.vdl2_queue.qsize()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@vdl2_bp.route('/start', methods=['POST'])
|
||||||
|
def start_vdl2() -> Response:
|
||||||
|
"""Start VDL2 decoder."""
|
||||||
|
global vdl2_message_count, vdl2_last_message_time, vdl2_active_device
|
||||||
|
|
||||||
|
with app_module.vdl2_lock:
|
||||||
|
if app_module.vdl2_process and app_module.vdl2_process.poll() is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'VDL2 decoder already running'
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
# Check for dumpvdl2
|
||||||
|
dumpvdl2_path = find_dumpvdl2()
|
||||||
|
if not dumpvdl2_path:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'dumpvdl2 not found. Install from: https://github.com/szpajder/dumpvdl2'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
# Validate inputs
|
||||||
|
try:
|
||||||
|
device = validate_device_index(data.get('device', '0'))
|
||||||
|
gain = validate_gain(data.get('gain', '40'))
|
||||||
|
ppm = validate_ppm(data.get('ppm', '0'))
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
|
# Check if device is available
|
||||||
|
device_int = int(device)
|
||||||
|
error = app_module.claim_sdr_device(device_int, 'vdl2')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
vdl2_active_device = device_int
|
||||||
|
|
||||||
|
# Get frequencies - use provided or defaults
|
||||||
|
# dumpvdl2 expects frequencies in Hz (integers)
|
||||||
|
frequencies = data.get('frequencies', DEFAULT_VDL2_FREQUENCIES)
|
||||||
|
if isinstance(frequencies, str):
|
||||||
|
frequencies = [f.strip() for f in frequencies.split(',')]
|
||||||
|
|
||||||
|
# Clear queue
|
||||||
|
while not app_module.vdl2_queue.empty():
|
||||||
|
try:
|
||||||
|
app_module.vdl2_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Reset stats
|
||||||
|
vdl2_message_count = 0
|
||||||
|
vdl2_last_message_time = None
|
||||||
|
|
||||||
|
# Resolve SDR type for device selection
|
||||||
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||||
|
try:
|
||||||
|
sdr_type = SDRType(sdr_type_str)
|
||||||
|
except ValueError:
|
||||||
|
sdr_type = SDRType.RTL_SDR
|
||||||
|
|
||||||
|
is_soapy = sdr_type not in (SDRType.RTL_SDR,)
|
||||||
|
|
||||||
|
# Build dumpvdl2 command
|
||||||
|
# dumpvdl2 --output decoded:json --rtlsdr <device> --gain <gain> --correction <ppm> <freq1> <freq2> ...
|
||||||
|
cmd = [dumpvdl2_path]
|
||||||
|
cmd.extend(['--output', 'decoded:json:file:path=-'])
|
||||||
|
|
||||||
|
if is_soapy:
|
||||||
|
# SoapySDR device
|
||||||
|
sdr_device = SDRFactory.create_default_device(sdr_type, index=device_int)
|
||||||
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
|
device_str = builder._build_device_string(sdr_device)
|
||||||
|
cmd.extend(['--soapysdr', device_str])
|
||||||
|
else:
|
||||||
|
cmd.extend(['--rtlsdr', str(device)])
|
||||||
|
|
||||||
|
# Add gain
|
||||||
|
if gain and str(gain) != '0':
|
||||||
|
cmd.extend(['--gain', str(gain)])
|
||||||
|
|
||||||
|
# Add PPM correction if specified
|
||||||
|
if ppm and str(ppm) != '0':
|
||||||
|
cmd.extend(['--correction', str(ppm)])
|
||||||
|
|
||||||
|
# Add frequencies (dumpvdl2 takes them as positional args in Hz)
|
||||||
|
cmd.extend(frequencies)
|
||||||
|
|
||||||
|
logger.info(f"Starting VDL2 decoder: {' '.join(cmd)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
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'],
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -226,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
|
||||||
@@ -312,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,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 \
|
||||||
@@ -635,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..."
|
||||||
|
|
||||||
@@ -687,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)..."
|
||||||
@@ -731,62 +835,64 @@ 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
|
||||||
refresh_sudo
|
|
||||||
$SUDO make install >/dev/null 2>&1
|
hdiutil detach "$tmp_dir/mnt" -quiet 2>/dev/null
|
||||||
fi
|
|
||||||
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -800,7 +906,7 @@ install_macos_packages() {
|
|||||||
sudo -v || { fail "sudo authentication failed"; exit 1; }
|
sudo -v || { fail "sudo authentication failed"; exit 1; }
|
||||||
fi
|
fi
|
||||||
|
|
||||||
TOTAL_STEPS=19
|
TOTAL_STEPS=22
|
||||||
CURRENT_STEP=0
|
CURRENT_STEP=0
|
||||||
|
|
||||||
progress "Checking Homebrew"
|
progress "Checking Homebrew"
|
||||||
@@ -873,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"
|
||||||
@@ -885,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
|
||||||
@@ -977,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
|
||||||
@@ -1018,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
|
||||||
@@ -1027,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..."
|
||||||
|
|
||||||
@@ -1190,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"
|
||||||
@@ -1332,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
|
||||||
@@ -1343,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) {
|
||||||
@@ -6370,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);
|
||||||
@@ -6548,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;
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
/* Analytics Dashboard Styles */
|
||||||
|
|
||||||
|
/* Analytics is a sidebar-only mode — hide the output panel and expand the sidebar */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.main-content.analytics-active {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
}
|
||||||
|
.main-content.analytics-active > .output-panel {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.main-content.analytics-active > .sidebar {
|
||||||
|
max-width: 100% !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
.main-content.analytics-active .sidebar-collapse-btn {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.main-content.analytics-active > .output-panel {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: var(--space-3, 12px);
|
||||||
|
margin-bottom: var(--space-4, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-card {
|
||||||
|
background: var(--bg-card, #151f2b);
|
||||||
|
border: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
padding: var(--space-3, 12px);
|
||||||
|
text-align: center;
|
||||||
|
transition: var(--transition-fast, 150ms ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-card:hover {
|
||||||
|
border-color: var(--accent-cyan, #4aa3ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-card .card-count {
|
||||||
|
font-size: var(--text-2xl, 24px);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #e0e6ed);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-card .card-label {
|
||||||
|
font-size: var(--text-xs, 10px);
|
||||||
|
color: var(--text-dim, #5a6a7a);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-top: var(--space-1, 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-card .card-sparkline {
|
||||||
|
height: 24px;
|
||||||
|
margin-top: var(--space-2, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-card .card-sparkline svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-card .card-sparkline polyline {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--accent-cyan, #4aa3ff);
|
||||||
|
stroke-width: 1.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Health indicators */
|
||||||
|
.analytics-health {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2, 8px);
|
||||||
|
margin-bottom: var(--space-4, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1, 4px);
|
||||||
|
font-size: var(--text-xs, 10px);
|
||||||
|
color: var(--text-dim, #5a6a7a);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-red, #e25d5d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-dot.running {
|
||||||
|
background: var(--accent-green, #38c180);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Emergency squawk panel */
|
||||||
|
.squawk-emergency {
|
||||||
|
background: rgba(226, 93, 93, 0.1);
|
||||||
|
border: 1px solid var(--accent-red, #e25d5d);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
padding: var(--space-3, 12px);
|
||||||
|
margin-bottom: var(--space-3, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.squawk-emergency .squawk-title {
|
||||||
|
color: var(--accent-red, #e25d5d);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--text-sm, 12px);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: var(--space-2, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.squawk-emergency .squawk-item {
|
||||||
|
font-size: var(--text-sm, 12px);
|
||||||
|
color: var(--text-primary, #e0e6ed);
|
||||||
|
padding: var(--space-1, 4px) 0;
|
||||||
|
border-bottom: 1px solid rgba(226, 93, 93, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.squawk-emergency .squawk-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alert feed */
|
||||||
|
.analytics-alert-feed {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-bottom: var(--space-4, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-alert-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-2, 8px);
|
||||||
|
padding: var(--space-2, 8px);
|
||||||
|
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
font-size: var(--text-xs, 10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-alert-item .alert-severity {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 9px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-severity.critical { background: var(--accent-red, #e25d5d); color: #fff; }
|
||||||
|
.alert-severity.high { background: var(--accent-orange, #d6a85e); color: #000; }
|
||||||
|
.alert-severity.medium { background: var(--accent-cyan, #4aa3ff); color: #fff; }
|
||||||
|
.alert-severity.low { background: var(--border-color, #1e2d3d); color: var(--text-dim, #5a6a7a); }
|
||||||
|
|
||||||
|
/* Correlation panel */
|
||||||
|
.analytics-correlation-pair {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2, 8px);
|
||||||
|
padding: var(--space-2, 8px);
|
||||||
|
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
font-size: var(--text-xs, 10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-correlation-pair .confidence-bar {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--bg-secondary, #101823);
|
||||||
|
border-radius: 2px;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-correlation-pair .confidence-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent-green, #38c180);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Geofence zone list */
|
||||||
|
.geofence-zone-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-2, 8px);
|
||||||
|
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
font-size: var(--text-xs, 10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.geofence-zone-item .zone-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e0e6ed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.geofence-zone-item .zone-radius {
|
||||||
|
color: var(--text-dim, #5a6a7a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.geofence-zone-item .zone-delete {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--accent-red, #e25d5d);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid var(--accent-red, #e25d5d);
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
background: transparent;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Export controls */
|
||||||
|
.export-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2, 8px);
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-controls select,
|
||||||
|
.export-controls button {
|
||||||
|
font-size: var(--text-xs, 10px);
|
||||||
|
padding: var(--space-1, 4px) var(--space-2, 8px);
|
||||||
|
background: var(--bg-card, #151f2b);
|
||||||
|
color: var(--text-primary, #e0e6ed);
|
||||||
|
border: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-controls button {
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--accent-cyan, #4aa3ff);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent-cyan, #4aa3ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-controls button:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section headers */
|
||||||
|
.analytics-section-header {
|
||||||
|
font-size: var(--text-xs, 10px);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim, #5a6a7a);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: var(--space-2, 8px);
|
||||||
|
padding-bottom: var(--space-1, 4px);
|
||||||
|
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.analytics-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-dim, #5a6a7a);
|
||||||
|
font-size: var(--text-xs, 10px);
|
||||||
|
padding: var(--space-4, 16px);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
/* Space Weather Mode Styles */
|
||||||
|
|
||||||
|
/* Main container */
|
||||||
|
.sw-visuals-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header metrics strip */
|
||||||
|
.sw-header-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-header-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 12px;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-header-stat + .sw-header-stat {
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-header-value {
|
||||||
|
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-header-label {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-header-value.accent-cyan { color: var(--accent-cyan); }
|
||||||
|
.sw-header-value.accent-green { color: #00ff88; }
|
||||||
|
.sw-header-value.accent-yellow { color: #ffcc00; }
|
||||||
|
.sw-header-value.accent-orange { color: #ff8800; }
|
||||||
|
.sw-header-value.accent-red { color: #ff3366; }
|
||||||
|
|
||||||
|
/* Refresh controls in strip */
|
||||||
|
.sw-strip-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-refresh-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||||
|
transition: border-color 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-refresh-btn:hover {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-last-update {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* NOAA G/S/R Scale cards */
|
||||||
|
.sw-scales-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-scale-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-scale-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-scale-value {
|
||||||
|
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-scale-desc {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scale severity colors */
|
||||||
|
.sw-scale-0 { color: #00ff88; border-color: #00ff8833; }
|
||||||
|
.sw-scale-1 { color: #88ff00; border-color: #88ff0033; }
|
||||||
|
.sw-scale-2 { color: #ffcc00; border-color: #ffcc0033; }
|
||||||
|
.sw-scale-3 { color: #ff8800; border-color: #ff880033; }
|
||||||
|
.sw-scale-4 { color: #ff4400; border-color: #ff440033; }
|
||||||
|
.sw-scale-5 { color: #ff0044; border-color: #ff004433; }
|
||||||
|
|
||||||
|
/* HF Band conditions grid */
|
||||||
|
.sw-band-panel {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-band-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-band-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto repeat(2, 1fr);
|
||||||
|
gap: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-band-header {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-band-name {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-band-cond {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-band-good { color: #00ff88; background: #00ff8815; }
|
||||||
|
.sw-band-fair { color: #ffcc00; background: #ffcc0015; }
|
||||||
|
.sw-band-poor { color: #ff3366; background: #ff336615; }
|
||||||
|
|
||||||
|
/* 2-column dashboard grid */
|
||||||
|
.sw-dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart containers */
|
||||||
|
.sw-chart-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-chart-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-chart-wrap {
|
||||||
|
position: relative;
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-chart-wrap canvas {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flare probability table */
|
||||||
|
.sw-prob-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-prob-table th {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-align: left;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-prob-table td {
|
||||||
|
padding: 4px 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Solar image gallery */
|
||||||
|
.sw-image-panel {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-image-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-image-tab {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-image-tab:hover {
|
||||||
|
border-color: var(--text-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-image-tab.active {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
background: var(--accent-cyan)10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-image-frame {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 200px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-image-frame img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 400px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* D-RAP frequency selector */
|
||||||
|
.sw-drap-freqs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-drap-freq-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-drap-freq-btn:hover {
|
||||||
|
border-color: var(--text-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-drap-freq-btn.active {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alerts list */
|
||||||
|
.sw-alerts-panel {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-alert-item {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-alert-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-alert-type {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-alert-time {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-alert-msg {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active regions table */
|
||||||
|
.sw-regions-panel {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-regions-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-regions-table th {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-align: left;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-regions-table td {
|
||||||
|
padding: 4px 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty / loading states */
|
||||||
|
.sw-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-loading::after {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-top-color: var(--accent-cyan);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: sw-spin 0.8s linear infinite;
|
||||||
|
margin-left: 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sw-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full-width card */
|
||||||
|
.sw-full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sw-dashboard-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-scales-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-header-strip {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-header-stat {
|
||||||
|
padding: 4px 8px;
|
||||||
|
min-width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw-header-value {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
padding: 8px 10px;
|
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;
|
||||||
|
|||||||
@@ -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;">
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* Analytics Dashboard Module
|
||||||
|
* Cross-mode summary, sparklines, alerts, correlations, geofence management, export.
|
||||||
|
*/
|
||||||
|
const Analytics = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let refreshTimer = null;
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
refresh();
|
||||||
|
if (!refreshTimer) {
|
||||||
|
refreshTimer = setInterval(refresh, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
if (refreshTimer) {
|
||||||
|
clearInterval(refreshTimer);
|
||||||
|
refreshTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
Promise.all([
|
||||||
|
fetch('/analytics/summary').then(r => r.json()).catch(() => null),
|
||||||
|
fetch('/analytics/activity').then(r => r.json()).catch(() => null),
|
||||||
|
fetch('/alerts/events?limit=20').then(r => r.json()).catch(() => null),
|
||||||
|
fetch('/correlation').then(r => r.json()).catch(() => null),
|
||||||
|
fetch('/analytics/geofences').then(r => r.json()).catch(() => null),
|
||||||
|
]).then(([summary, activity, alerts, correlations, geofences]) => {
|
||||||
|
if (summary) renderSummary(summary);
|
||||||
|
if (activity) renderSparklines(activity.sparklines || {});
|
||||||
|
if (alerts) renderAlerts(alerts.events || []);
|
||||||
|
if (correlations) renderCorrelations(correlations);
|
||||||
|
if (geofences) renderGeofences(geofences.zones || []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummary(data) {
|
||||||
|
const counts = data.counts || {};
|
||||||
|
_setText('analyticsCountAdsb', counts.adsb || 0);
|
||||||
|
_setText('analyticsCountAis', counts.ais || 0);
|
||||||
|
_setText('analyticsCountWifi', counts.wifi || 0);
|
||||||
|
_setText('analyticsCountBt', counts.bluetooth || 0);
|
||||||
|
_setText('analyticsCountDsc', counts.dsc || 0);
|
||||||
|
_setText('analyticsCountAcars', counts.acars || 0);
|
||||||
|
_setText('analyticsCountVdl2', counts.vdl2 || 0);
|
||||||
|
_setText('analyticsCountAprs', counts.aprs || 0);
|
||||||
|
_setText('analyticsCountMesh', counts.meshtastic || 0);
|
||||||
|
|
||||||
|
// Health
|
||||||
|
const health = data.health || {};
|
||||||
|
const container = document.getElementById('analyticsHealth');
|
||||||
|
if (container) {
|
||||||
|
let html = '';
|
||||||
|
const modeLabels = {
|
||||||
|
pager: 'Pager', sensor: '433MHz', adsb: 'ADS-B', ais: 'AIS',
|
||||||
|
acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', wifi: 'WiFi',
|
||||||
|
bluetooth: 'BT', dsc: 'DSC'
|
||||||
|
};
|
||||||
|
for (const [mode, info] of Object.entries(health)) {
|
||||||
|
if (mode === 'sdr_devices') continue;
|
||||||
|
const running = info && info.running;
|
||||||
|
const label = modeLabels[mode] || mode;
|
||||||
|
html += '<div class="health-item"><span class="health-dot' + (running ? ' running' : '') + '"></span>' + _esc(label) + '</div>';
|
||||||
|
}
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Squawks
|
||||||
|
const squawks = data.squawks || [];
|
||||||
|
const sqSection = document.getElementById('analyticsSquawkSection');
|
||||||
|
const sqList = document.getElementById('analyticsSquawkList');
|
||||||
|
if (sqSection && sqList) {
|
||||||
|
if (squawks.length > 0) {
|
||||||
|
sqSection.style.display = '';
|
||||||
|
sqList.innerHTML = squawks.map(s =>
|
||||||
|
'<div class="squawk-item"><strong>' + _esc(s.squawk) + '</strong> ' +
|
||||||
|
_esc(s.meaning) + ' — ' + _esc(s.callsign || s.icao) + '</div>'
|
||||||
|
).join('');
|
||||||
|
} else {
|
||||||
|
sqSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSparklines(sparklines) {
|
||||||
|
const map = {
|
||||||
|
adsb: 'analyticsSparkAdsb',
|
||||||
|
ais: 'analyticsSparkAis',
|
||||||
|
wifi: 'analyticsSparkWifi',
|
||||||
|
bluetooth: 'analyticsSparkBt',
|
||||||
|
dsc: 'analyticsSparkDsc',
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [mode, elId] of Object.entries(map)) {
|
||||||
|
const el = document.getElementById(elId);
|
||||||
|
if (!el) continue;
|
||||||
|
const data = sparklines[mode] || [];
|
||||||
|
if (data.length < 2) {
|
||||||
|
el.innerHTML = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const max = Math.max(...data, 1);
|
||||||
|
const w = 100;
|
||||||
|
const h = 24;
|
||||||
|
const step = w / (data.length - 1);
|
||||||
|
const points = data.map((v, i) =>
|
||||||
|
(i * step).toFixed(1) + ',' + (h - (v / max) * (h - 2)).toFixed(1)
|
||||||
|
).join(' ');
|
||||||
|
el.innerHTML = '<svg viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none"><polyline points="' + points + '"/></svg>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAlerts(events) {
|
||||||
|
const container = document.getElementById('analyticsAlertFeed');
|
||||||
|
if (!container) return;
|
||||||
|
if (!events || events.length === 0) {
|
||||||
|
container.innerHTML = '<div class="analytics-empty">No recent alerts</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = events.slice(0, 20).map(e => {
|
||||||
|
const sev = e.severity || 'medium';
|
||||||
|
const title = e.title || e.event_type || 'Alert';
|
||||||
|
const time = e.created_at ? new Date(e.created_at).toLocaleTimeString() : '';
|
||||||
|
return '<div class="analytics-alert-item">' +
|
||||||
|
'<span class="alert-severity ' + _esc(sev) + '">' + _esc(sev) + '</span>' +
|
||||||
|
'<span>' + _esc(title) + '</span>' +
|
||||||
|
'<span style="margin-left:auto;color:var(--text-dim)">' + _esc(time) + '</span>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCorrelations(data) {
|
||||||
|
const container = document.getElementById('analyticsCorrelations');
|
||||||
|
if (!container) return;
|
||||||
|
const pairs = (data && data.correlations) || [];
|
||||||
|
if (pairs.length === 0) {
|
||||||
|
container.innerHTML = '<div class="analytics-empty">No correlations detected</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = pairs.slice(0, 20).map(p => {
|
||||||
|
const conf = Math.round((p.confidence || 0) * 100);
|
||||||
|
return '<div class="analytics-correlation-pair">' +
|
||||||
|
'<span>' + _esc(p.wifi_mac || '') + '</span>' +
|
||||||
|
'<span style="color:var(--text-dim)">↔</span>' +
|
||||||
|
'<span>' + _esc(p.bt_mac || '') + '</span>' +
|
||||||
|
'<div class="confidence-bar"><div class="confidence-fill" style="width:' + conf + '%"></div></div>' +
|
||||||
|
'<span style="color:var(--text-dim)">' + conf + '%</span>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGeofences(zones) {
|
||||||
|
const container = document.getElementById('analyticsGeofenceList');
|
||||||
|
if (!container) return;
|
||||||
|
if (!zones || zones.length === 0) {
|
||||||
|
container.innerHTML = '<div class="analytics-empty">No geofence zones defined</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = zones.map(z =>
|
||||||
|
'<div class="geofence-zone-item">' +
|
||||||
|
'<span class="zone-name">' + _esc(z.name) + '</span>' +
|
||||||
|
'<span class="zone-radius">' + z.radius_m + 'm</span>' +
|
||||||
|
'<button class="zone-delete" onclick="Analytics.deleteGeofence(' + z.id + ')">DEL</button>' +
|
||||||
|
'</div>'
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGeofence() {
|
||||||
|
const name = prompt('Zone name:');
|
||||||
|
if (!name) return;
|
||||||
|
const lat = parseFloat(prompt('Latitude:', '0'));
|
||||||
|
const lon = parseFloat(prompt('Longitude:', '0'));
|
||||||
|
const radius = parseFloat(prompt('Radius (meters):', '1000'));
|
||||||
|
if (isNaN(lat) || isNaN(lon) || isNaN(radius)) {
|
||||||
|
alert('Invalid input');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch('/analytics/geofences', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, lat, lon, radius_m: radius }),
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteGeofence(id) {
|
||||||
|
if (!confirm('Delete this geofence zone?')) return;
|
||||||
|
fetch('/analytics/geofences/' + id, { method: 'DELETE' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(() => refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportData(mode) {
|
||||||
|
const m = mode || (document.getElementById('exportMode') || {}).value || 'adsb';
|
||||||
|
const f = (document.getElementById('exportFormat') || {}).value || 'json';
|
||||||
|
window.open('/analytics/export/' + encodeURIComponent(m) + '?format=' + encodeURIComponent(f), '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
function _setText(id, val) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _esc(s) {
|
||||||
|
if (typeof s !== 'string') s = String(s == null ? '' : s);
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init, destroy, refresh, addGeofence, deleteGeofence, exportData };
|
||||||
|
})();
|
||||||
@@ -8,6 +8,7 @@ const GPS = (function() {
|
|||||||
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 = {
|
||||||
@@ -41,6 +42,7 @@ const GPS = (function() {
|
|||||||
updateSkyUI(data.sky);
|
updateSkyUI(data.sky);
|
||||||
}
|
}
|
||||||
subscribeToStream();
|
subscribeToStream();
|
||||||
|
startSkyPolling();
|
||||||
// Ensure the global GPS stream is running
|
// Ensure the global GPS stream is running
|
||||||
if (typeof startGpsStream === 'function' && !gpsEventSource) {
|
if (typeof startGpsStream === 'function' && !gpsEventSource) {
|
||||||
startGpsStream();
|
startGpsStream();
|
||||||
@@ -58,6 +60,7 @@ const GPS = (function() {
|
|||||||
|
|
||||||
function disconnect() {
|
function disconnect() {
|
||||||
unsubscribeFromStream();
|
unsubscribeFromStream();
|
||||||
|
stopSkyPolling();
|
||||||
fetch('/gps/stop', { method: 'POST' })
|
fetch('/gps/stop', { method: 'POST' })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
connected = false;
|
connected = false;
|
||||||
@@ -77,6 +80,34 @@ const GPS = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startSkyPolling() {
|
||||||
|
stopSkyPolling();
|
||||||
|
// Poll satellite data every 5 seconds as a reliable fallback
|
||||||
|
// SSE stream may miss sky updates due to queue contention with position messages
|
||||||
|
pollSatellites();
|
||||||
|
skyPollTimer = setInterval(pollSatellites, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
function subscribeToStream() {
|
function subscribeToStream() {
|
||||||
// Subscribe to the global GPS stream instead of opening a separate SSE connection
|
// Subscribe to the global GPS stream instead of opening a separate SSE connection
|
||||||
if (typeof addGpsStreamSubscriber === 'function') {
|
if (typeof addGpsStreamSubscriber === 'function') {
|
||||||
@@ -260,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);
|
||||||
@@ -268,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);
|
||||||
}
|
}
|
||||||
@@ -300,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);
|
||||||
@@ -315,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);
|
||||||
@@ -395,6 +426,7 @@ const GPS = (function() {
|
|||||||
|
|
||||||
function destroy() {
|
function destroy() {
|
||||||
unsubscribeFromStream();
|
unsubscribeFromStream();
|
||||||
|
stopSkyPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -0,0 +1,677 @@
|
|||||||
|
/**
|
||||||
|
* Space Weather Mode — IIFE module
|
||||||
|
* Polls /space-weather/data every 5 min, renders dashboard with Chart.js
|
||||||
|
*/
|
||||||
|
const SpaceWeather = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let _initialized = false;
|
||||||
|
let _pollTimer = null;
|
||||||
|
let _autoRefresh = true;
|
||||||
|
const POLL_INTERVAL = 5 * 60 * 1000; // 5 min
|
||||||
|
|
||||||
|
// Chart.js instances
|
||||||
|
let _kpChart = null;
|
||||||
|
let _windChart = null;
|
||||||
|
let _xrayChart = null;
|
||||||
|
|
||||||
|
// Current image selections
|
||||||
|
let _solarImageKey = 'sdo_193';
|
||||||
|
let _drapFreq = 'drap_global';
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (!_initialized) {
|
||||||
|
_initialized = true;
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
_startAutoRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
_stopAutoRefresh();
|
||||||
|
_destroyCharts();
|
||||||
|
_initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
_fetchData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSolarImage(key) {
|
||||||
|
_solarImageKey = key;
|
||||||
|
_updateSolarImageTabs();
|
||||||
|
const frame = document.getElementById('swSolarImageFrame');
|
||||||
|
if (frame) {
|
||||||
|
frame.innerHTML = '<div class="sw-loading">Loading</div>';
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = function () { frame.innerHTML = ''; frame.appendChild(img); };
|
||||||
|
img.onerror = function () { frame.innerHTML = '<div class="sw-empty">Failed to load image</div>'; };
|
||||||
|
img.src = '/space-weather/image/' + key + '?t=' + Date.now();
|
||||||
|
img.alt = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDrapFreq(key) {
|
||||||
|
_drapFreq = key;
|
||||||
|
_updateDrapTabs();
|
||||||
|
const frame = document.getElementById('swDrapImageFrame');
|
||||||
|
if (frame) {
|
||||||
|
frame.innerHTML = '<div class="sw-loading">Loading</div>';
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = function () { frame.innerHTML = ''; frame.appendChild(img); };
|
||||||
|
img.onerror = function () { frame.innerHTML = '<div class="sw-empty">Failed to load image</div>'; };
|
||||||
|
img.src = '/space-weather/image/' + key + '?t=' + Date.now();
|
||||||
|
img.alt = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAutoRefresh() {
|
||||||
|
const cb = document.getElementById('swAutoRefresh');
|
||||||
|
_autoRefresh = cb ? cb.checked : !_autoRefresh;
|
||||||
|
if (_autoRefresh) _startAutoRefresh();
|
||||||
|
else _stopAutoRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Polling
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _startAutoRefresh() {
|
||||||
|
_stopAutoRefresh();
|
||||||
|
if (_autoRefresh) {
|
||||||
|
_pollTimer = setInterval(_fetchData, POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stopAutoRefresh() {
|
||||||
|
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fetchData() {
|
||||||
|
fetch('/space-weather/data')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
_renderAll(data);
|
||||||
|
_updateTimestamp();
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
console.warn('SpaceWeather fetch error:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Master render
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _renderAll(data) {
|
||||||
|
_renderHeaderStrip(data);
|
||||||
|
_renderScales(data);
|
||||||
|
_renderBandConditions(data);
|
||||||
|
_renderKpChart(data);
|
||||||
|
_renderWindChart(data);
|
||||||
|
_renderXrayChart(data);
|
||||||
|
_renderFlareProb(data);
|
||||||
|
_renderSolarImage();
|
||||||
|
_renderDrapImage();
|
||||||
|
_renderAuroraImage();
|
||||||
|
_renderAlerts(data);
|
||||||
|
_renderRegions(data);
|
||||||
|
_updateSidebar(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Header strip
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _renderHeaderStrip(data) {
|
||||||
|
var sfi = '--', kp = '--', aIndex = '--', ssn = '--', wind = '--', bz = '--';
|
||||||
|
|
||||||
|
// SFI from band_conditions (HamQSL) or flux
|
||||||
|
if (data.band_conditions && data.band_conditions.sfi) {
|
||||||
|
sfi = data.band_conditions.sfi;
|
||||||
|
} else if (data.flux && data.flux.length > 1) {
|
||||||
|
var last = data.flux[data.flux.length - 1];
|
||||||
|
sfi = last[1] || '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kp from kp_index
|
||||||
|
if (data.kp_index && data.kp_index.length > 1) {
|
||||||
|
var lastKp = data.kp_index[data.kp_index.length - 1];
|
||||||
|
kp = lastKp[1] || '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
// A-index from band_conditions
|
||||||
|
if (data.band_conditions && data.band_conditions.aindex) {
|
||||||
|
aIndex = data.band_conditions.aindex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sunspot number
|
||||||
|
if (data.band_conditions && data.band_conditions.sunspots) {
|
||||||
|
ssn = data.band_conditions.sunspots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solar wind speed — last non-null entry
|
||||||
|
if (data.solar_wind_plasma && data.solar_wind_plasma.length > 1) {
|
||||||
|
for (var i = data.solar_wind_plasma.length - 1; i >= 1; i--) {
|
||||||
|
if (data.solar_wind_plasma[i][2]) {
|
||||||
|
wind = Math.round(parseFloat(data.solar_wind_plasma[i][2]));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMF Bz — last non-null entry
|
||||||
|
if (data.solar_wind_mag && data.solar_wind_mag.length > 1) {
|
||||||
|
for (var j = data.solar_wind_mag.length - 1; j >= 1; j--) {
|
||||||
|
if (data.solar_wind_mag[j][3]) {
|
||||||
|
bz = parseFloat(data.solar_wind_mag[j][3]).toFixed(1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_setText('swStripSfi', sfi);
|
||||||
|
_setText('swStripKp', kp);
|
||||||
|
_setText('swStripA', aIndex);
|
||||||
|
_setText('swStripSsn', ssn);
|
||||||
|
_setText('swStripWind', wind !== '--' ? wind + ' km/s' : '--');
|
||||||
|
_setText('swStripBz', bz !== '--' ? bz + ' nT' : '--');
|
||||||
|
|
||||||
|
// Color Kp by severity
|
||||||
|
var kpEl = document.getElementById('swStripKp');
|
||||||
|
if (kpEl) {
|
||||||
|
var kpNum = parseFloat(kp);
|
||||||
|
kpEl.className = 'sw-header-value';
|
||||||
|
if (kpNum >= 7) kpEl.classList.add('accent-red');
|
||||||
|
else if (kpNum >= 5) kpEl.classList.add('accent-orange');
|
||||||
|
else if (kpNum >= 4) kpEl.classList.add('accent-yellow');
|
||||||
|
else kpEl.classList.add('accent-green');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color Bz — negative is bad
|
||||||
|
var bzEl = document.getElementById('swStripBz');
|
||||||
|
if (bzEl) {
|
||||||
|
var bzNum = parseFloat(bz);
|
||||||
|
bzEl.className = 'sw-header-value';
|
||||||
|
if (bzNum < -10) bzEl.classList.add('accent-red');
|
||||||
|
else if (bzNum < -5) bzEl.classList.add('accent-orange');
|
||||||
|
else if (bzNum < 0) bzEl.classList.add('accent-yellow');
|
||||||
|
else bzEl.classList.add('accent-green');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// NOAA Scales
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _renderScales(data) {
|
||||||
|
if (!data.scales) return;
|
||||||
|
var s = data.scales;
|
||||||
|
// Structure: { "0": { R: {Scale, Text}, S: {Scale, Text}, G: {Scale, Text} }, ... }
|
||||||
|
// Key "0" = current conditions
|
||||||
|
var current = s['0'];
|
||||||
|
if (!current) return;
|
||||||
|
|
||||||
|
var scaleMap = {
|
||||||
|
'G': { el: 'swScaleG', label: 'Geomagnetic Storms' },
|
||||||
|
'S': { el: 'swScaleS', label: 'Solar Radiation' },
|
||||||
|
'R': { el: 'swScaleR', label: 'Radio Blackouts' }
|
||||||
|
};
|
||||||
|
['G', 'S', 'R'].forEach(function (k) {
|
||||||
|
var info = scaleMap[k];
|
||||||
|
var scaleData = current[k];
|
||||||
|
var val = '0', text = info.label;
|
||||||
|
if (scaleData) {
|
||||||
|
val = String(scaleData.Scale || '0').replace(/[^0-9]/g, '') || '0';
|
||||||
|
if (scaleData.Text && scaleData.Text !== 'none') {
|
||||||
|
text = scaleData.Text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var el = document.getElementById(info.el);
|
||||||
|
if (el) {
|
||||||
|
el.querySelector('.sw-scale-value').textContent = k + val;
|
||||||
|
el.querySelector('.sw-scale-value').className = 'sw-scale-value sw-scale-' + val;
|
||||||
|
var descEl = el.querySelector('.sw-scale-desc');
|
||||||
|
if (descEl) descEl.textContent = text;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Band conditions
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _renderBandConditions(data) {
|
||||||
|
var grid = document.getElementById('swBandGrid');
|
||||||
|
if (!grid) return;
|
||||||
|
if (!data.band_conditions || !data.band_conditions.bands || data.band_conditions.bands.length === 0) {
|
||||||
|
grid.innerHTML = '<div class="sw-empty" style="grid-column:1/-1">No band data available</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Group by band name, collect day/night
|
||||||
|
var bands = {};
|
||||||
|
data.band_conditions.bands.forEach(function (b) {
|
||||||
|
if (!bands[b.name]) bands[b.name] = {};
|
||||||
|
bands[b.name][b.time.toLowerCase()] = b.condition;
|
||||||
|
});
|
||||||
|
|
||||||
|
var html = '<div class="sw-band-header">Band</div><div class="sw-band-header" style="text-align:center">Day</div><div class="sw-band-header" style="text-align:center">Night</div>';
|
||||||
|
Object.keys(bands).forEach(function (name) {
|
||||||
|
html += '<div class="sw-band-name">' + name + '</div>';
|
||||||
|
['day', 'night'].forEach(function (t) {
|
||||||
|
var cond = bands[name][t] || '--';
|
||||||
|
var cls = 'sw-band-cond';
|
||||||
|
var cl = cond.toLowerCase();
|
||||||
|
if (cl === 'good') cls += ' sw-band-good';
|
||||||
|
else if (cl === 'fair') cls += ' sw-band-fair';
|
||||||
|
else if (cl === 'poor') cls += ' sw-band-poor';
|
||||||
|
html += '<div class="' + cls + '">' + cond + '</div>';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
grid.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Kp bar chart
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _renderKpChart(data) {
|
||||||
|
var canvas = document.getElementById('swKpChart');
|
||||||
|
if (!canvas) return;
|
||||||
|
if (!data.kp_index || data.kp_index.length < 2) return;
|
||||||
|
|
||||||
|
var rows = data.kp_index.slice(1); // skip header
|
||||||
|
var labels = [];
|
||||||
|
var values = [];
|
||||||
|
var colors = [];
|
||||||
|
|
||||||
|
// Take last 24 entries
|
||||||
|
var subset = rows.slice(-24);
|
||||||
|
subset.forEach(function (r) {
|
||||||
|
var dt = r[0] || '';
|
||||||
|
labels.push(dt.slice(5, 16)); // MM-DD HH:MM
|
||||||
|
var v = parseFloat(r[1]) || 0;
|
||||||
|
values.push(v);
|
||||||
|
if (v >= 7) colors.push('#ff3366');
|
||||||
|
else if (v >= 5) colors.push('#ff8800');
|
||||||
|
else if (v >= 4) colors.push('#ffcc00');
|
||||||
|
else colors.push('#00ff88');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (_kpChart) { _kpChart.destroy(); _kpChart = null; }
|
||||||
|
_kpChart = new Chart(canvas, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
data: values,
|
||||||
|
backgroundColor: colors,
|
||||||
|
borderWidth: 0,
|
||||||
|
barPercentage: 0.8
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: _chartOpts('Kp', 0, 9, false)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Solar wind chart
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _renderWindChart(data) {
|
||||||
|
var canvas = document.getElementById('swWindChart');
|
||||||
|
if (!canvas) return;
|
||||||
|
if (!data.solar_wind_plasma || data.solar_wind_plasma.length < 2) return;
|
||||||
|
|
||||||
|
var rows = data.solar_wind_plasma.slice(1);
|
||||||
|
var labels = [];
|
||||||
|
var speedData = [];
|
||||||
|
var densityData = [];
|
||||||
|
|
||||||
|
// Sample every 3rd point to avoid overcrowding
|
||||||
|
for (var i = 0; i < rows.length; i += 3) {
|
||||||
|
var r = rows[i];
|
||||||
|
labels.push(r[0] ? r[0].slice(11, 16) : '');
|
||||||
|
speedData.push(r[2] ? parseFloat(r[2]) : null);
|
||||||
|
densityData.push(r[1] ? parseFloat(r[1]) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_windChart) { _windChart.destroy(); _windChart = null; }
|
||||||
|
_windChart = new Chart(canvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Speed (km/s)',
|
||||||
|
data: speedData,
|
||||||
|
borderColor: '#00ccff',
|
||||||
|
backgroundColor: '#00ccff22',
|
||||||
|
borderWidth: 1.5,
|
||||||
|
pointRadius: 0,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
yAxisID: 'y'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Density (p/cm³)',
|
||||||
|
data: densityData,
|
||||||
|
borderColor: '#ff8800',
|
||||||
|
borderWidth: 1,
|
||||||
|
pointRadius: 0,
|
||||||
|
borderDash: [4, 2],
|
||||||
|
tension: 0.3,
|
||||||
|
yAxisID: 'y1'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: true, position: 'top', labels: { color: '#888', font: { size: 10 }, boxWidth: 12, padding: 8 } }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { display: true, ticks: { color: '#555', font: { size: 9 }, maxTicksLimit: 8 }, grid: { color: '#ffffff08' } },
|
||||||
|
y: { display: true, position: 'left', ticks: { color: '#00ccff', font: { size: 9 } }, grid: { color: '#ffffff08' }, title: { display: false } },
|
||||||
|
y1: { display: true, position: 'right', ticks: { color: '#ff8800', font: { size: 9 } }, grid: { drawOnChartArea: false } }
|
||||||
|
},
|
||||||
|
interaction: { mode: 'index', intersect: false }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// X-ray flux chart
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _renderXrayChart(data) {
|
||||||
|
var canvas = document.getElementById('swXrayChart');
|
||||||
|
if (!canvas) return;
|
||||||
|
if (!data.xrays || data.xrays.length < 2) return;
|
||||||
|
|
||||||
|
// New format: array of objects with time_tag, flux, energy
|
||||||
|
// Filter to short-wavelength (0.1-0.8nm) only
|
||||||
|
var filtered = data.xrays.filter(function (r) {
|
||||||
|
return r.energy && r.energy === '0.1-0.8nm';
|
||||||
|
});
|
||||||
|
if (filtered.length === 0) filtered = data.xrays;
|
||||||
|
|
||||||
|
var labels = [];
|
||||||
|
var values = [];
|
||||||
|
|
||||||
|
// Sample every 3rd point
|
||||||
|
for (var i = 0; i < filtered.length; i += 3) {
|
||||||
|
var r = filtered[i];
|
||||||
|
var tag = r.time_tag || '';
|
||||||
|
labels.push(tag.slice(11, 16));
|
||||||
|
values.push(r.flux ? parseFloat(r.flux) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_xrayChart) { _xrayChart.destroy(); _xrayChart = null; }
|
||||||
|
_xrayChart = new Chart(canvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'X-Ray Flux (W/m²)',
|
||||||
|
data: values,
|
||||||
|
borderColor: '#ff3366',
|
||||||
|
backgroundColor: '#ff336622',
|
||||||
|
borderWidth: 1.5,
|
||||||
|
pointRadius: 0,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { display: true, ticks: { color: '#555', font: { size: 9 }, maxTicksLimit: 8 }, grid: { color: '#ffffff08' } },
|
||||||
|
y: {
|
||||||
|
display: true,
|
||||||
|
type: 'logarithmic',
|
||||||
|
ticks: {
|
||||||
|
color: '#888',
|
||||||
|
font: { size: 9 },
|
||||||
|
callback: function (v) {
|
||||||
|
if (v >= 1e-4) return 'X';
|
||||||
|
if (v >= 1e-5) return 'M';
|
||||||
|
if (v >= 1e-6) return 'C';
|
||||||
|
if (v >= 1e-7) return 'B';
|
||||||
|
if (v >= 1e-8) return 'A';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
grid: { color: '#ffffff08' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Flare probability
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _renderFlareProb(data) {
|
||||||
|
var el = document.getElementById('swFlareProb');
|
||||||
|
if (!el) return;
|
||||||
|
if (!data.flare_probability || data.flare_probability.length === 0) {
|
||||||
|
el.innerHTML = '<div class="sw-empty">No flare data</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// New format: array of objects with date, c_class_1_day, m_class_1_day, x_class_1_day, etc.
|
||||||
|
var latest = data.flare_probability.slice(-3);
|
||||||
|
var html = '<table class="sw-prob-table"><thead><tr>';
|
||||||
|
html += '<th>Date</th><th>C 1-day</th><th>M 1-day</th><th>X 1-day</th><th>Proton</th>';
|
||||||
|
html += '</tr></thead><tbody>';
|
||||||
|
latest.forEach(function (row) {
|
||||||
|
html += '<tr>';
|
||||||
|
html += '<td>' + _escHtml(row.date || '--') + '</td>';
|
||||||
|
html += '<td>' + _escHtml(row.c_class_1_day || '--') + '%</td>';
|
||||||
|
html += '<td>' + _escHtml(row.m_class_1_day || '--') + '%</td>';
|
||||||
|
html += '<td>' + _escHtml(row.x_class_1_day || '--') + '%</td>';
|
||||||
|
html += '<td>' + _escHtml(row['10mev_protons_1_day'] || '--') + '%</td>';
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Images
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _renderSolarImage() {
|
||||||
|
selectSolarImage(_solarImageKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderDrapImage() {
|
||||||
|
selectDrapFreq(_drapFreq);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderAuroraImage() {
|
||||||
|
var frame = document.getElementById('swAuroraFrame');
|
||||||
|
if (!frame) return;
|
||||||
|
var img = new Image();
|
||||||
|
img.onload = function () { frame.innerHTML = ''; frame.appendChild(img); };
|
||||||
|
img.onerror = function () { frame.innerHTML = '<div class="sw-empty">Failed to load aurora image</div>'; };
|
||||||
|
img.src = '/space-weather/image/aurora_north?t=' + Date.now();
|
||||||
|
img.alt = 'Aurora Forecast';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateSolarImageTabs() {
|
||||||
|
document.querySelectorAll('.sw-solar-tab').forEach(function (btn) {
|
||||||
|
btn.classList.toggle('active', btn.dataset.key === _solarImageKey);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateDrapTabs() {
|
||||||
|
document.querySelectorAll('.sw-drap-freq-btn').forEach(function (btn) {
|
||||||
|
btn.classList.toggle('active', btn.dataset.key === _drapFreq);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Alerts
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _renderAlerts(data) {
|
||||||
|
var el = document.getElementById('swAlertsList');
|
||||||
|
if (!el) return;
|
||||||
|
if (!data.alerts || data.alerts.length === 0) {
|
||||||
|
el.innerHTML = '<div class="sw-empty">No active alerts</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '';
|
||||||
|
// Show latest 10
|
||||||
|
var items = data.alerts.slice(0, 10);
|
||||||
|
items.forEach(function (a) {
|
||||||
|
var msg = a.message || a.product_text || '';
|
||||||
|
// Truncate long messages
|
||||||
|
if (msg.length > 300) msg = msg.substring(0, 300) + '...';
|
||||||
|
html += '<div class="sw-alert-item">';
|
||||||
|
html += '<div class="sw-alert-type">' + _escHtml(a.product_id || 'Alert') + '</div>';
|
||||||
|
html += '<div class="sw-alert-time">' + _escHtml(a.issue_datetime || '') + '</div>';
|
||||||
|
html += '<div class="sw-alert-msg">' + _escHtml(msg) + '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Active regions
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _renderRegions(data) {
|
||||||
|
var el = document.getElementById('swRegionsBody');
|
||||||
|
if (!el) return;
|
||||||
|
if (!data.solar_regions || data.solar_regions.length === 0) {
|
||||||
|
el.innerHTML = '<tr><td colspan="5" class="sw-empty">No active regions</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// New format: array of objects with region, observed_date, location, longitude, area, etc.
|
||||||
|
// De-duplicate by region number (keep latest observed_date per region)
|
||||||
|
var byRegion = {};
|
||||||
|
data.solar_regions.forEach(function (r) {
|
||||||
|
var key = r.region || '';
|
||||||
|
if (!byRegion[key] || (r.observed_date > byRegion[key].observed_date)) {
|
||||||
|
byRegion[key] = r;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var regions = Object.values(byRegion);
|
||||||
|
var html = '';
|
||||||
|
regions.forEach(function (r) {
|
||||||
|
html += '<tr>';
|
||||||
|
html += '<td>' + _escHtml(String(r.region || '')) + '</td>';
|
||||||
|
html += '<td>' + _escHtml(r.observed_date || '') + '</td>';
|
||||||
|
html += '<td>' + _escHtml(r.location || '') + '</td>';
|
||||||
|
html += '<td>' + _escHtml(String(r.longitude || '')) + '</td>';
|
||||||
|
html += '<td>' + _escHtml(String(r.area || '')) + '</td>';
|
||||||
|
html += '</tr>';
|
||||||
|
});
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Sidebar quick status
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _updateSidebar(data) {
|
||||||
|
var sfi = '--', kp = '--', aIdx = '--', ssn = '--', wind = '--', bz = '--';
|
||||||
|
|
||||||
|
if (data.band_conditions) {
|
||||||
|
if (data.band_conditions.sfi) sfi = data.band_conditions.sfi;
|
||||||
|
if (data.band_conditions.aindex) aIdx = data.band_conditions.aindex;
|
||||||
|
if (data.band_conditions.sunspots) ssn = data.band_conditions.sunspots;
|
||||||
|
}
|
||||||
|
if (data.kp_index && data.kp_index.length > 1) {
|
||||||
|
kp = data.kp_index[data.kp_index.length - 1][1] || '--';
|
||||||
|
}
|
||||||
|
if (data.solar_wind_plasma && data.solar_wind_plasma.length > 1) {
|
||||||
|
for (var i = data.solar_wind_plasma.length - 1; i >= 1; i--) {
|
||||||
|
if (data.solar_wind_plasma[i][2]) {
|
||||||
|
wind = Math.round(parseFloat(data.solar_wind_plasma[i][2])) + ' km/s';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.solar_wind_mag && data.solar_wind_mag.length > 1) {
|
||||||
|
for (var j = data.solar_wind_mag.length - 1; j >= 1; j--) {
|
||||||
|
if (data.solar_wind_mag[j][3]) {
|
||||||
|
bz = parseFloat(data.solar_wind_mag[j][3]).toFixed(1) + ' nT';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_setText('swSidebarSfi', sfi);
|
||||||
|
_setText('swSidebarKp', kp);
|
||||||
|
_setText('swSidebarA', aIdx);
|
||||||
|
_setText('swSidebarSsn', ssn);
|
||||||
|
_setText('swSidebarWind', wind);
|
||||||
|
_setText('swSidebarBz', bz);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _setText(id, text) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (el) el.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _escHtml(s) {
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.textContent = s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateTimestamp() {
|
||||||
|
var el = document.getElementById('swLastUpdate');
|
||||||
|
if (el) el.textContent = 'Updated: ' + new Date().toLocaleTimeString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _chartOpts(yLabel, yMin, yMax, showLegend) {
|
||||||
|
return {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: !!showLegend, labels: { color: '#888', font: { size: 10 } } }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { display: true, ticks: { color: '#555', font: { size: 9 }, maxRotation: 45, maxTicksLimit: 8 }, grid: { color: '#ffffff08' } },
|
||||||
|
y: { display: true, min: yMin, max: yMax, ticks: { color: '#888', font: { size: 9 }, stepSize: 1 }, grid: { color: '#ffffff08' } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _destroyCharts() {
|
||||||
|
if (_kpChart) { _kpChart.destroy(); _kpChart = null; }
|
||||||
|
if (_windChart) { _windChart.destroy(); _windChart = null; }
|
||||||
|
if (_xrayChart) { _xrayChart.destroy(); _xrayChart = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Expose public API
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: init,
|
||||||
|
destroy: destroy,
|
||||||
|
refresh: refresh,
|
||||||
|
selectSolarImage: selectSolarImage,
|
||||||
|
selectDrapFreq: selectDrapFreq,
|
||||||
|
toggleAutoRefresh: toggleAutoRefresh
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -399,16 +399,20 @@ const WeatherSat = (function() {
|
|||||||
|
|
||||||
addConsoleEntry('Capture complete', 'signal');
|
addConsoleEntry('Capture complete', 'signal');
|
||||||
updatePhaseIndicator('complete');
|
updatePhaseIndicator('complete');
|
||||||
|
if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer);
|
||||||
consoleAutoHideTimer = setTimeout(() => showConsole(false), 30000);
|
consoleAutoHideTimer = setTimeout(() => showConsole(false), 30000);
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (data.status === 'error') {
|
} else if (data.status === 'error') {
|
||||||
|
isRunning = false;
|
||||||
|
if (!schedulerEnabled) stopStream();
|
||||||
updateStatusUI('idle', 'Error');
|
updateStatusUI('idle', 'Error');
|
||||||
showNotification('Weather Sat', data.message || 'Capture error');
|
showNotification('Weather Sat', data.message || 'Capture error');
|
||||||
if (captureStatus) captureStatus.classList.remove('active');
|
if (captureStatus) captureStatus.classList.remove('active');
|
||||||
|
|
||||||
if (data.message) addConsoleEntry(data.message, 'error');
|
if (data.message) addConsoleEntry(data.message, 'error');
|
||||||
updatePhaseIndicator('error');
|
updatePhaseIndicator('error');
|
||||||
|
if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer);
|
||||||
consoleAutoHideTimer = setTimeout(() => showConsole(false), 15000);
|
consoleAutoHideTimer = setTimeout(() => showConsole(false), 15000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -566,7 +570,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 +614,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 +628,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 +696,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);
|
||||||
}
|
}
|
||||||
@@ -761,8 +765,17 @@ const WeatherSat = (function() {
|
|||||||
}).addTo(groundTrackLayer);
|
}).addTo(groundTrackLayer);
|
||||||
|
|
||||||
// Observer marker
|
// Observer marker
|
||||||
const lat = parseFloat(localStorage.getItem('observerLat'));
|
let obsLat, obsLon;
|
||||||
const lon = parseFloat(localStorage.getItem('observerLon'));
|
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||||
|
const shared = ObserverLocation.getShared();
|
||||||
|
obsLat = shared?.lat;
|
||||||
|
obsLon = shared?.lon;
|
||||||
|
} else {
|
||||||
|
obsLat = parseFloat(localStorage.getItem('observerLat'));
|
||||||
|
obsLon = parseFloat(localStorage.getItem('observerLon'));
|
||||||
|
}
|
||||||
|
const lat = obsLat;
|
||||||
|
const lon = obsLon;
|
||||||
if (!isNaN(lat) && !isNaN(lon)) {
|
if (!isNaN(lat) && !isNaN(lon)) {
|
||||||
L.circleMarker([lat, lon], {
|
L.circleMarker([lat, lon], {
|
||||||
radius: 6, color: '#ffbb00', fillColor: '#ffbb00', fillOpacity: 0.8, weight: 1,
|
radius: 6, color: '#ffbb00', fillColor: '#ffbb00', fillOpacity: 0.8, weight: 1,
|
||||||
@@ -946,8 +959,15 @@ const WeatherSat = (function() {
|
|||||||
* Enable auto-scheduler
|
* Enable auto-scheduler
|
||||||
*/
|
*/
|
||||||
async function enableScheduler() {
|
async function enableScheduler() {
|
||||||
const lat = parseFloat(localStorage.getItem('observerLat'));
|
let lat, lon;
|
||||||
const lon = parseFloat(localStorage.getItem('observerLon'));
|
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||||
|
const shared = ObserverLocation.getShared();
|
||||||
|
lat = shared?.lat;
|
||||||
|
lon = shared?.lon;
|
||||||
|
} else {
|
||||||
|
lat = parseFloat(localStorage.getItem('observerLat'));
|
||||||
|
lon = parseFloat(localStorage.getItem('observerLon'));
|
||||||
|
}
|
||||||
|
|
||||||
if (isNaN(lat) || isNaN(lon)) {
|
if (isNaN(lat) || isNaN(lon)) {
|
||||||
showNotification('Weather Sat', 'Set observer location first');
|
showNotification('Weather Sat', 'Set observer location first');
|
||||||
|
|||||||
@@ -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,9 +50,9 @@
|
|||||||
<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/modes/analytics.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') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-timeline.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-timeline.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/activity-timeline.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/activity-timeline.css') }}">
|
||||||
@@ -66,6 +66,7 @@
|
|||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/gps.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/gps.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate2">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate2">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/space-weather.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/function-strip.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/function-strip.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}">
|
||||||
@@ -258,6 +259,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"><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="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"/></svg></span>
|
||||||
<span class="mode-name">GPS</span>
|
<span class="mode-name">GPS</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="mode-card mode-card-sm" onclick="selectMode('spaceweather')">
|
||||||
|
<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="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span>
|
||||||
|
<span class="mode-name">Space Wx</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -551,10 +556,14 @@
|
|||||||
|
|
||||||
{% include 'partials/modes/gps.html' %}
|
{% include 'partials/modes/gps.html' %}
|
||||||
|
|
||||||
|
{% include 'partials/modes/space-weather.html' %}
|
||||||
|
|
||||||
{% include 'partials/modes/listening-post.html' %}
|
{% include 'partials/modes/listening-post.html' %}
|
||||||
|
|
||||||
{% include 'partials/modes/tscm.html' %}
|
{% include 'partials/modes/tscm.html' %}
|
||||||
|
|
||||||
|
{% include 'partials/modes/analytics.html' %}
|
||||||
|
|
||||||
{% include 'partials/modes/ais.html' %}
|
{% include 'partials/modes/ais.html' %}
|
||||||
|
|
||||||
{% include 'partials/modes/spy-stations.html' %}
|
{% include 'partials/modes/spy-stations.html' %}
|
||||||
@@ -569,6 +578,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
|
||||||
@@ -1005,6 +1016,7 @@
|
|||||||
<option value="brazil">Brazil (145.570)</option>
|
<option value="brazil">Brazil (145.570)</option>
|
||||||
<option value="japan">Japan (144.640)</option>
|
<option value="japan">Japan (144.640)</option>
|
||||||
<option value="china">China (144.640)</option>
|
<option value="china">China (144.640)</option>
|
||||||
|
<option value="iss">ISS (145.825)</option>
|
||||||
<option value="custom">Custom Frequency</option>
|
<option value="custom">Custom Frequency</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -2561,7 +2573,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;">
|
||||||
@@ -2828,7 +2840,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;">
|
||||||
@@ -2898,6 +2910,141 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Space Weather Dashboard -->
|
||||||
|
<div id="spaceWeatherVisuals" class="sw-visuals-container" style="display: none;">
|
||||||
|
<!-- Header metrics strip -->
|
||||||
|
<div class="sw-header-strip">
|
||||||
|
<div class="sw-header-stat">
|
||||||
|
<span class="sw-header-value accent-cyan" id="swStripSfi">--</span>
|
||||||
|
<span class="sw-header-label">SFI</span>
|
||||||
|
</div>
|
||||||
|
<div class="sw-header-stat">
|
||||||
|
<span class="sw-header-value" id="swStripKp">--</span>
|
||||||
|
<span class="sw-header-label">Kp</span>
|
||||||
|
</div>
|
||||||
|
<div class="sw-header-stat">
|
||||||
|
<span class="sw-header-value" id="swStripA">--</span>
|
||||||
|
<span class="sw-header-label">A-Index</span>
|
||||||
|
</div>
|
||||||
|
<div class="sw-header-stat">
|
||||||
|
<span class="sw-header-value" id="swStripSsn">--</span>
|
||||||
|
<span class="sw-header-label">SSN</span>
|
||||||
|
</div>
|
||||||
|
<div class="sw-header-stat">
|
||||||
|
<span class="sw-header-value" id="swStripWind">--</span>
|
||||||
|
<span class="sw-header-label">Wind</span>
|
||||||
|
</div>
|
||||||
|
<div class="sw-header-stat">
|
||||||
|
<span class="sw-header-value" id="swStripBz">--</span>
|
||||||
|
<span class="sw-header-label">Bz</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NOAA G/S/R Scales -->
|
||||||
|
<div class="sw-scales-row">
|
||||||
|
<div class="sw-scale-card" id="swScaleG">
|
||||||
|
<div class="sw-scale-label">Geomagnetic</div>
|
||||||
|
<div class="sw-scale-value sw-scale-0">G0</div>
|
||||||
|
<div class="sw-scale-desc">Quiet</div>
|
||||||
|
</div>
|
||||||
|
<div class="sw-scale-card" id="swScaleS">
|
||||||
|
<div class="sw-scale-label">Solar Radiation</div>
|
||||||
|
<div class="sw-scale-value sw-scale-0">S0</div>
|
||||||
|
<div class="sw-scale-desc">None</div>
|
||||||
|
</div>
|
||||||
|
<div class="sw-scale-card" id="swScaleR">
|
||||||
|
<div class="sw-scale-label">Radio Blackouts</div>
|
||||||
|
<div class="sw-scale-value sw-scale-0">R0</div>
|
||||||
|
<div class="sw-scale-desc">None</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- HF Band Conditions -->
|
||||||
|
<div class="sw-band-panel">
|
||||||
|
<div class="sw-band-title">HF Band Conditions</div>
|
||||||
|
<div class="sw-band-grid" id="swBandGrid">
|
||||||
|
<div class="sw-loading">Loading band data</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts row -->
|
||||||
|
<div class="sw-dashboard-grid">
|
||||||
|
<div class="sw-chart-card">
|
||||||
|
<div class="sw-chart-title">Kp Index (3-hourly)</div>
|
||||||
|
<div class="sw-chart-wrap"><canvas id="swKpChart"></canvas></div>
|
||||||
|
</div>
|
||||||
|
<div class="sw-chart-card">
|
||||||
|
<div class="sw-chart-title">Solar Wind</div>
|
||||||
|
<div class="sw-chart-wrap"><canvas id="swWindChart"></canvas></div>
|
||||||
|
</div>
|
||||||
|
<div class="sw-chart-card">
|
||||||
|
<div class="sw-chart-title">X-Ray Flux (GOES)</div>
|
||||||
|
<div class="sw-chart-wrap"><canvas id="swXrayChart"></canvas></div>
|
||||||
|
</div>
|
||||||
|
<div class="sw-chart-card">
|
||||||
|
<div class="sw-chart-title">Flare Probability</div>
|
||||||
|
<div id="swFlareProb"><div class="sw-loading">Loading</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Solar imagery gallery -->
|
||||||
|
<div class="sw-dashboard-grid">
|
||||||
|
<div class="sw-image-panel">
|
||||||
|
<div class="sw-chart-title">Solar Imagery (SDO)</div>
|
||||||
|
<div class="sw-image-tabs">
|
||||||
|
<button class="sw-image-tab sw-solar-tab active" data-key="sdo_193" onclick="SpaceWeather.selectSolarImage('sdo_193')">193Å</button>
|
||||||
|
<button class="sw-image-tab sw-solar-tab" data-key="sdo_304" onclick="SpaceWeather.selectSolarImage('sdo_304')">304Å</button>
|
||||||
|
<button class="sw-image-tab sw-solar-tab" data-key="sdo_magnetogram" onclick="SpaceWeather.selectSolarImage('sdo_magnetogram')">Magnetogram</button>
|
||||||
|
</div>
|
||||||
|
<div class="sw-image-frame" id="swSolarImageFrame">
|
||||||
|
<div class="sw-loading">Loading</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sw-image-panel">
|
||||||
|
<div class="sw-chart-title">Aurora Forecast (North)</div>
|
||||||
|
<div class="sw-image-frame" id="swAuroraFrame">
|
||||||
|
<div class="sw-loading">Loading</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- D-RAP absorption map -->
|
||||||
|
<div class="sw-image-panel">
|
||||||
|
<div class="sw-chart-title">D-Region Absorption (D-RAP)</div>
|
||||||
|
<div class="sw-drap-freqs">
|
||||||
|
<button class="sw-drap-freq-btn active" data-key="drap_global" onclick="SpaceWeather.selectDrapFreq('drap_global')">Global</button>
|
||||||
|
<button class="sw-drap-freq-btn" data-key="drap_5" onclick="SpaceWeather.selectDrapFreq('drap_5')">5 MHz</button>
|
||||||
|
<button class="sw-drap-freq-btn" data-key="drap_10" onclick="SpaceWeather.selectDrapFreq('drap_10')">10 MHz</button>
|
||||||
|
<button class="sw-drap-freq-btn" data-key="drap_15" onclick="SpaceWeather.selectDrapFreq('drap_15')">15 MHz</button>
|
||||||
|
<button class="sw-drap-freq-btn" data-key="drap_20" onclick="SpaceWeather.selectDrapFreq('drap_20')">20 MHz</button>
|
||||||
|
<button class="sw-drap-freq-btn" data-key="drap_25" onclick="SpaceWeather.selectDrapFreq('drap_25')">25 MHz</button>
|
||||||
|
<button class="sw-drap-freq-btn" data-key="drap_30" onclick="SpaceWeather.selectDrapFreq('drap_30')">30 MHz</button>
|
||||||
|
</div>
|
||||||
|
<div class="sw-image-frame" id="swDrapImageFrame">
|
||||||
|
<div class="sw-loading">Loading</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alerts & Active Regions -->
|
||||||
|
<div class="sw-dashboard-grid">
|
||||||
|
<div class="sw-alerts-panel">
|
||||||
|
<div class="sw-chart-title">Active Alerts</div>
|
||||||
|
<div id="swAlertsList"><div class="sw-loading">Loading</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="sw-regions-panel">
|
||||||
|
<div class="sw-chart-title">Active Sunspot Regions</div>
|
||||||
|
<table class="sw-regions-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Region</th><th>Date</th><th>Loc</th><th>Lo</th><th>Area</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="swRegionsBody">
|
||||||
|
<tr><td colspan="5" class="sw-loading">Loading</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
|
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
|
||||||
<div class="recon-panel collapsed" id="reconPanel">
|
<div class="recon-panel collapsed" id="reconPanel">
|
||||||
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
|
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
|
||||||
@@ -2920,7 +3067,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;">
|
||||||
@@ -2938,7 +3085,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;">
|
||||||
@@ -3023,6 +3170,8 @@
|
|||||||
<script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
|
<script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
|
||||||
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate2"></script>
|
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate2"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/modes/analytics.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -3158,7 +3307,8 @@
|
|||||||
const validModes = new Set([
|
const validModes = new Set([
|
||||||
'pager', 'sensor', 'rtlamr', 'aprs', 'listening',
|
'pager', 'sensor', 'rtlamr', 'aprs', 'listening',
|
||||||
'spystations', 'meshtastic', 'wifi', 'bluetooth', 'bt_locate',
|
'spystations', 'meshtastic', 'wifi', 'bluetooth', 'bt_locate',
|
||||||
'tscm', 'satellite', 'sstv', 'weathersat', 'sstv_general', 'gps', 'websdr', 'subghz'
|
'tscm', 'satellite', 'sstv', 'weathersat', 'sstv_general', 'gps', 'websdr', 'subghz',
|
||||||
|
'analytics', 'spaceweather'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function getModeFromQuery() {
|
function getModeFromQuery() {
|
||||||
@@ -3615,11 +3765,11 @@
|
|||||||
'pager': 'sdr', 'sensor': 'sdr',
|
'pager': 'sdr', 'sensor': 'sdr',
|
||||||
'aprs': 'sdr', 'listening': 'sdr',
|
'aprs': 'sdr', 'listening': 'sdr',
|
||||||
'wifi': 'wireless', 'bluetooth': 'wireless', 'bt_locate': 'wireless',
|
'wifi': 'wireless', 'bluetooth': 'wireless', 'bt_locate': 'wireless',
|
||||||
'tscm': 'security',
|
'tscm': 'security', 'analytics': 'security',
|
||||||
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr',
|
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr',
|
||||||
'meshtastic': 'sdr',
|
'meshtastic': 'sdr',
|
||||||
'satellite': 'space', 'sstv': 'space', 'weathersat': 'space', 'sstv_general': 'space', 'gps': 'space',
|
'satellite': 'space', 'sstv': 'space', 'weathersat': 'space', 'sstv_general': 'space', 'gps': 'space',
|
||||||
'subghz': 'sdr'
|
'spaceweather': 'space', 'subghz': 'sdr'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove has-active from all dropdowns
|
// Remove has-active from all dropdowns
|
||||||
@@ -3694,7 +3844,8 @@
|
|||||||
'pager': 'pager', 'sensor': '433',
|
'pager': 'pager', 'sensor': '433',
|
||||||
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth', 'bt_locate': 'bt locate',
|
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth', 'bt_locate': 'bt locate',
|
||||||
'listening': 'listening', 'aprs': 'aprs', 'tscm': 'tscm', 'meshtastic': 'meshtastic',
|
'listening': 'listening', 'aprs': 'aprs', 'tscm': 'tscm', 'meshtastic': 'meshtastic',
|
||||||
'dmr': 'dmr', 'websdr': 'websdr', 'sstv_general': 'hf sstv'
|
'dmr': 'dmr', 'websdr': 'websdr', 'sstv_general': 'hf sstv',
|
||||||
|
'analytics': 'analytics'
|
||||||
};
|
};
|
||||||
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
||||||
const label = btn.querySelector('.nav-label');
|
const label = btn.querySelector('.nav-label');
|
||||||
@@ -3722,6 +3873,10 @@
|
|||||||
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');
|
||||||
|
document.getElementById('analyticsMode')?.classList.toggle('active', mode === 'analytics');
|
||||||
|
document.getElementById('spaceWeatherMode')?.classList.toggle('active', mode === 'spaceweather');
|
||||||
|
|
||||||
|
|
||||||
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');
|
||||||
@@ -3762,7 +3917,9 @@
|
|||||||
'meshtastic': 'MESHTASTIC',
|
'meshtastic': 'MESHTASTIC',
|
||||||
'dmr': 'DIGITAL VOICE',
|
'dmr': 'DIGITAL VOICE',
|
||||||
'websdr': 'WEBSDR',
|
'websdr': 'WEBSDR',
|
||||||
'subghz': 'SUBGHZ'
|
'subghz': 'SUBGHZ',
|
||||||
|
'analytics': 'ANALYTICS',
|
||||||
|
'spaceweather': 'SPACE WX'
|
||||||
};
|
};
|
||||||
const activeModeIndicator = document.getElementById('activeModeIndicator');
|
const activeModeIndicator = document.getElementById('activeModeIndicator');
|
||||||
if (activeModeIndicator) activeModeIndicator.innerHTML = '<span class="pulse-dot"></span>' + (modeNames[mode] || mode.toUpperCase());
|
if (activeModeIndicator) activeModeIndicator.innerHTML = '<span class="pulse-dot"></span>' + (modeNames[mode] || mode.toUpperCase());
|
||||||
@@ -3782,6 +3939,7 @@
|
|||||||
const websdrVisuals = document.getElementById('websdrVisuals');
|
const websdrVisuals = document.getElementById('websdrVisuals');
|
||||||
const subghzVisuals = document.getElementById('subghzVisuals');
|
const subghzVisuals = document.getElementById('subghzVisuals');
|
||||||
const btLocateVisuals = document.getElementById('btLocateVisuals');
|
const btLocateVisuals = document.getElementById('btLocateVisuals');
|
||||||
|
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
|
||||||
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||||
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||||
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
|
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
|
||||||
@@ -3798,6 +3956,7 @@
|
|||||||
if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none';
|
if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none';
|
||||||
if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none';
|
if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none';
|
||||||
if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none';
|
if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none';
|
||||||
|
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
|
||||||
|
|
||||||
// Hide sidebar by default for Meshtastic mode, show for others
|
// Hide sidebar by default for Meshtastic mode, show for others
|
||||||
const mainContent = document.querySelector('.main-content');
|
const mainContent = document.querySelector('.main-content');
|
||||||
@@ -3807,6 +3966,8 @@
|
|||||||
} else {
|
} else {
|
||||||
mainContent.classList.remove('mesh-sidebar-hidden');
|
mainContent.classList.remove('mesh-sidebar-hidden');
|
||||||
}
|
}
|
||||||
|
// Analytics is sidebar-only — hide output panel and expand sidebar
|
||||||
|
mainContent.classList.toggle('analytics-active', mode === 'analytics');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show/hide mode-specific timeline containers
|
// Show/hide mode-specific timeline containers
|
||||||
@@ -3836,7 +3997,9 @@
|
|||||||
'meshtastic': 'Meshtastic Mesh Monitor',
|
'meshtastic': 'Meshtastic Mesh Monitor',
|
||||||
'dmr': 'Digital Voice Decoder',
|
'dmr': 'Digital Voice Decoder',
|
||||||
'websdr': 'HF/Shortwave WebSDR',
|
'websdr': 'HF/Shortwave WebSDR',
|
||||||
'subghz': 'SubGHz Transceiver'
|
'subghz': 'SubGHz Transceiver',
|
||||||
|
'analytics': 'Cross-Mode Analytics',
|
||||||
|
'spaceweather': 'Space Weather Monitor'
|
||||||
};
|
};
|
||||||
const outputTitle = document.getElementById('outputTitle');
|
const outputTitle = document.getElementById('outputTitle');
|
||||||
if (outputTitle) outputTitle.textContent = titles[mode] || 'Signal Monitor';
|
if (outputTitle) outputTitle.textContent = titles[mode] || 'Signal Monitor';
|
||||||
@@ -3850,11 +4013,25 @@
|
|||||||
refreshTscmDevices();
|
refreshTscmDevices();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize/destroy Analytics mode
|
||||||
|
if (mode === 'analytics') {
|
||||||
|
// Expand all analytics sections (sidebar sections default to collapsed)
|
||||||
|
document.querySelectorAll('#analyticsMode .section.collapsed').forEach(s => s.classList.remove('collapsed'));
|
||||||
|
if (typeof Analytics !== 'undefined') Analytics.init();
|
||||||
|
} else {
|
||||||
|
if (typeof Analytics !== 'undefined' && Analytics.destroy) Analytics.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize/destroy Space Weather mode
|
||||||
|
if (mode !== 'spaceweather') {
|
||||||
|
if (typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy) SpaceWeather.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm)
|
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm)
|
||||||
const reconBtn = document.getElementById('reconBtn');
|
const reconBtn = document.getElementById('reconBtn');
|
||||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||||
const reconPanel = document.getElementById('reconPanel');
|
const reconPanel = document.getElementById('reconPanel');
|
||||||
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz') {
|
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics' || mode === 'spaceweather') {
|
||||||
if (reconPanel) reconPanel.style.display = 'none';
|
if (reconPanel) reconPanel.style.display = 'none';
|
||||||
if (reconBtn) reconBtn.style.display = 'none';
|
if (reconBtn) reconBtn.style.display = 'none';
|
||||||
if (intelBtn) intelBtn.style.display = 'none';
|
if (intelBtn) intelBtn.style.display = 'none';
|
||||||
@@ -3869,7 +4046,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
|
||||||
@@ -3892,8 +4069,8 @@
|
|||||||
// Hide output console for modes with their own visualizations
|
// Hide output console for modes with their own visualizations
|
||||||
const outputEl = document.getElementById('output');
|
const outputEl = document.getElementById('output');
|
||||||
const statusBar = document.querySelector('.status-bar');
|
const statusBar = document.querySelector('.status-bar');
|
||||||
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz') ? 'none' : 'block';
|
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics' || mode === 'spaceweather') ? 'none' : 'block';
|
||||||
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'dmr' || mode === 'subghz') ? 'none' : 'flex';
|
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'dmr' || mode === 'subghz' || mode === 'spaceweather') ? 'none' : 'flex';
|
||||||
|
|
||||||
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
|
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
|
||||||
if (mode !== 'meshtastic') {
|
if (mode !== 'meshtastic') {
|
||||||
@@ -3959,6 +4136,8 @@
|
|||||||
SubGhz.init();
|
SubGhz.init();
|
||||||
} else if (mode === 'bt_locate') {
|
} else if (mode === 'bt_locate') {
|
||||||
BtLocate.init();
|
BtLocate.init();
|
||||||
|
} else if (mode === 'spaceweather') {
|
||||||
|
SpaceWeather.init();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5849,7 +6028,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);
|
||||||
}
|
}
|
||||||
@@ -5865,7 +6044,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);
|
||||||
}
|
}
|
||||||
@@ -8519,7 +8698,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);
|
||||||
@@ -10339,7 +10518,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);
|
||||||
}
|
}
|
||||||
@@ -10412,7 +10591,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15453,313 +15632,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,44 @@
|
|||||||
<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 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="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span><span class="desc">Space Weather - Solar & geomagnetic data</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -96,15 +105,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 +114,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 +166,47 @@
|
|||||||
<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>Space Weather Mode</h3>
|
||||||
|
<ul class="tip-list">
|
||||||
|
<li>Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL</li>
|
||||||
|
<li>Solar indices (SFI, Kp, A-index, sunspot number) and NOAA G/S/R scales</li>
|
||||||
|
<li>HF band conditions, X-ray flux, solar wind speed, and flare probability</li>
|
||||||
|
<li>Solar imagery (SDO 193/304/Magnetogram), D-RAP absorption maps, aurora forecast</li>
|
||||||
|
<li>No SDR hardware required — all data from public APIs</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 +225,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 +243,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 +330,20 @@
|
|||||||
<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>Space Weather:</strong> Internet connection (public APIs)</li>
|
||||||
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>
|
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>
|
||||||
<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 (N. America)</td>
|
||||||
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">131.725 / 131.825 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': ['131.725', '131.825'],
|
||||||
|
'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>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<!-- ANALYTICS MODE -->
|
||||||
|
<div id="analyticsMode" class="mode-content">
|
||||||
|
{# Analytics Dashboard Sidebar Panel #}
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||||
|
<span>Summary</span>
|
||||||
|
<span class="collapse-icon">▼</span>
|
||||||
|
</h3>
|
||||||
|
<div class="section-content">
|
||||||
|
<div class="analytics-grid" id="analyticsSummaryCards">
|
||||||
|
<div class="analytics-card" data-mode="adsb">
|
||||||
|
<div class="card-count" id="analyticsCountAdsb">0</div>
|
||||||
|
<div class="card-label">Aircraft</div>
|
||||||
|
<div class="card-sparkline" id="analyticsSparkAdsb"></div>
|
||||||
|
</div>
|
||||||
|
<div class="analytics-card" data-mode="ais">
|
||||||
|
<div class="card-count" id="analyticsCountAis">0</div>
|
||||||
|
<div class="card-label">Vessels</div>
|
||||||
|
<div class="card-sparkline" id="analyticsSparkAis"></div>
|
||||||
|
</div>
|
||||||
|
<div class="analytics-card" data-mode="wifi">
|
||||||
|
<div class="card-count" id="analyticsCountWifi">0</div>
|
||||||
|
<div class="card-label">WiFi</div>
|
||||||
|
<div class="card-sparkline" id="analyticsSparkWifi"></div>
|
||||||
|
</div>
|
||||||
|
<div class="analytics-card" data-mode="bluetooth">
|
||||||
|
<div class="card-count" id="analyticsCountBt">0</div>
|
||||||
|
<div class="card-label">Bluetooth</div>
|
||||||
|
<div class="card-sparkline" id="analyticsSparkBt"></div>
|
||||||
|
</div>
|
||||||
|
<div class="analytics-card" data-mode="dsc">
|
||||||
|
<div class="card-count" id="analyticsCountDsc">0</div>
|
||||||
|
<div class="card-label">DSC</div>
|
||||||
|
<div class="card-sparkline" id="analyticsSparkDsc"></div>
|
||||||
|
</div>
|
||||||
|
<div class="analytics-card" data-mode="acars">
|
||||||
|
<div class="card-count" id="analyticsCountAcars">0</div>
|
||||||
|
<div class="card-label">ACARS</div>
|
||||||
|
<div class="card-sparkline" id="analyticsSparkAcars"></div>
|
||||||
|
</div>
|
||||||
|
<div class="analytics-card" data-mode="vdl2">
|
||||||
|
<div class="card-count" id="analyticsCountVdl2">0</div>
|
||||||
|
<div class="card-label">VDL2</div>
|
||||||
|
<div class="card-sparkline" id="analyticsSparkVdl2"></div>
|
||||||
|
</div>
|
||||||
|
<div class="analytics-card" data-mode="aprs">
|
||||||
|
<div class="card-count" id="analyticsCountAprs">0</div>
|
||||||
|
<div class="card-label">APRS</div>
|
||||||
|
<div class="card-sparkline" id="analyticsSparkAprs"></div>
|
||||||
|
</div>
|
||||||
|
<div class="analytics-card" data-mode="meshtastic">
|
||||||
|
<div class="card-count" id="analyticsCountMesh">0</div>
|
||||||
|
<div class="card-label">Mesh</div>
|
||||||
|
<div class="card-sparkline" id="analyticsSparkMesh"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||||
|
<span>Mode Health</span>
|
||||||
|
<span class="collapse-icon">▼</span>
|
||||||
|
</h3>
|
||||||
|
<div class="section-content">
|
||||||
|
<div class="analytics-health" id="analyticsHealth"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" id="analyticsSquawkSection" style="display:none;">
|
||||||
|
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||||
|
<span>Emergency Squawks</span>
|
||||||
|
<span class="collapse-icon">▼</span>
|
||||||
|
</h3>
|
||||||
|
<div class="section-content">
|
||||||
|
<div class="squawk-emergency" id="analyticsSquawkPanel">
|
||||||
|
<div class="squawk-title">Active Emergency Codes</div>
|
||||||
|
<div id="analyticsSquawkList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||||
|
<span>Recent Alerts</span>
|
||||||
|
<span class="collapse-icon">▼</span>
|
||||||
|
</h3>
|
||||||
|
<div class="section-content">
|
||||||
|
<div class="analytics-alert-feed" id="analyticsAlertFeed">
|
||||||
|
<div class="analytics-empty">No recent alerts</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||||
|
<span>Correlations</span>
|
||||||
|
<span class="collapse-icon">▼</span>
|
||||||
|
</h3>
|
||||||
|
<div class="section-content">
|
||||||
|
<div id="analyticsCorrelations">
|
||||||
|
<div class="analytics-empty">No correlations detected</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||||
|
<span>Geofences</span>
|
||||||
|
<span class="collapse-icon">▼</span>
|
||||||
|
</h3>
|
||||||
|
<div class="section-content">
|
||||||
|
<div id="analyticsGeofenceList"></div>
|
||||||
|
<button class="btn btn-sm" onclick="Analytics.addGeofence()" style="margin-top:8px; font-size:10px; padding:4px 10px; background:var(--accent-cyan); color:#fff; border:none; border-radius:4px; cursor:pointer;">
|
||||||
|
+ Add Zone
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||||
|
<span>Export Data</span>
|
||||||
|
<span class="collapse-icon">▼</span>
|
||||||
|
</h3>
|
||||||
|
<div class="section-content">
|
||||||
|
<div class="export-controls">
|
||||||
|
<select id="exportMode">
|
||||||
|
<option value="adsb">ADS-B</option>
|
||||||
|
<option value="ais">AIS</option>
|
||||||
|
<option value="wifi">WiFi</option>
|
||||||
|
<option value="bluetooth">Bluetooth</option>
|
||||||
|
<option value="dsc">DSC</option>
|
||||||
|
</select>
|
||||||
|
<select id="exportFormat">
|
||||||
|
<option value="json">JSON</option>
|
||||||
|
<option value="csv">CSV</option>
|
||||||
|
</select>
|
||||||
|
<button onclick="Analytics.exportData()">Export</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<!-- SPACE WEATHER MODE -->
|
||||||
|
<div id="spaceWeatherMode" class="mode-content">
|
||||||
|
<div class="section">
|
||||||
|
<h3>Space Weather Monitor</h3>
|
||||||
|
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
||||||
|
Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL.
|
||||||
|
No SDR hardware required — data is fetched from public APIs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Quick Status</h3>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; font-size: 11px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||||||
|
<div style="display: flex; justify-content: space-between; padding: 4px 6px; background: var(--bg-primary); border-radius: 3px;">
|
||||||
|
<span style="color: var(--text-dim);">SFI</span>
|
||||||
|
<span id="swSidebarSfi" style="color: var(--accent-cyan); font-weight: 600;">--</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between; padding: 4px 6px; background: var(--bg-primary); border-radius: 3px;">
|
||||||
|
<span style="color: var(--text-dim);">Kp</span>
|
||||||
|
<span id="swSidebarKp" style="color: var(--accent-cyan); font-weight: 600;">--</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between; padding: 4px 6px; background: var(--bg-primary); border-radius: 3px;">
|
||||||
|
<span style="color: var(--text-dim);">A-Index</span>
|
||||||
|
<span id="swSidebarA" style="color: var(--accent-cyan); font-weight: 600;">--</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between; padding: 4px 6px; background: var(--bg-primary); border-radius: 3px;">
|
||||||
|
<span style="color: var(--text-dim);">SSN</span>
|
||||||
|
<span id="swSidebarSsn" style="color: var(--accent-cyan); font-weight: 600;">--</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between; padding: 4px 6px; background: var(--bg-primary); border-radius: 3px;">
|
||||||
|
<span style="color: var(--text-dim);">Wind</span>
|
||||||
|
<span id="swSidebarWind" style="color: var(--accent-cyan); font-weight: 600;">--</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between; padding: 4px 6px; background: var(--bg-primary); border-radius: 3px;">
|
||||||
|
<span style="color: var(--text-dim);">Bz</span>
|
||||||
|
<span id="swSidebarBz" style="color: var(--accent-cyan); font-weight: 600;">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Refresh</h3>
|
||||||
|
<button class="mode-btn" onclick="SpaceWeather.refresh()" style="width: 100%; margin-bottom: 8px;">
|
||||||
|
Refresh Now
|
||||||
|
</button>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display: flex; align-items: center; gap: 6px;">
|
||||||
|
<input type="checkbox" id="swAutoRefresh" checked onchange="SpaceWeather.toggleAutoRefresh()" style="width: auto;">
|
||||||
|
Auto-refresh (5 min)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="swLastUpdate" style="font-size: 10px; color: var(--text-dim); font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; margin-top: 4px;">
|
||||||
|
Not yet loaded
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Resources</h3>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 6px;">
|
||||||
|
<a href="https://www.swpc.noaa.gov/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||||
|
NOAA Space Weather
|
||||||
|
</a>
|
||||||
|
<a href="https://sdo.gsfc.nasa.gov/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||||
|
NASA SDO
|
||||||
|
</a>
|
||||||
|
<a href="https://www.hamqsl.com/solar.html" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||||
|
HamQSL Solar Data
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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>') }}
|
||||||
@@ -101,6 +103,7 @@
|
|||||||
|
|
||||||
<div class="mode-nav-dropdown-menu">
|
<div class="mode-nav-dropdown-menu">
|
||||||
{{ mode_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
{{ mode_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
||||||
|
{{ mode_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,6 +125,7 @@
|
|||||||
{{ mode_item('weathersat', 'Weather Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
{{ mode_item('weathersat', 'Weather Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||||
{{ mode_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
{{ mode_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
||||||
{{ mode_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
|
{{ mode_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
|
||||||
|
{{ mode_item('spaceweather', 'Space Weather', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -178,11 +182,14 @@
|
|||||||
{{ 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>') }}
|
||||||
{{ mobile_item('bt_locate', 'Locate', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
|
{{ mobile_item('bt_locate', 'Locate', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
|
||||||
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
||||||
|
{{ mobile_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
|
||||||
{% if is_index_page %}
|
{% if is_index_page %}
|
||||||
{{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>') }}
|
{{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>') }}
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -192,6 +199,7 @@
|
|||||||
{{ mobile_item('weathersat', 'WxSat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
{{ mobile_item('weathersat', 'WxSat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||||
{{ mobile_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
|
{{ mobile_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
|
||||||
{{ mobile_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
|
{{ mobile_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
|
||||||
|
{{ mobile_item('spaceweather', 'SpaceWx', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/></svg>') }}
|
||||||
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
|
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
|
||||||
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||||
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
|
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""Tests for analytics endpoints, export, and squawk detection."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope='session')
|
||||||
|
def app():
|
||||||
|
"""Create application for testing."""
|
||||||
|
import app as app_module
|
||||||
|
import utils.database as db_mod
|
||||||
|
from routes import register_blueprints
|
||||||
|
|
||||||
|
app_module.app.config['TESTING'] = True
|
||||||
|
|
||||||
|
# Use temp directory for test database
|
||||||
|
tmp_dir = Path(tempfile.mkdtemp())
|
||||||
|
db_mod.DB_DIR = tmp_dir
|
||||||
|
db_mod.DB_PATH = tmp_dir / 'test_intercept.db'
|
||||||
|
# Reset thread-local connection so it picks up new path
|
||||||
|
if hasattr(db_mod._local, 'connection') and db_mod._local.connection:
|
||||||
|
db_mod._local.connection.close()
|
||||||
|
db_mod._local.connection = None
|
||||||
|
|
||||||
|
db_mod.init_db()
|
||||||
|
|
||||||
|
if 'pager' not in app_module.app.blueprints:
|
||||||
|
register_blueprints(app_module.app)
|
||||||
|
|
||||||
|
return app_module.app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app):
|
||||||
|
client = app.test_client()
|
||||||
|
# Set session login to bypass require_login before_request hook
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess['logged_in'] = True
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnalyticsSummary:
|
||||||
|
"""Tests for /analytics/summary endpoint."""
|
||||||
|
|
||||||
|
def test_summary_returns_json(self, client):
|
||||||
|
response = client.get('/analytics/summary')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert 'counts' in data
|
||||||
|
assert 'health' in data
|
||||||
|
assert 'squawks' in data
|
||||||
|
|
||||||
|
def test_summary_counts_structure(self, client):
|
||||||
|
response = client.get('/analytics/summary')
|
||||||
|
data = json.loads(response.data)
|
||||||
|
counts = data['counts']
|
||||||
|
assert 'adsb' in counts
|
||||||
|
assert 'ais' in counts
|
||||||
|
assert 'wifi' in counts
|
||||||
|
assert 'bluetooth' in counts
|
||||||
|
assert 'dsc' in counts
|
||||||
|
# All should be integers
|
||||||
|
for val in counts.values():
|
||||||
|
assert isinstance(val, int)
|
||||||
|
|
||||||
|
def test_summary_health_structure(self, client):
|
||||||
|
response = client.get('/analytics/summary')
|
||||||
|
data = json.loads(response.data)
|
||||||
|
health = data['health']
|
||||||
|
# Should have process statuses
|
||||||
|
assert 'pager' in health
|
||||||
|
assert 'sensor' in health
|
||||||
|
assert 'adsb' in health
|
||||||
|
# Each should have a running flag
|
||||||
|
for mode_info in health.values():
|
||||||
|
if isinstance(mode_info, dict) and 'running' in mode_info:
|
||||||
|
assert isinstance(mode_info['running'], bool)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnalyticsExport:
|
||||||
|
"""Tests for /analytics/export/<mode> endpoint."""
|
||||||
|
|
||||||
|
def test_export_adsb_json(self, client):
|
||||||
|
response = client.get('/analytics/export/adsb?format=json')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert data['mode'] == 'adsb'
|
||||||
|
assert 'data' in data
|
||||||
|
assert isinstance(data['data'], list)
|
||||||
|
|
||||||
|
def test_export_adsb_csv(self, client):
|
||||||
|
response = client.get('/analytics/export/adsb?format=csv')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.content_type.startswith('text/csv')
|
||||||
|
assert 'Content-Disposition' in response.headers
|
||||||
|
|
||||||
|
def test_export_invalid_mode(self, client):
|
||||||
|
response = client.get('/analytics/export/invalid_mode')
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
|
||||||
|
def test_export_wifi_json(self, client):
|
||||||
|
response = client.get('/analytics/export/wifi?format=json')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert data['mode'] == 'wifi'
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnalyticsSquawks:
|
||||||
|
"""Tests for squawk detection."""
|
||||||
|
|
||||||
|
def test_squawks_endpoint(self, client):
|
||||||
|
response = client.get('/analytics/squawks')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert isinstance(data['squawks'], list)
|
||||||
|
|
||||||
|
def test_get_emergency_squawks_detects_7700(self):
|
||||||
|
from utils.analytics import get_emergency_squawks
|
||||||
|
|
||||||
|
# Mock the adsb_aircraft DataStore
|
||||||
|
mock_store = MagicMock()
|
||||||
|
mock_store.items.return_value = [
|
||||||
|
('ABC123', {'squawk': '7700', 'callsign': 'TEST01', 'altitude': 35000}),
|
||||||
|
('DEF456', {'squawk': '1200', 'callsign': 'TEST02'}),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch('utils.analytics.app_module') as mock_app:
|
||||||
|
mock_app.adsb_aircraft = mock_store
|
||||||
|
squawks = get_emergency_squawks()
|
||||||
|
|
||||||
|
assert len(squawks) == 1
|
||||||
|
assert squawks[0]['squawk'] == '7700'
|
||||||
|
assert squawks[0]['meaning'] == 'General Emergency'
|
||||||
|
assert squawks[0]['icao'] == 'ABC123'
|
||||||
|
|
||||||
|
|
||||||
|
class TestGeofenceCRUD:
|
||||||
|
"""Tests for geofence CRUD endpoints."""
|
||||||
|
|
||||||
|
def test_list_geofences(self, client):
|
||||||
|
response = client.get('/analytics/geofences')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert isinstance(data['zones'], list)
|
||||||
|
|
||||||
|
def test_create_geofence(self, client):
|
||||||
|
response = client.post('/analytics/geofences',
|
||||||
|
data=json.dumps({
|
||||||
|
'name': 'Test Zone',
|
||||||
|
'lat': 51.5074,
|
||||||
|
'lon': -0.1278,
|
||||||
|
'radius_m': 500,
|
||||||
|
}),
|
||||||
|
content_type='application/json')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert 'zone_id' in data
|
||||||
|
|
||||||
|
def test_create_geofence_missing_fields(self, client):
|
||||||
|
response = client.post('/analytics/geofences',
|
||||||
|
data=json.dumps({'name': 'No coords'}),
|
||||||
|
content_type='application/json')
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_create_geofence_invalid_coords(self, client):
|
||||||
|
response = client.post('/analytics/geofences',
|
||||||
|
data=json.dumps({
|
||||||
|
'name': 'Bad',
|
||||||
|
'lat': 100,
|
||||||
|
'lon': 0,
|
||||||
|
'radius_m': 100,
|
||||||
|
}),
|
||||||
|
content_type='application/json')
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_delete_geofence_not_found(self, client):
|
||||||
|
response = client.delete('/analytics/geofences/99999')
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnalyticsActivity:
|
||||||
|
"""Tests for /analytics/activity endpoint."""
|
||||||
|
|
||||||
|
def test_activity_returns_sparklines(self, client):
|
||||||
|
response = client.get('/analytics/activity')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert 'sparklines' in data
|
||||||
|
assert isinstance(data['sparklines'], dict)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Tests for FlightCorrelator: ACARS/VDL2 message matching."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from utils.flight_correlator import FlightCorrelator
|
||||||
|
|
||||||
|
|
||||||
|
class TestFlightCorrelator:
|
||||||
|
"""Test ACARS/VDL2 message matching by callsign."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup(self):
|
||||||
|
self.correlator = FlightCorrelator(max_messages=100)
|
||||||
|
|
||||||
|
def test_add_acars_message(self):
|
||||||
|
self.correlator.add_acars_message({
|
||||||
|
'flight': 'BAW123', 'tail': 'G-ABCD', 'text': 'Hello',
|
||||||
|
})
|
||||||
|
assert self.correlator.acars_count == 1
|
||||||
|
|
||||||
|
def test_add_vdl2_message(self):
|
||||||
|
self.correlator.add_vdl2_message({
|
||||||
|
'flight': 'DLH456', 'text': 'World',
|
||||||
|
})
|
||||||
|
assert self.correlator.vdl2_count == 1
|
||||||
|
|
||||||
|
def test_match_by_callsign(self):
|
||||||
|
self.correlator.add_acars_message({
|
||||||
|
'flight': 'BAW123', 'text': 'msg1',
|
||||||
|
})
|
||||||
|
self.correlator.add_acars_message({
|
||||||
|
'flight': 'DLH456', 'text': 'msg2',
|
||||||
|
})
|
||||||
|
|
||||||
|
result = self.correlator.get_messages_for_aircraft(callsign='BAW123')
|
||||||
|
assert len(result['acars']) == 1
|
||||||
|
assert result['acars'][0]['text'] == 'msg1'
|
||||||
|
|
||||||
|
def test_match_by_icao(self):
|
||||||
|
self.correlator.add_vdl2_message({
|
||||||
|
'icao': 'ABC123', 'text': 'vdl2 msg',
|
||||||
|
})
|
||||||
|
|
||||||
|
result = self.correlator.get_messages_for_aircraft(icao='ABC123')
|
||||||
|
assert len(result['vdl2']) == 1
|
||||||
|
assert result['vdl2'][0]['text'] == 'vdl2 msg'
|
||||||
|
|
||||||
|
def test_no_match_returns_empty(self):
|
||||||
|
self.correlator.add_acars_message({'flight': 'BAW123', 'text': 'msg'})
|
||||||
|
|
||||||
|
result = self.correlator.get_messages_for_aircraft(callsign='NOMATCH')
|
||||||
|
assert result['acars'] == []
|
||||||
|
assert result['vdl2'] == []
|
||||||
|
|
||||||
|
def test_empty_search_returns_empty(self):
|
||||||
|
result = self.correlator.get_messages_for_aircraft()
|
||||||
|
assert result == {'acars': [], 'vdl2': []}
|
||||||
|
|
||||||
|
def test_ring_buffer_limit(self):
|
||||||
|
correlator = FlightCorrelator(max_messages=5)
|
||||||
|
for i in range(10):
|
||||||
|
correlator.add_acars_message({'flight': f'FL{i}', 'text': f'msg{i}'})
|
||||||
|
|
||||||
|
assert correlator.acars_count == 5
|
||||||
|
# First 5 messages should have been evicted
|
||||||
|
result = correlator.get_messages_for_aircraft(callsign='FL0')
|
||||||
|
assert len(result['acars']) == 0
|
||||||
|
# Last message should still be there
|
||||||
|
result = correlator.get_messages_for_aircraft(callsign='FL9')
|
||||||
|
assert len(result['acars']) == 1
|
||||||
|
|
||||||
|
def test_case_insensitive_matching(self):
|
||||||
|
self.correlator.add_acars_message({'flight': 'baw123', 'text': 'lowercase'})
|
||||||
|
|
||||||
|
result = self.correlator.get_messages_for_aircraft(callsign='BAW123')
|
||||||
|
assert len(result['acars']) == 1
|
||||||
|
|
||||||
|
def test_match_by_tail_field(self):
|
||||||
|
self.correlator.add_acars_message({
|
||||||
|
'tail': 'G-ABCD', 'text': 'tail match',
|
||||||
|
})
|
||||||
|
|
||||||
|
result = self.correlator.get_messages_for_aircraft(callsign='G-ABCD')
|
||||||
|
assert len(result['acars']) == 1
|
||||||
|
|
||||||
|
def test_internal_fields_not_returned(self):
|
||||||
|
self.correlator.add_acars_message({'flight': 'TEST', 'text': 'msg'})
|
||||||
|
|
||||||
|
result = self.correlator.get_messages_for_aircraft(callsign='TEST')
|
||||||
|
msg = result['acars'][0]
|
||||||
|
assert '_corr_time' not in msg
|
||||||
|
|
||||||
|
def test_both_acars_and_vdl2_returned(self):
|
||||||
|
self.correlator.add_acars_message({'flight': 'UAL789', 'text': 'acars'})
|
||||||
|
self.correlator.add_vdl2_message({'flight': 'UAL789', 'text': 'vdl2'})
|
||||||
|
|
||||||
|
result = self.correlator.get_messages_for_aircraft(callsign='UAL789')
|
||||||
|
assert len(result['acars']) == 1
|
||||||
|
assert len(result['vdl2']) == 1
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Tests for geofence haversine, enter/exit detection, and persistence."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
class TestHaversineDistance:
|
||||||
|
"""Test haversine_distance accuracy."""
|
||||||
|
|
||||||
|
def test_same_point_zero_distance(self):
|
||||||
|
from utils.geofence import haversine_distance
|
||||||
|
assert haversine_distance(51.5, -0.1, 51.5, -0.1) == 0.0
|
||||||
|
|
||||||
|
def test_known_distance_london_paris(self):
|
||||||
|
from utils.geofence import haversine_distance
|
||||||
|
# London to Paris ~340km
|
||||||
|
dist = haversine_distance(51.5074, -0.1278, 48.8566, 2.3522)
|
||||||
|
assert 340_000 < dist < 345_000
|
||||||
|
|
||||||
|
def test_short_distance(self):
|
||||||
|
from utils.geofence import haversine_distance
|
||||||
|
# Two points ~111m apart (0.001 degrees latitude at equator)
|
||||||
|
dist = haversine_distance(0.0, 0.0, 0.001, 0.0)
|
||||||
|
assert 100 < dist < 120
|
||||||
|
|
||||||
|
def test_antipodal_distance(self):
|
||||||
|
from utils.geofence import haversine_distance
|
||||||
|
# North pole to south pole ~20015km
|
||||||
|
dist = haversine_distance(90.0, 0.0, -90.0, 0.0)
|
||||||
|
assert 20_000_000 < dist < 20_050_000
|
||||||
|
|
||||||
|
|
||||||
|
class TestGeofenceManager:
|
||||||
|
"""Test enter/exit detection logic."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _setup(self):
|
||||||
|
"""Provide a fresh GeofenceManager with mocked DB."""
|
||||||
|
from utils.geofence import GeofenceManager
|
||||||
|
|
||||||
|
with patch('utils.geofence._ensure_table'), patch('utils.geofence.get_db') as mock_db:
|
||||||
|
# Mock the context manager
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_db.return_value.__enter__ = MagicMock(return_value=mock_conn)
|
||||||
|
mock_db.return_value.__exit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
self.manager = GeofenceManager()
|
||||||
|
# Override list_zones to return test data
|
||||||
|
self._zones = []
|
||||||
|
self.manager.list_zones = lambda: self._zones
|
||||||
|
|
||||||
|
def test_no_zones_returns_empty(self):
|
||||||
|
events = self.manager.check_position('TEST1', 'aircraft', 51.5, -0.1)
|
||||||
|
assert events == []
|
||||||
|
|
||||||
|
def test_enter_event(self):
|
||||||
|
self._zones = [{
|
||||||
|
'id': 1, 'name': 'London', 'lat': 51.5074, 'lon': -0.1278,
|
||||||
|
'radius_m': 10000, 'alert_on': 'enter_exit',
|
||||||
|
}]
|
||||||
|
# First position inside zone
|
||||||
|
events = self.manager.check_position('AC1', 'aircraft', 51.5074, -0.1278)
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0]['type'] == 'geofence_enter'
|
||||||
|
assert events[0]['zone_name'] == 'London'
|
||||||
|
|
||||||
|
def test_no_duplicate_enter(self):
|
||||||
|
self._zones = [{
|
||||||
|
'id': 1, 'name': 'London', 'lat': 51.5074, 'lon': -0.1278,
|
||||||
|
'radius_m': 10000, 'alert_on': 'enter_exit',
|
||||||
|
}]
|
||||||
|
# First enter
|
||||||
|
self.manager.check_position('AC1', 'aircraft', 51.5074, -0.1278)
|
||||||
|
# Second check still inside - should not fire enter again
|
||||||
|
events = self.manager.check_position('AC1', 'aircraft', 51.508, -0.128)
|
||||||
|
assert len(events) == 0
|
||||||
|
|
||||||
|
def test_exit_event(self):
|
||||||
|
self._zones = [{
|
||||||
|
'id': 1, 'name': 'London', 'lat': 51.5074, 'lon': -0.1278,
|
||||||
|
'radius_m': 1000, 'alert_on': 'enter_exit',
|
||||||
|
}]
|
||||||
|
# Enter
|
||||||
|
self.manager.check_position('AC1', 'aircraft', 51.5074, -0.1278)
|
||||||
|
# Exit (far away)
|
||||||
|
events = self.manager.check_position('AC1', 'aircraft', 52.0, 0.0)
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0]['type'] == 'geofence_exit'
|
||||||
|
|
||||||
|
def test_enter_only_mode(self):
|
||||||
|
self._zones = [{
|
||||||
|
'id': 1, 'name': 'London', 'lat': 51.5074, 'lon': -0.1278,
|
||||||
|
'radius_m': 1000, 'alert_on': 'enter',
|
||||||
|
}]
|
||||||
|
# Enter
|
||||||
|
events = self.manager.check_position('AC1', 'aircraft', 51.5074, -0.1278)
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0]['type'] == 'geofence_enter'
|
||||||
|
# Exit should not fire
|
||||||
|
events = self.manager.check_position('AC1', 'aircraft', 52.0, 0.0)
|
||||||
|
assert len(events) == 0
|
||||||
|
|
||||||
|
def test_metadata_included_in_event(self):
|
||||||
|
self._zones = [{
|
||||||
|
'id': 1, 'name': 'Zone', 'lat': 0.0, 'lon': 0.0,
|
||||||
|
'radius_m': 100000, 'alert_on': 'enter_exit',
|
||||||
|
}]
|
||||||
|
events = self.manager.check_position(
|
||||||
|
'AC1', 'aircraft', 0.0, 0.0,
|
||||||
|
metadata={'callsign': 'TEST01', 'altitude': 35000}
|
||||||
|
)
|
||||||
|
assert events[0]['callsign'] == 'TEST01'
|
||||||
|
assert events[0]['altitude'] == 35000
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"""Cross-mode analytics: activity tracking, summaries, and emergency squawk detection."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import app as app_module
|
||||||
|
|
||||||
|
|
||||||
|
class ModeActivityTracker:
|
||||||
|
"""Track device counts per mode in time-bucketed ring buffer for sparklines."""
|
||||||
|
|
||||||
|
def __init__(self, max_buckets: int = 60, bucket_interval: float = 5.0):
|
||||||
|
self._max_buckets = max_buckets
|
||||||
|
self._bucket_interval = bucket_interval
|
||||||
|
self._history: dict[str, deque] = {}
|
||||||
|
self._last_record_time = 0.0
|
||||||
|
|
||||||
|
def record(self) -> None:
|
||||||
|
"""Snapshot current counts for all modes."""
|
||||||
|
now = time.time()
|
||||||
|
if now - self._last_record_time < self._bucket_interval:
|
||||||
|
return
|
||||||
|
self._last_record_time = now
|
||||||
|
|
||||||
|
counts = _get_mode_counts()
|
||||||
|
for mode, count in counts.items():
|
||||||
|
if mode not in self._history:
|
||||||
|
self._history[mode] = deque(maxlen=self._max_buckets)
|
||||||
|
self._history[mode].append(count)
|
||||||
|
|
||||||
|
def get_sparkline(self, mode: str) -> list[int]:
|
||||||
|
"""Return sparkline array for a mode."""
|
||||||
|
self.record()
|
||||||
|
return list(self._history.get(mode, []))
|
||||||
|
|
||||||
|
def get_all_sparklines(self) -> dict[str, list[int]]:
|
||||||
|
"""Return sparkline arrays for all tracked modes."""
|
||||||
|
self.record()
|
||||||
|
return {mode: list(values) for mode, values in self._history.items()}
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton
|
||||||
|
_tracker: ModeActivityTracker | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_activity_tracker() -> ModeActivityTracker:
|
||||||
|
global _tracker
|
||||||
|
if _tracker is None:
|
||||||
|
_tracker = ModeActivityTracker()
|
||||||
|
return _tracker
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_len(attr_name: str) -> int:
|
||||||
|
"""Safely get len() of an app_module attribute."""
|
||||||
|
try:
|
||||||
|
return len(getattr(app_module, attr_name))
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_route_attr(module_path: str, attr_name: str, default: int = 0) -> int:
|
||||||
|
"""Safely read a module-level counter from a route file."""
|
||||||
|
try:
|
||||||
|
import importlib
|
||||||
|
mod = importlib.import_module(module_path)
|
||||||
|
return int(getattr(mod, attr_name, default))
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _get_mode_counts() -> dict[str, int]:
|
||||||
|
"""Read current entity counts from all available data sources."""
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
|
||||||
|
# ADS-B aircraft (DataStore)
|
||||||
|
counts['adsb'] = _safe_len('adsb_aircraft')
|
||||||
|
|
||||||
|
# AIS vessels (DataStore)
|
||||||
|
counts['ais'] = _safe_len('ais_vessels')
|
||||||
|
|
||||||
|
# WiFi: prefer v2 scanner, fall back to legacy DataStore
|
||||||
|
wifi_count = 0
|
||||||
|
try:
|
||||||
|
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
||||||
|
if wifi_scanner is not None:
|
||||||
|
wifi_count = len(wifi_scanner.access_points)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if wifi_count == 0:
|
||||||
|
wifi_count = _safe_len('wifi_networks')
|
||||||
|
counts['wifi'] = wifi_count
|
||||||
|
|
||||||
|
# Bluetooth: prefer v2 scanner, fall back to legacy DataStore
|
||||||
|
bt_count = 0
|
||||||
|
try:
|
||||||
|
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
||||||
|
if bt_scanner is not None:
|
||||||
|
bt_count = len(bt_scanner.get_devices())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if bt_count == 0:
|
||||||
|
bt_count = _safe_len('bt_devices')
|
||||||
|
counts['bluetooth'] = bt_count
|
||||||
|
|
||||||
|
# DSC messages (DataStore)
|
||||||
|
counts['dsc'] = _safe_len('dsc_messages')
|
||||||
|
|
||||||
|
# ACARS message count (route-level counter)
|
||||||
|
counts['acars'] = _safe_route_attr('routes.acars', 'acars_message_count')
|
||||||
|
|
||||||
|
# VDL2 message count (route-level counter)
|
||||||
|
counts['vdl2'] = _safe_route_attr('routes.vdl2', 'vdl2_message_count')
|
||||||
|
|
||||||
|
# APRS stations (route-level dict)
|
||||||
|
try:
|
||||||
|
import routes.aprs as aprs_mod
|
||||||
|
counts['aprs'] = len(getattr(aprs_mod, 'aprs_stations', {}))
|
||||||
|
except Exception:
|
||||||
|
counts['aprs'] = 0
|
||||||
|
|
||||||
|
# Meshtastic recent messages (route-level list)
|
||||||
|
try:
|
||||||
|
import routes.meshtastic as mesh_route
|
||||||
|
counts['meshtastic'] = len(getattr(mesh_route, '_recent_messages', []))
|
||||||
|
except Exception:
|
||||||
|
counts['meshtastic'] = 0
|
||||||
|
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def get_cross_mode_summary() -> dict[str, Any]:
|
||||||
|
"""Return counts dict for all available data sources."""
|
||||||
|
counts = _get_mode_counts()
|
||||||
|
wifi_clients_count = 0
|
||||||
|
try:
|
||||||
|
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
||||||
|
if wifi_scanner is not None:
|
||||||
|
wifi_clients_count = len(wifi_scanner.clients)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if wifi_clients_count == 0:
|
||||||
|
wifi_clients_count = _safe_len('wifi_clients')
|
||||||
|
counts['wifi_clients'] = wifi_clients_count
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def get_mode_health() -> dict[str, dict]:
|
||||||
|
"""Check process refs and SDR status for each mode."""
|
||||||
|
health: dict[str, dict] = {}
|
||||||
|
|
||||||
|
process_map = {
|
||||||
|
'pager': 'current_process',
|
||||||
|
'sensor': 'sensor_process',
|
||||||
|
'adsb': 'adsb_process',
|
||||||
|
'ais': 'ais_process',
|
||||||
|
'acars': 'acars_process',
|
||||||
|
'vdl2': 'vdl2_process',
|
||||||
|
'aprs': 'aprs_process',
|
||||||
|
'wifi': 'wifi_process',
|
||||||
|
'bluetooth': 'bt_process',
|
||||||
|
'dsc': 'dsc_process',
|
||||||
|
'rtlamr': 'rtlamr_process',
|
||||||
|
'dmr': 'dmr_process',
|
||||||
|
}
|
||||||
|
|
||||||
|
for mode, attr in process_map.items():
|
||||||
|
proc = getattr(app_module, attr, None)
|
||||||
|
running = proc is not None and (proc.poll() is None if proc else False)
|
||||||
|
health[mode] = {'running': running}
|
||||||
|
|
||||||
|
# Override WiFi/BT health with v2 scanner status if available
|
||||||
|
try:
|
||||||
|
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
||||||
|
if wifi_scanner is not None and wifi_scanner.is_scanning:
|
||||||
|
health['wifi'] = {'running': True}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
||||||
|
if bt_scanner is not None and bt_scanner.is_scanning:
|
||||||
|
health['bluetooth'] = {'running': True}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Meshtastic: check client connection status
|
||||||
|
try:
|
||||||
|
from utils.meshtastic import get_meshtastic_client
|
||||||
|
client = get_meshtastic_client()
|
||||||
|
health['meshtastic'] = {'running': client._interface is not None}
|
||||||
|
except Exception:
|
||||||
|
health['meshtastic'] = {'running': False}
|
||||||
|
|
||||||
|
try:
|
||||||
|
sdr_status = app_module.get_sdr_device_status()
|
||||||
|
health['sdr_devices'] = {str(k): v for k, v in sdr_status.items()}
|
||||||
|
except Exception:
|
||||||
|
health['sdr_devices'] = {}
|
||||||
|
|
||||||
|
return health
|
||||||
|
|
||||||
|
|
||||||
|
EMERGENCY_SQUAWKS = {
|
||||||
|
'7700': 'General Emergency',
|
||||||
|
'7600': 'Comms Failure',
|
||||||
|
'7500': 'Hijack',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_emergency_squawks() -> list[dict]:
|
||||||
|
"""Iterate adsb_aircraft DataStore for emergency squawk codes."""
|
||||||
|
emergencies: list[dict] = []
|
||||||
|
try:
|
||||||
|
for icao, aircraft in app_module.adsb_aircraft.items():
|
||||||
|
sq = str(aircraft.get('squawk', '')).strip()
|
||||||
|
if sq in EMERGENCY_SQUAWKS:
|
||||||
|
emergencies.append({
|
||||||
|
'icao': icao,
|
||||||
|
'callsign': aircraft.get('callsign', ''),
|
||||||
|
'squawk': sq,
|
||||||
|
'meaning': EMERGENCY_SQUAWKS[sq],
|
||||||
|
'altitude': aircraft.get('altitude'),
|
||||||
|
'lat': aircraft.get('lat'),
|
||||||
|
'lon': aircraft.get('lon'),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return emergencies
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Match ACARS/VDL2 messages to ADS-B aircraft by callsign."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
|
||||||
|
class FlightCorrelator:
|
||||||
|
"""Correlate ACARS and VDL2 messages with ADS-B aircraft."""
|
||||||
|
|
||||||
|
def __init__(self, max_messages: int = 1000):
|
||||||
|
self._acars_messages: deque[dict] = deque(maxlen=max_messages)
|
||||||
|
self._vdl2_messages: deque[dict] = deque(maxlen=max_messages)
|
||||||
|
|
||||||
|
def add_acars_message(self, msg: dict) -> None:
|
||||||
|
self._acars_messages.append({
|
||||||
|
**msg,
|
||||||
|
'_corr_time': time.time(),
|
||||||
|
})
|
||||||
|
|
||||||
|
def add_vdl2_message(self, msg: dict) -> None:
|
||||||
|
self._vdl2_messages.append({
|
||||||
|
**msg,
|
||||||
|
'_corr_time': time.time(),
|
||||||
|
})
|
||||||
|
|
||||||
|
def get_messages_for_aircraft(
|
||||||
|
self, icao: str | None = None, callsign: str | None = None
|
||||||
|
) -> dict[str, list[dict]]:
|
||||||
|
"""Match ACARS/VDL2 messages by callsign, flight, or registration fields."""
|
||||||
|
if not icao and not callsign:
|
||||||
|
return {'acars': [], 'vdl2': []}
|
||||||
|
|
||||||
|
search_terms: set[str] = set()
|
||||||
|
if callsign:
|
||||||
|
search_terms.add(callsign.strip().upper())
|
||||||
|
if icao:
|
||||||
|
search_terms.add(icao.strip().upper())
|
||||||
|
|
||||||
|
acars = []
|
||||||
|
for msg in self._acars_messages:
|
||||||
|
if self._msg_matches(msg, search_terms):
|
||||||
|
acars.append(self._clean_msg(msg))
|
||||||
|
|
||||||
|
vdl2 = []
|
||||||
|
for msg in self._vdl2_messages:
|
||||||
|
if self._msg_matches(msg, search_terms):
|
||||||
|
vdl2.append(self._clean_msg(msg))
|
||||||
|
|
||||||
|
return {'acars': acars, 'vdl2': vdl2}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _msg_matches(msg: dict, terms: set[str]) -> bool:
|
||||||
|
"""Check if any identifying field in msg matches the search terms."""
|
||||||
|
for field in ('flight', 'tail', 'reg', 'callsign', 'icao', 'addr'):
|
||||||
|
val = msg.get(field)
|
||||||
|
if val and str(val).strip().upper() in terms:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clean_msg(msg: dict) -> dict:
|
||||||
|
"""Return message without internal correlation fields."""
|
||||||
|
return {k: v for k, v in msg.items() if not k.startswith('_corr_')}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def acars_count(self) -> int:
|
||||||
|
return len(self._acars_messages)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def vdl2_count(self) -> int:
|
||||||
|
return len(self._vdl2_messages)
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton
|
||||||
|
_correlator: FlightCorrelator | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_flight_correlator() -> FlightCorrelator:
|
||||||
|
global _correlator
|
||||||
|
if _correlator is None:
|
||||||
|
_correlator = FlightCorrelator()
|
||||||
|
return _correlator
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Geofence zones with haversine distance, enter/exit detection, and SQLite persistence."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from utils.database import get_db
|
||||||
|
|
||||||
|
|
||||||
|
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||||
|
"""Return distance in meters between two lat/lon points."""
|
||||||
|
R = 6_371_000 # Earth radius in meters
|
||||||
|
phi1 = math.radians(lat1)
|
||||||
|
phi2 = math.radians(lat2)
|
||||||
|
dphi = math.radians(lat2 - lat1)
|
||||||
|
dlam = math.radians(lon2 - lon1)
|
||||||
|
|
||||||
|
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
|
||||||
|
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_table() -> None:
|
||||||
|
"""Create geofence_zones table if it doesn't exist."""
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS geofence_zones (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
lat REAL NOT NULL,
|
||||||
|
lon REAL NOT NULL,
|
||||||
|
radius_m REAL NOT NULL,
|
||||||
|
alert_on TEXT DEFAULT 'enter_exit',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
|
||||||
|
class GeofenceManager:
|
||||||
|
"""Manages geofence zones with enter/exit detection."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._inside: dict[str, set[int]] = {} # entity_id -> set of zone_ids inside
|
||||||
|
_ensure_table()
|
||||||
|
|
||||||
|
def list_zones(self) -> list[dict]:
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
'SELECT id, name, lat, lon, radius_m, alert_on, created_at FROM geofence_zones ORDER BY id'
|
||||||
|
)
|
||||||
|
return [dict(row) for row in cursor]
|
||||||
|
|
||||||
|
def add_zone(self, name: str, lat: float, lon: float, radius_m: float,
|
||||||
|
alert_on: str = 'enter_exit') -> int:
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
'INSERT INTO geofence_zones (name, lat, lon, radius_m, alert_on) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
(name, lat, lon, radius_m, alert_on),
|
||||||
|
)
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
def delete_zone(self, zone_id: int) -> bool:
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('DELETE FROM geofence_zones WHERE id = ?', (zone_id,))
|
||||||
|
# Clean up inside tracking
|
||||||
|
for entity_zones in self._inside.values():
|
||||||
|
entity_zones.discard(zone_id)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def check_position(self, entity_id: str, entity_type: str,
|
||||||
|
lat: float, lon: float,
|
||||||
|
metadata: dict[str, Any] | None = None) -> list[dict]:
|
||||||
|
"""Check entity position against all zones. Returns list of events."""
|
||||||
|
zones = self.list_zones()
|
||||||
|
if not zones:
|
||||||
|
return []
|
||||||
|
|
||||||
|
events: list[dict] = []
|
||||||
|
prev_inside = self._inside.get(entity_id, set())
|
||||||
|
curr_inside: set[int] = set()
|
||||||
|
|
||||||
|
for zone in zones:
|
||||||
|
dist = haversine_distance(lat, lon, zone['lat'], zone['lon'])
|
||||||
|
zid = zone['id']
|
||||||
|
if dist <= zone['radius_m']:
|
||||||
|
curr_inside.add(zid)
|
||||||
|
|
||||||
|
if zid not in prev_inside and zone['alert_on'] in ('enter', 'enter_exit'):
|
||||||
|
events.append({
|
||||||
|
'type': 'geofence_enter',
|
||||||
|
'zone_id': zid,
|
||||||
|
'zone_name': zone['name'],
|
||||||
|
'entity_id': entity_id,
|
||||||
|
'entity_type': entity_type,
|
||||||
|
'distance_m': round(dist, 1),
|
||||||
|
'lat': lat,
|
||||||
|
'lon': lon,
|
||||||
|
**(metadata or {}),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
if zid in prev_inside and zone['alert_on'] in ('exit', 'enter_exit'):
|
||||||
|
events.append({
|
||||||
|
'type': 'geofence_exit',
|
||||||
|
'zone_id': zid,
|
||||||
|
'zone_name': zone['name'],
|
||||||
|
'entity_id': entity_id,
|
||||||
|
'entity_type': entity_type,
|
||||||
|
'distance_m': round(dist, 1),
|
||||||
|
'lat': lat,
|
||||||
|
'lon': lon,
|
||||||
|
**(metadata or {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
self._inside[entity_id] = curr_inside
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton
|
||||||
|
_manager: GeofenceManager | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_geofence_manager() -> GeofenceManager:
|
||||||
|
global _manager
|
||||||
|
if _manager is None:
|
||||||
|
_manager = GeofenceManager()
|
||||||
|
return _manager
|
||||||
@@ -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,
|
||||||
@@ -490,7 +506,7 @@ def get_current_position() -> GPSPosition | None:
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
_gpsd_process: 'subprocess.Popen | None' = None
|
_gpsd_process: 'subprocess.Popen | None' = None
|
||||||
_gpsd_process_lock = threading.Lock()
|
_gpsd_process_lock = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
def detect_gps_devices() -> list[dict]:
|
def detect_gps_devices() -> list[dict]:
|
||||||
|
|||||||
@@ -306,6 +306,9 @@ class MeshtasticClient:
|
|||||||
self._range_test_running: bool = False
|
self._range_test_running: bool = False
|
||||||
self._range_test_results: list[dict] = []
|
self._range_test_results: list[dict] = []
|
||||||
|
|
||||||
|
# Topology tracking: node_id -> {neighbors, hop_count, msg_count, last_seen}
|
||||||
|
self._topology: dict[str, dict] = {}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
return self._running
|
return self._running
|
||||||
@@ -326,6 +329,35 @@ class MeshtasticClient:
|
|||||||
"""Set callback for received messages."""
|
"""Set callback for received messages."""
|
||||||
self._callback = callback
|
self._callback = callback
|
||||||
|
|
||||||
|
def record_message_route(self, from_node: str, to_node: str, hops: int | None = None) -> None:
|
||||||
|
"""Record a message route for topology tracking."""
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
for node_id in (from_node, to_node):
|
||||||
|
if node_id not in self._topology:
|
||||||
|
self._topology[node_id] = {
|
||||||
|
'neighbors': set(),
|
||||||
|
'hop_count': hops,
|
||||||
|
'msg_count': 0,
|
||||||
|
'last_seen': now,
|
||||||
|
}
|
||||||
|
entry = self._topology[node_id]
|
||||||
|
entry['msg_count'] += 1
|
||||||
|
entry['last_seen'] = now
|
||||||
|
self._topology[from_node]['neighbors'].add(to_node)
|
||||||
|
self._topology[to_node]['neighbors'].add(from_node)
|
||||||
|
|
||||||
|
def get_topology(self) -> dict:
|
||||||
|
"""Return topology dict with serializable sets."""
|
||||||
|
result = {}
|
||||||
|
for node_id, data in self._topology.items():
|
||||||
|
result[node_id] = {
|
||||||
|
'neighbors': list(data.get('neighbors', set())),
|
||||||
|
'hop_count': data.get('hop_count'),
|
||||||
|
'msg_count': data.get('msg_count', 0),
|
||||||
|
'last_seen': data.get('last_seen'),
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
def connect(self, device: str | None = None, connection_type: str = 'serial',
|
def connect(self, device: str | None = None, connection_type: str = 'serial',
|
||||||
hostname: str | None = None) -> bool:
|
hostname: str | None = None) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -463,6 +495,14 @@ class MeshtasticClient:
|
|||||||
# Track node from packet (always, even for filtered messages)
|
# Track node from packet (always, even for filtered messages)
|
||||||
self._track_node_from_packet(packet, decoded, portnum)
|
self._track_node_from_packet(packet, decoded, portnum)
|
||||||
|
|
||||||
|
# Record topology route
|
||||||
|
if from_num and to_num:
|
||||||
|
self.record_message_route(
|
||||||
|
self._format_node_id(from_num),
|
||||||
|
self._format_node_id(to_num),
|
||||||
|
packet.get('hopLimit'),
|
||||||
|
)
|
||||||
|
|
||||||
# Parse traceroute responses
|
# Parse traceroute responses
|
||||||
if portnum == 'TRACEROUTE_APP':
|
if portnum == 'TRACEROUTE_APP':
|
||||||
self._handle_traceroute_response(packet, decoded)
|
self._handle_traceroute_response(packet, decoded)
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Periodic pattern detection via interval analysis."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
|
||||||
|
class TemporalPatternDetector:
|
||||||
|
"""Detect periodic patterns from event timestamps per device."""
|
||||||
|
|
||||||
|
def __init__(self, max_timestamps: int = 200):
|
||||||
|
self._timestamps: dict[str, list[float]] = defaultdict(list)
|
||||||
|
self._max_timestamps = max_timestamps
|
||||||
|
|
||||||
|
def record_event(self, device_id: str, mode: str, timestamp: float | None = None) -> None:
|
||||||
|
key = f"{mode}:{device_id}"
|
||||||
|
ts = timestamp or time.time()
|
||||||
|
buf = self._timestamps[key]
|
||||||
|
buf.append(ts)
|
||||||
|
if len(buf) > self._max_timestamps:
|
||||||
|
del buf[: len(buf) - self._max_timestamps]
|
||||||
|
|
||||||
|
def detect_patterns(self, device_id: str, mode: str | None = None) -> dict | None:
|
||||||
|
"""Detect periodic patterns for a device.
|
||||||
|
|
||||||
|
Returns dict with period_seconds, confidence, occurrences or None.
|
||||||
|
"""
|
||||||
|
keys = []
|
||||||
|
if mode:
|
||||||
|
keys.append(f"{mode}:{device_id}")
|
||||||
|
else:
|
||||||
|
keys = [k for k in self._timestamps if k.endswith(f":{device_id}")]
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
result = self._analyze_intervals(self._timestamps.get(key, []))
|
||||||
|
if result:
|
||||||
|
result['device_id'] = device_id
|
||||||
|
result['mode'] = key.split(':')[0]
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _analyze_intervals(self, timestamps: list[float]) -> dict | None:
|
||||||
|
if len(timestamps) < 4:
|
||||||
|
return None
|
||||||
|
|
||||||
|
intervals = [timestamps[i + 1] - timestamps[i] for i in range(len(timestamps) - 1)]
|
||||||
|
|
||||||
|
# Find the median interval
|
||||||
|
sorted_intervals = sorted(intervals)
|
||||||
|
median = sorted_intervals[len(sorted_intervals) // 2]
|
||||||
|
|
||||||
|
if median < 1.0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Count how many intervals are within 20% of the median
|
||||||
|
tolerance = median * 0.2
|
||||||
|
matching = sum(1 for iv in intervals if abs(iv - median) <= tolerance)
|
||||||
|
confidence = matching / len(intervals)
|
||||||
|
|
||||||
|
if confidence < 0.5:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
'period_seconds': round(median, 1),
|
||||||
|
'confidence': round(confidence, 3),
|
||||||
|
'occurrences': len(timestamps),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_all_patterns(self) -> list[dict]:
|
||||||
|
"""Return all detected patterns across all devices."""
|
||||||
|
results = []
|
||||||
|
seen = set()
|
||||||
|
for key in self._timestamps:
|
||||||
|
mode, device_id = key.split(':', 1)
|
||||||
|
if device_id in seen:
|
||||||
|
continue
|
||||||
|
pattern = self.detect_patterns(device_id, mode)
|
||||||
|
if pattern:
|
||||||
|
results.append(pattern)
|
||||||
|
seen.add(device_id)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton
|
||||||
|
_detector: TemporalPatternDetector | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_pattern_detector() -> TemporalPatternDetector:
|
||||||
|
global _detector
|
||||||
|
if _detector is None:
|
||||||
|
_detector = TemporalPatternDetector()
|
||||||
|
return _detector
|
||||||
@@ -156,7 +156,9 @@ class WeatherSatDecoder:
|
|||||||
self._process: subprocess.Popen | None = None
|
self._process: subprocess.Popen | None = None
|
||||||
self._running = False
|
self._running = False
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
self._pty_lock = threading.Lock()
|
||||||
self._images_lock = threading.Lock()
|
self._images_lock = threading.Lock()
|
||||||
|
self._stop_event = threading.Event()
|
||||||
self._callback: Callable[[CaptureProgress], None] | None = None
|
self._callback: Callable[[CaptureProgress], None] | None = None
|
||||||
self._output_dir = Path(output_dir) if output_dir else Path('data/weather_sat')
|
self._output_dir = Path(output_dir) if output_dir else Path('data/weather_sat')
|
||||||
self._images: list[WeatherSatImage] = []
|
self._images: list[WeatherSatImage] = []
|
||||||
@@ -212,6 +214,16 @@ class WeatherSatDecoder:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _close_pty(self) -> None:
|
||||||
|
"""Close the PTY master fd in a thread-safe manner."""
|
||||||
|
with self._pty_lock:
|
||||||
|
if self._pty_master_fd is not None:
|
||||||
|
try:
|
||||||
|
os.close(self._pty_master_fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self._pty_master_fd = None
|
||||||
|
|
||||||
def set_callback(self, callback: Callable[[CaptureProgress], None]) -> None:
|
def set_callback(self, callback: Callable[[CaptureProgress], None]) -> None:
|
||||||
"""Set callback for capture progress updates."""
|
"""Set callback for capture progress updates."""
|
||||||
self._callback = callback
|
self._callback = callback
|
||||||
@@ -292,6 +304,7 @@ class WeatherSatDecoder:
|
|||||||
self._current_mode = sat_info['mode']
|
self._current_mode = sat_info['mode']
|
||||||
self._capture_start_time = time.time()
|
self._capture_start_time = time.time()
|
||||||
self._capture_phase = 'decoding'
|
self._capture_phase = 'decoding'
|
||||||
|
self._stop_event.clear()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._running = True
|
self._running = True
|
||||||
@@ -372,6 +385,7 @@ class WeatherSatDecoder:
|
|||||||
self._device_index = device_index
|
self._device_index = device_index
|
||||||
self._capture_start_time = time.time()
|
self._capture_start_time = time.time()
|
||||||
self._capture_phase = 'tuning'
|
self._capture_phase = 'tuning'
|
||||||
|
self._stop_event.clear()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._running = True
|
self._running = True
|
||||||
@@ -781,13 +795,11 @@ class WeatherSatDecoder:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error reading SatDump output: {e}")
|
logger.error(f"Error reading SatDump output: {e}")
|
||||||
finally:
|
finally:
|
||||||
# Close PTY master fd
|
# Close PTY master fd (thread-safe)
|
||||||
if self._pty_master_fd is not None:
|
self._close_pty()
|
||||||
try:
|
|
||||||
os.close(self._pty_master_fd)
|
# Signal watcher thread to do final scan and exit
|
||||||
except OSError:
|
self._stop_event.set()
|
||||||
pass
|
|
||||||
self._pty_master_fd = None
|
|
||||||
|
|
||||||
# Process ended — release resources
|
# Process ended — release resources
|
||||||
was_running = self._running
|
was_running = self._running
|
||||||
@@ -795,17 +807,38 @@ class WeatherSatDecoder:
|
|||||||
elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0
|
elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0
|
||||||
|
|
||||||
if was_running:
|
if was_running:
|
||||||
self._capture_phase = 'complete'
|
# Collect exit status (returncode is only set after poll/wait)
|
||||||
self._emit_progress(CaptureProgress(
|
if self._process and self._process.returncode is None:
|
||||||
status='complete',
|
try:
|
||||||
satellite=self._current_satellite,
|
self._process.wait(timeout=5)
|
||||||
frequency=self._current_frequency,
|
except subprocess.TimeoutExpired:
|
||||||
mode=self._current_mode,
|
self._process.kill()
|
||||||
message=f"Capture complete ({elapsed}s)",
|
self._process.wait()
|
||||||
elapsed_seconds=elapsed,
|
retcode = self._process.returncode if self._process else None
|
||||||
log_type='info',
|
if retcode and retcode != 0:
|
||||||
capture_phase='complete',
|
self._capture_phase = 'error'
|
||||||
))
|
self._emit_progress(CaptureProgress(
|
||||||
|
status='error',
|
||||||
|
satellite=self._current_satellite,
|
||||||
|
frequency=self._current_frequency,
|
||||||
|
mode=self._current_mode,
|
||||||
|
message=f"SatDump crashed (exit code {retcode}). Check SatDump installation and SDR device.",
|
||||||
|
elapsed_seconds=elapsed,
|
||||||
|
log_type='error',
|
||||||
|
capture_phase='error',
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
self._capture_phase = 'complete'
|
||||||
|
self._emit_progress(CaptureProgress(
|
||||||
|
status='complete',
|
||||||
|
satellite=self._current_satellite,
|
||||||
|
frequency=self._current_frequency,
|
||||||
|
mode=self._current_mode,
|
||||||
|
message=f"Capture complete ({elapsed}s)",
|
||||||
|
elapsed_seconds=elapsed,
|
||||||
|
log_type='info',
|
||||||
|
capture_phase='complete',
|
||||||
|
))
|
||||||
|
|
||||||
# Notify route layer to release SDR device
|
# Notify route layer to release SDR device
|
||||||
if self._on_complete_callback:
|
if self._on_complete_callback:
|
||||||
@@ -822,63 +855,79 @@ class WeatherSatDecoder:
|
|||||||
known_files: set[str] = set()
|
known_files: set[str] = set()
|
||||||
|
|
||||||
while self._running:
|
while self._running:
|
||||||
time.sleep(2)
|
self._scan_output_dir(known_files)
|
||||||
|
# Use stop_event for faster wakeup on process exit
|
||||||
|
if self._stop_event.wait(timeout=2):
|
||||||
|
break
|
||||||
|
|
||||||
try:
|
# Final scan — SatDump writes images at the end of processing,
|
||||||
# Recursively scan for image files
|
# often after the process has already exited. Do multiple scans
|
||||||
for ext in ('*.png', '*.jpg', '*.jpeg'):
|
# with a short delay to catch late-written files.
|
||||||
for filepath in self._capture_output_dir.rglob(ext):
|
for _ in range(3):
|
||||||
file_key = str(filepath)
|
time.sleep(0.5)
|
||||||
if file_key in known_files:
|
self._scan_output_dir(known_files)
|
||||||
|
|
||||||
|
def _scan_output_dir(self, known_files: set[str]) -> None:
|
||||||
|
"""Scan capture output directory for new image files."""
|
||||||
|
if not self._capture_output_dir:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Recursively scan for image files
|
||||||
|
for ext in ('*.png', '*.jpg', '*.jpeg'):
|
||||||
|
for filepath in self._capture_output_dir.rglob(ext):
|
||||||
|
file_key = str(filepath)
|
||||||
|
if file_key in known_files:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip tiny files (likely incomplete)
|
||||||
|
try:
|
||||||
|
stat = filepath.stat()
|
||||||
|
if stat.st_size < 1000:
|
||||||
continue
|
continue
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
# Skip tiny files (likely incomplete)
|
# Determine product type from filename/path
|
||||||
try:
|
product = self._parse_product_name(filepath)
|
||||||
stat = filepath.stat()
|
|
||||||
if stat.st_size < 1000:
|
|
||||||
continue
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
known_files.add(file_key)
|
# Copy image to main output dir for serving
|
||||||
|
serve_name = f"{self._current_satellite}_{filepath.stem}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
|
||||||
|
serve_path = self._output_dir / serve_name
|
||||||
|
try:
|
||||||
|
shutil.copy2(filepath, serve_path)
|
||||||
|
except OSError:
|
||||||
|
# Copy failed — don't mark as known so it can be retried
|
||||||
|
continue
|
||||||
|
|
||||||
# Determine product type from filename/path
|
# Only mark as known after successful copy
|
||||||
product = self._parse_product_name(filepath)
|
known_files.add(file_key)
|
||||||
|
|
||||||
# Copy image to main output dir for serving
|
image = WeatherSatImage(
|
||||||
serve_name = f"{self._current_satellite}_{filepath.stem}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
|
filename=serve_name,
|
||||||
serve_path = self._output_dir / serve_name
|
path=serve_path,
|
||||||
try:
|
satellite=self._current_satellite,
|
||||||
shutil.copy2(filepath, serve_path)
|
mode=self._current_mode,
|
||||||
except OSError:
|
timestamp=datetime.now(timezone.utc),
|
||||||
serve_path = filepath
|
frequency=self._current_frequency,
|
||||||
serve_name = filepath.name
|
size_bytes=stat.st_size,
|
||||||
|
product=product,
|
||||||
|
)
|
||||||
|
with self._images_lock:
|
||||||
|
self._images.append(image)
|
||||||
|
|
||||||
image = WeatherSatImage(
|
logger.info(f"New weather satellite image: {serve_name} ({product})")
|
||||||
filename=serve_name,
|
self._emit_progress(CaptureProgress(
|
||||||
path=serve_path,
|
status='complete',
|
||||||
satellite=self._current_satellite,
|
satellite=self._current_satellite,
|
||||||
mode=self._current_mode,
|
frequency=self._current_frequency,
|
||||||
timestamp=datetime.now(timezone.utc),
|
mode=self._current_mode,
|
||||||
frequency=self._current_frequency,
|
message=f'Image decoded: {product}',
|
||||||
size_bytes=stat.st_size,
|
image=image,
|
||||||
product=product,
|
))
|
||||||
)
|
|
||||||
with self._images_lock:
|
|
||||||
self._images.append(image)
|
|
||||||
|
|
||||||
logger.info(f"New weather satellite image: {serve_name} ({product})")
|
except Exception as e:
|
||||||
self._emit_progress(CaptureProgress(
|
logger.error(f"Error scanning for images: {e}")
|
||||||
status='complete',
|
|
||||||
satellite=self._current_satellite,
|
|
||||||
frequency=self._current_frequency,
|
|
||||||
mode=self._current_mode,
|
|
||||||
message=f'Image decoded: {product}',
|
|
||||||
image=image,
|
|
||||||
))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error watching images: {e}")
|
|
||||||
|
|
||||||
def _parse_product_name(self, filepath: Path) -> str:
|
def _parse_product_name(self, filepath: Path) -> str:
|
||||||
"""Parse a human-readable product name from the image filepath."""
|
"""Parse a human-readable product name from the image filepath."""
|
||||||
@@ -916,13 +965,8 @@ class WeatherSatDecoder:
|
|||||||
"""Stop weather satellite capture."""
|
"""Stop weather satellite capture."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._running = False
|
self._running = False
|
||||||
|
self._stop_event.set()
|
||||||
if self._pty_master_fd is not None:
|
self._close_pty()
|
||||||
try:
|
|
||||||
os.close(self._pty_master_fd)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
self._pty_master_fd = None
|
|
||||||
|
|
||||||
if self._process:
|
if self._process:
|
||||||
safe_terminate(self._process)
|
safe_terminate(self._process)
|
||||||
|
|||||||