Compare commits

...

36 Commits

Author SHA1 Message Date
Smittix 16239c1d31 feat: Add Space Weather mode with real-time solar and geomagnetic monitoring
New mode providing real-time space weather data from NOAA SWPC, NASA SDO,
and HamQSL APIs. Includes Kp index, solar wind, X-ray flux charts, HF band
conditions, D-RAP absorption maps, aurora forecast, solar imagery, flare
probability, and active solar regions. No SDR hardware required.

Bumps version to 2.20.0. Updates all documentation including README, FEATURES,
USAGE, UI_GUIDE, help modal, and GitHub Pages site.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:10:34 +00:00
Smittix cae7a0586f fix: Update North America ACARS frequencies and add ISS APRS option
Update default ACARS frequencies for North America to 131.725/131.825 MHz and add ISS (145.825 MHz) as a selectable APRS frequency region.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 18:37:15 +00:00
Smittix 23f28a8102 fix: Resolve multiple weather satellite decoder bugs
- Fix SatDump crash reported as "Capture complete" by collecting exit
  status via process.wait() before checking returncode
- Fix PTY file descriptor double-close race between stop() and reader
  thread by adding thread-safe _close_pty() helper with dedicated lock
- Fix image watcher missing final images by doing post-exit scans after
  SatDump process ends, using threading.Event for fast wakeup
- Fix failed image copy permanently skipping file by only marking as
  known after successful copy
- Fix frontend error handler not resetting isRunning, preventing new
  captures after a crash
- Fix console auto-hide timer leak on rapid complete/error events
- Fix ground track and auto-scheduler ignoring shared ObserverLocation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:16:28 +00:00
Smittix 34ecec3800 fix: Hide collapse sidebar button in analytics mode
The button is unnecessary since analytics expands the sidebar to
full width with no output panel to reveal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:21:18 +00:00
Smittix d40bd37406 fix: Expand analytics sections on mode switch
Sidebar sections are collapsed by default on DOMContentLoaded. When
switching to analytics mode, expand all its sections so the dashboard
content is visible immediately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:18:15 +00:00
Smittix 4ed41434e2 fix: Hide output panel in analytics mode to prevent overlay
Analytics is a sidebar-only mode with no visuals container, so the
output panel was rendering on top of the analytics content. Add
analytics-active class to expand the sidebar full-width and hide
the output panel when in analytics mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:13:24 +00:00
Smittix 6a0b54fa0e fix: Hide output console when switching to analytics mode
The decoder output panel was not being hidden when entering analytics
mode, causing it to render on top of the analytics dashboard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 14:22:21 +00:00
Smittix b83ecfcc19 feat: Add ACARS, VDL2, APRS, and Meshtastic to analytics dashboard
Extend cross-mode analytics to include ACARS/VDL2 message counts, APRS
stations, and Meshtastic messages. Refactor count helpers into reusable
_safe_len() and _safe_route_attr() utilities. Add health checks for
rtlamr, dmr, and meshtastic modes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 14:13:28 +00:00
Smittix 671bf38083 fix: Read WiFi/BT data from v2 scanners in analytics dashboard
The analytics summary, health, and export were only reading from legacy
DataStores (app_module.wifi_networks, bt_devices) which the v2 WiFi and
Bluetooth scanners don't populate. Now checks v2 scanner singletons
first and falls back to legacy stores.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:48:56 +00:00
Smittix 0f5a414a09 feat: Add cross-mode analytics dashboard with geofencing, correlations, and data export
Adds a unified analytics mode under the Security nav group that aggregates
data across all signal modes. Includes emergency squawk alerting (7700/7600/7500),
vertical rate anomaly detection, ACARS/VDL2-to-ADS-B flight correlation,
geofence zones with enter/exit detection for aircraft/vessels/APRS stations,
temporal pattern detection, RSSI history tracking, Meshtastic topology mapping,
and JSON/CSV data export.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 12:59:31 +00:00
Smittix 831426948f fix: Reconnect VDL2/ACARS streams after navigating away from ADS-B dashboard
When navigating away from the dashboard and back, the page reloads with
no knowledge of running decoders. Add status checks on page load to sync
UI state and reconnect SSE streams. Also add auto-reconnect on SSE error
with guard conditions to prevent loops when intentionally stopped.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:38:02 +00:00
Smittix df2c0a0d25 fix: Report SatDump crash as error instead of misleading "Capture complete"
Check process exit code when SatDump terminates — non-zero exit now
emits an error status with the exit code instead of falsely reporting
a successful capture completion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:11:33 +00:00
Smittix d427f69dcd chore: Bump version to 2.19.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:42:41 +00:00
Smittix cab04e6e2c feat: Make trails checked by default and remove both radar modes from ADS-B
Trails checkbox now defaults to checked (on). Removed the RADAR view
toggle, Radar overlay checkbox, RadarScope class, and all associated
animation/overlay JS and CSS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:39:50 +00:00
Smittix 8969fefe2e feat: Bundle Roboto Condensed woff2 for offline mode
Add latin and latin-ext woff2 variable font files for Roboto Condensed.
Update fonts-local.css with @font-face declarations using weight range
300-700. Restore conditional CDN/local font loading across all templates
and fix nested Jinja conditionals in dashboard pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:33:49 +00:00
Smittix 5e9fcc5c49 feat: Switch application font to Roboto Condensed
Replace IBM Plex Mono, Space Mono, and JetBrains Mono with Roboto
Condensed across all CSS variables, inline styles, canvas ctx.font
references, and Google Fonts CDN links. Updates 28 files covering
templates, stylesheets, and JS modules for consistent typography.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:29:05 +00:00
Smittix 53b23fc2f7 fix: Replace emoji icons with SVGs, deduplicate help modal, fix fonts
- Replace all emoji HTML entities in Stats Bar Icons with matching SVGs
  from the actual stats bar implementation
- Remove stale inline help modal from index.html, use shared partial
- Set help modal font-family to match app-wide IBM Plex Mono
- Reduce font sizes for cleaner, more professional appearance
- Tighten padding and spacing throughout the help modal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:20:29 +00:00
Smittix eeb3a29ecf docs: Update help modal with all modes and correct SVG icons
Replace emoji icons with actual SVG icons matching nav.html. Add missing
mode descriptions for WebSDR, SubGHz, ISS SSTV, Weather Sat, HF SSTV,
GPS, and BT Locate. Update requirements section with all mode prereqs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:14:42 +00:00
Smittix 4cdfa98a4e feat: Add Support & Contact section with Buy Me a Coffee and email
Add a 4-card support section with Buy Me a Coffee (highlighted in gold),
obfuscated email (click-to-reveal to defeat scrapers), Discord, and
GitHub Issues. Email is assembled via JS at runtime with no plaintext
address in the HTML source. Added links to footer as well.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:04:58 +00:00
Smittix 9fcec6cbb8 feat: Add animated satellite/signal background to GitHub Pages
Canvas-based animation with orbiting satellite dots, signal pulse rings,
drifting particles, and a faint grid overlay. Uses the accent cyan color
at very low opacity to stay subtle. Particles brighten near the cursor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 22:58:09 +00:00
Smittix a527ac191a docs: Update dashboard screenshot with v2.18.0 main screen
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 22:55:50 +00:00
Smittix 8cd3aafd10 docs: Update documentation with new modes, screenshots, and carousel UI
Add VDL2 to README, FEATURES.md, and USAGE.md. Add missing usage guides
for ACARS, WebSDR, ISS SSTV, HF SSTV, TSCM, Spy Stations, and Offline
Mode. Add ISS SSTV section to FEATURES.md. Add 7 new screenshots to
GitHub Pages (Spy Stations, GPS, WebSDR, VDL2, Weather Satellite,
Satellite Tracker, ISS SSTV). Redesign features section as a filterable
carousel with category tabs, SVG icons, and scroll indicators.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 22:52:22 +00:00
Smittix 5c76a423af feat: Remove ACARS as standalone mode, already in ADS-B dashboard
Same as VDL2 - ACARS is integrated into the ADS-B dashboard sidebar
so it doesn't need its own separate mode entry in the nav or index.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 22:25:21 +00:00
Smittix c80bf99b91 fix: Raise nav bar z-index so Space dropdown isn't clipped by content
The mode-nav dropdown menus were being visually covered by the main
content area (maps, visuals) below. Increase z-index from 100 to 1100
so dropdown menus render above all page content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 22:22:15 +00:00
Smittix 6e5cb0a23e fix: Hide cyan scrollbar on controls bar and prevent airband cutoff
The blue bar at the bottom was the cyan-styled horizontal scrollbar on
the controls-bar. Hide it and allow the airband group to flex/wrap so
it stays within the viewport instead of overflowing off-screen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 22:17:49 +00:00
Smittix ffb98425f1 feat: Consolidate VDL2 into ADS-B dashboard, fix blue bar, add CSV export
Remove VDL2 as a standalone mode since it's already integrated into the
ADS-B dashboard sidebar. Remove the blue border-top on the controls bar.
Add CSV export button to VDL2 panel for downloading collected messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 22:12:03 +00:00
Smittix 533e92c711 fix: Unwrap dumpvdl2 nested vdl2 key so modal displays data correctly
dumpvdl2 JSON nests all fields under a "vdl2" object. Both the sidebar
cards and modal now unwrap this correctly. Modal sections reorganized
into Radio (signal/noise/freq/FEC), AVLC Frame, ACARS, XID, and
Message body with all available fields extracted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 21:52:36 +00:00
Smittix 9f32b05719 feat: Replace VDL2 inline expand with modal for easier reading
Message cards now open a centered modal overlay on click with organized
sections (Connection, ACARS, Position, Message) in a readable grid
layout. Includes raw JSON toggle, closes via X button, backdrop click,
or Escape key.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 21:49:40 +00:00
Smittix 2a05aaa4d8 feat: Make VDL2 message cards clickable with expandable details
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 21:39:51 +00:00
Smittix 6529febcfa fix: Overhaul setup.sh for reliability and macOS compatibility
- Use pre-built SatDump DMG on macOS instead of building from source
  (avoids sol2/Apple Clang deprecation errors)
- Fix `python: command not found` by using explicit venv/bin/python paths
- Split pip install into core + optional packages to avoid all-or-nothing
  failures on newer Python versions
- Make dumpvdl2 optional (warn instead of fail) since VDL2 is one feature
- Fix Homebrew volk package name (libvolk -> volk)
- Add GCC 13+ sol2 deprecation pragma patch for Debian SatDump build
- Quote $(which) to handle paths with spaces
- Remove macOS sed fallback from Debian-only function
- Update TOTAL_STEPS counts (macOS: 22, Debian: 28)
- Add hdiutil detach cleanup to SatDump DMG install trap

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 21:25:15 +00:00
Smittix bd87d4b4c6 fix: Use full dumpvdl2 output specifier for v2.6.0 compatibility
dumpvdl2 2.6.0 requires the complete output specifier format
'decoded:json:file:path=-' instead of just 'decoded:json'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 20:36:13 +00:00
Smittix 5a0589dd69 fix: Wire up VDL2 agent mode, fix dashboard layout gap and stats strip sizing
- Add VDL2 to syncModeUI setter map and allModes array in agents.js
  so agent state sync works for VDL2
- Fix dashboard bottom gap by using flex layout on body instead of
  hardcoded calc(100dvh - 160px) height
- Match source stat font-size to other stats (14px) for consistent
  strip sizing
- Add left-sidebars wrapper, VDL2 agent mode support, mutual sidebar
  collapse, and ACARS/VDL2 modeNames in index.html

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 20:27:25 +00:00
Smittix 5605ae0359 fix: Preserve GPS satellites across DOP-only SKY messages
gpsd sends multiple SKY messages per cycle — some contain only DOP
values with an empty satellites array. Previously this would overwrite
the satellite list, causing the sky view to flicker empty. Now DOP-only
SKY messages preserve the existing satellite list. Also adds a 5-second
polling fallback for satellite data since SSE can miss sky updates due
to queue contention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 19:48:46 +00:00
Smittix 2b3f351ff0 feat: Add VDL2 mode and ACARS standalone frontend
Add VDL2 (VHF Digital Link Mode 2) decoding via dumpvdl2 as a new mode,
and promote ACARS from ADS-B-dashboard-only to a first-class standalone
mode in the main SPA. Both aviation datalink modes now have full nav
entries, sidebar partials with region-based frequency selectors, and
SSE streaming. VDL2 also integrated into the ADS-B dashboard as a
collapsible sidebar alongside ACARS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 19:48:10 +00:00
Smittix 126b9ba2ee fix: Handle CMake 4.0+ compatibility for acarsdec build (#136)
CMake 4.0 removed backward compat with cmake_minimum_required < 3.5.
Add -DCMAKE_POLICY_VERSION_MINIMUM=3.5 to acarsdec cmake invocations
in setup.sh (macOS + Debian) and Dockerfile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:41:22 +00:00
Smittix c0498ebe68 fix: Resolve gpsd deadlock causing GPS connect to hang
start_gpsd_daemon() acquires _gpsd_process_lock then calls
stop_gpsd_daemon() which tries to acquire the same non-reentrant Lock,
causing an immediate deadlock. Changed to RLock so the same thread can
re-enter the lock.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:38:00 +00:00
81 changed files with 7730 additions and 1633 deletions
+22 -1
View File
@@ -95,6 +95,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libfftw3-dev \
liblapack-dev \
libcodec2-dev \
libglib2.0-dev \
libxml2-dev \
# Build dump1090
&& cd /tmp \
&& 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 \
&& cd acarsdec \
&& mkdir build && cd build \
&& cmake .. -Drtl=ON \
&& cmake .. -Drtl=ON -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
&& make \
&& cp acarsdec /usr/bin/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
&& cd /tmp \
&& git clone https://github.com/windytan/slowrx.git \
+3
View File
@@ -32,6 +32,7 @@ Support the developer of this open-source project
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
- **Vessel Tracking** - AIS ship tracking with VHF DSC distress monitoring
- **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
- **Weather Satellites** - NOAA APT and Meteor LRPT image decoding via SatDump with auto-scheduler
- **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
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
- **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
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
- **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) |
[direwolf](https://github.com/wb2osz/direwolf) |
[rtl_amr](https://github.com/bemasher/rtlamr) |
[dumpvdl2](https://github.com/szpajder/dumpvdl2) |
[aircrack-ng](https://www.aircrack-ng.org/) |
[Leaflet.js](https://leafletjs.com/) |
[SatDump](https://github.com/SatDump/SatDump) |
+12 -1
View File
@@ -150,6 +150,11 @@ acars_process = None
acars_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
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_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),
'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),
'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),
'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),
@@ -702,6 +708,7 @@ def health_check() -> Response:
def kill_all() -> Response:
"""Kill all decoder, WiFi, and Bluetooth processes."""
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 dmr_process, dmr_rtl_process
@@ -714,7 +721,7 @@ def kill_all() -> Response:
processes_to_kill = [
'rtl_fm', 'multimon-ng', 'rtl_433',
'airodump-ng', 'aireplay-ng', 'airmon-ng',
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
'dump1090', 'acarsdec', 'dumpvdl2', 'direwolf', 'AIS-catcher',
'hcitool', 'bluetoothctl', 'satdump', 'dsd',
'rtl_tcp', 'rtl_power', 'rtlamr', 'ffmpeg',
'hackrf_transfer', 'hackrf_sweep'
@@ -751,6 +758,10 @@ def kill_all() -> Response:
with acars_lock:
acars_process = None
# Reset VDL2 state
with vdl2_lock:
vdl2_process = None
# Reset APRS state
with aprs_lock:
aprs_process = None
+25 -1
View File
@@ -7,10 +7,34 @@ import os
import sys
# Application version
VERSION = "2.18.0"
VERSION = "2.20.0"
# Changelog - latest release notes (shown on welcome screen)
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",
"date": "February 2026",
+41 -1
View File
@@ -99,6 +99,18 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
- **Multi-SDR support** - RTL-SDR, HackRF, LimeSDR, Airspy, SDRplay
- **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
- **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
- **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
- **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
- **Common modes supported** - PD120, PD180, Martin1, Scottie1, Robot36
## 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
- **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
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
+13 -4
View File
@@ -206,14 +206,23 @@ Extended base for full-screen dashboards (maps, visualizations).
| `listening` | Listening post |
| `spystations` | Spy stations |
| `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
The navigation is organized into groups:
- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic
- **Wireless**: WiFi, Bluetooth
- **Security**: TSCM
- **Space**: Satellite, ISS SSTV
- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic, WebSDR, SubGHz
- **Wireless**: WiFi, Bluetooth, BT Locate
- **Security**: TSCM, Analytics
- **Space**: Satellite, ISS SSTV, Weather Sat, HF SSTV, GPS, Space Weather
---
+163
View File
@@ -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.
## 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
1. **Select Hardware** - Choose your SDR type
@@ -110,6 +126,23 @@ The system highlights aircraft transmitting emergency squawks:
- **7600** - Radio failure
- **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)
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
- 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
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
- 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
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
- 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
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)
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)
Deploy lightweight sensor nodes across multiple locations and aggregate data to a central controller.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 694 KiB

After

Width:  |  Height:  |  Size: 790 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 876 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+531 -145
View File
@@ -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">
</head>
<body>
<canvas id="bg-canvas"></canvas>
<nav class="navbar">
<div class="nav-container">
<a href="#" class="nav-logo">iNTERCEPT</a>
@@ -35,7 +36,7 @@
</div>
<div class="hero-stats">
<div class="stat">
<span class="stat-value">20+</span>
<span class="stat-value">25+</span>
<span class="stat-label">Modes</span>
</div>
<div class="stat">
@@ -58,151 +59,148 @@
<h2>Capabilities</h2>
<p class="section-subtitle">Everything you need for signal intelligence in one interface</p>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon">📟</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">
<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 class="carousel-filters">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="sdr">SDR / RF</button>
<button class="filter-btn" data-filter="aviation">Aviation & Maritime</button>
<button class="filter-btn" data-filter="space">Space & Satellite</button>
<button class="filter-btn" data-filter="wireless">Wireless & Security</button>
<button class="filter-btn" data-filter="platform">Platform</button>
</div>
<div class="carousel-wrapper">
<button class="carousel-arrow carousel-arrow-left" aria-label="Scroll left">&#8249;</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">&#8250;</button>
</div>
<div class="carousel-indicators" id="carousel-indicators"></div>
</div>
</section>
@@ -252,6 +250,34 @@
<img src="images/bt-locate.png" alt="BT Locate SAR Tracker">
<span class="screenshot-label">BT Locate — SAR Tracker</span>
</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>
</section>
@@ -336,6 +362,36 @@ docker compose up -d</code></pre>
</div>
</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">
<div class="container">
<div class="footer-content">
@@ -346,6 +402,8 @@ docker compose up -d</code></pre>
<div class="footer-links">
<a href="https://github.com/smittix/intercept" target="_blank">GitHub</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/DISTRIBUTED_AGENTS.md">Remote Agents</a>
</div>
@@ -394,6 +452,334 @@ docker compose up -d</code></pre>
document.addEventListener('keydown', (e) => {
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>
</body>
</html>
+258 -9
View File
@@ -17,6 +17,22 @@
--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;
padding: 0;
@@ -245,18 +261,74 @@ section h2 {
background: var(--bg-secondary);
}
.features-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
/* Category filter tabs */
.carousel-filters {
display: flex;
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 {
flex: 0 0 280px;
scroll-snap-align: start;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px 24px;
transition: all 0.3s;
min-height: 200px;
}
.feature-card.hidden {
display: none;
}
.feature-card:hover {
@@ -266,8 +338,15 @@ section h2 {
}
.feature-icon {
font-size: 2rem;
width: 36px;
height: 36px;
margin-bottom: 16px;
color: var(--accent);
}
.feature-icon svg {
width: 100%;
height: 100%;
}
.feature-card h3 {
@@ -283,6 +362,81 @@ section h2 {
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 */
.screenshot-gallery {
display: grid;
@@ -550,6 +704,72 @@ section h2 {
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 {
background: var(--bg-secondary);
@@ -641,14 +861,22 @@ section h2 {
margin: 0 auto;
}
.features-grid {
grid-template-columns: repeat(2, 1fr);
.carousel-wrapper {
padding: 0 48px;
}
.feature-card {
flex: 0 0 260px;
}
.screenshot-gallery {
grid-template-columns: repeat(2, 1fr);
}
.support-grid {
grid-template-columns: repeat(2, 1fr);
}
.install-options {
grid-template-columns: 1fr;
}
@@ -669,14 +897,35 @@ section h2 {
gap: 24px;
}
.features-grid {
grid-template-columns: 1fr;
.carousel-wrapper {
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 {
grid-template-columns: 1fr;
}
.support-grid {
grid-template-columns: 1fr;
}
.nav-links {
display: none;
}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "intercept"
version = "2.18.0"
version = "2.20.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md"
requires-python = ">=3.9"
+6
View File
@@ -13,6 +13,7 @@ def register_blueprints(app):
from .ais import ais_bp
from .dsc import dsc_bp
from .acars import acars_bp
from .vdl2 import vdl2_bp
from .aprs import aprs_bp
from .satellite import satellite_bp
from .gps import gps_bp
@@ -34,6 +35,8 @@ def register_blueprints(app):
from .recordings import recordings_bp
from .subghz import subghz_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(sensor_bp)
@@ -46,6 +49,7 @@ def register_blueprints(app):
app.register_blueprint(ais_bp)
app.register_blueprint(dsc_bp) # VHF DSC maritime distress
app.register_blueprint(acars_bp)
app.register_blueprint(vdl2_bp)
app.register_blueprint(aprs_bp)
app.register_blueprint(satellite_bp)
app.register_blueprint(gps_bp)
@@ -67,6 +71,8 @@ def register_blueprints(app):
app.register_blueprint(recordings_bp) # Session recordings
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
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
import app as app_module
+10 -6
View File
@@ -35,11 +35,8 @@ acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
# Default VHF ACARS frequencies (MHz) - common worldwide
DEFAULT_ACARS_FREQUENCIES = [
'131.550', # Primary worldwide
'130.025', # Secondary USA/Canada
'129.125', # USA
'131.525', # Europe
'131.725', # Europe secondary
'131.725', # North America
'131.825', # North America
]
# 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)
# 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
if app_module.logging_enabled:
try:
@@ -440,7 +444,7 @@ def get_frequencies() -> Response:
return jsonify({
'default': DEFAULT_ACARS_FREQUENCIES,
'regions': {
'north_america': ['129.125', '130.025', '130.450', '131.550'],
'north_america': ['131.725', '131.825'],
'europe': ['131.525', '131.725', '131.550'],
'asia_pacific': ['131.550', '131.450'],
}
+41
View File
@@ -439,6 +439,12 @@ def parse_sbs_stream(service_addr):
if parts[16]:
try:
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):
pass
@@ -456,6 +462,14 @@ def parse_sbs_stream(service_addr):
elif msg_type == '6' and len(parts) > 17:
if 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)
pending_updates.add(icao)
@@ -488,6 +502,19 @@ def parse_sbs_stream(service_addr):
'source_host': service_addr,
'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()
last_update = now
@@ -1103,3 +1130,17 @@ def aircraft_photo(registration: str):
except Exception as e:
logger.debug(f"Error fetching aircraft photo: {e}")
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})
+42 -1
View File
@@ -124,13 +124,27 @@ def parse_ais_stream(port: int):
if now - last_update >= AIS_UPDATE_INTERVAL:
for mmsi in pending_updates:
if mmsi in app_module.ais_vessels:
_vessel_snap = app_module.ais_vessels[mmsi]
try:
app_module.ais_queue.put_nowait({
'type': 'vessel',
**app_module.ais_vessels[mmsi]
**_vessel_snap
})
except queue.Full:
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()
last_update = now
@@ -282,6 +296,16 @@ def process_ais_message(msg: dict) -> dict | None:
# Timestamp
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
@@ -502,6 +526,23 @@ def stream_ais():
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')
def ais_dashboard():
"""Popout AIS dashboard."""
+207
View File
@@ -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'})
+115 -101
View File
@@ -19,16 +19,16 @@ from typing import Generator, Optional
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.sse import format_sse
from utils.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType
from utils.constants import (
PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
PROCESS_START_WAIT,
from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sse import format_sse
from utils.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType
from utils.constants import (
PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
PROCESS_START_WAIT,
)
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
@@ -47,6 +47,7 @@ APRS_FREQUENCIES = {
'brazil': '145.570',
'japan': '144.640',
'china': '144.640',
'iss': '145.825',
}
# Statistics
@@ -73,19 +74,19 @@ def find_multimon_ng() -> Optional[str]:
return shutil.which('multimon-ng')
def find_rtl_fm() -> Optional[str]:
"""Find rtl_fm binary."""
return shutil.which('rtl_fm')
def find_rx_fm() -> Optional[str]:
"""Find SoapySDR rx_fm binary."""
return shutil.which('rx_fm')
def find_rtl_power() -> Optional[str]:
"""Find rtl_power binary for spectrum scanning."""
return shutil.which('rtl_power')
def find_rtl_fm() -> Optional[str]:
"""Find rtl_fm binary."""
return shutil.which('rtl_fm')
def find_rx_fm() -> Optional[str]:
"""Find SoapySDR rx_fm binary."""
return shutil.which('rx_fm')
def find_rtl_power() -> Optional[str]:
"""Find rtl_power binary for spectrum scanning."""
return shutil.which('rtl_power')
# 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'),
'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
if len(aprs_stations) > APRS_MAX_STATIONS:
oldest = min(
@@ -1420,22 +1434,22 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
@aprs_bp.route('/tools')
def check_aprs_tools() -> Response:
"""Check for APRS decoding tools."""
has_rtl_fm = find_rtl_fm() is not None
has_rx_fm = find_rx_fm() is not None
has_direwolf = find_direwolf() is not None
has_multimon = find_multimon_ng() is not None
has_fm_demod = has_rtl_fm or has_rx_fm
return jsonify({
'rtl_fm': has_rtl_fm,
'rx_fm': has_rx_fm,
'direwolf': has_direwolf,
'multimon_ng': 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)
})
def check_aprs_tools() -> Response:
"""Check for APRS decoding tools."""
has_rtl_fm = find_rtl_fm() is not None
has_rx_fm = find_rx_fm() is not None
has_direwolf = find_direwolf() is not None
has_multimon = find_multimon_ng() is not None
has_fm_demod = has_rtl_fm or has_rx_fm
return jsonify({
'rtl_fm': has_rtl_fm,
'rx_fm': has_rx_fm,
'direwolf': has_direwolf,
'multimon_ng': 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)
})
@aprs_bp.route('/status')
@@ -1476,12 +1490,12 @@ def start_aprs() -> Response:
'message': 'APRS decoder already running'
}), 409
# Check for decoder (prefer direwolf, fallback to multimon-ng)
direwolf_path = find_direwolf()
multimon_path = find_multimon_ng()
if not direwolf_path and not multimon_path:
return jsonify({
# Check for decoder (prefer direwolf, fallback to multimon-ng)
direwolf_path = find_direwolf()
multimon_path = find_multimon_ng()
if not direwolf_path and not multimon_path:
return jsonify({
'status': 'error',
'message': 'No APRS decoder found. Install direwolf or multimon-ng'
}), 400
@@ -1489,31 +1503,31 @@ def start_aprs() -> Response:
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
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if sdr_type == SDRType.RTL_SDR:
if find_rtl_fm() is None:
return jsonify({
'status': 'error',
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
}), 400
else:
if find_rx_fm() is None:
return jsonify({
'status': 'error',
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
}), 400
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
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
try:
sdr_type = SDRType(sdr_type_str)
except ValueError:
sdr_type = SDRType.RTL_SDR
if sdr_type == SDRType.RTL_SDR:
if find_rtl_fm() is None:
return jsonify({
'status': 'error',
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
}), 400
else:
if find_rx_fm() is None:
return jsonify({
'status': 'error',
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
}), 400
# Reserve SDR device to prevent conflicts with other modes
error = app_module.claim_sdr_device(device, 'aprs')
@@ -1545,29 +1559,29 @@ def start_aprs() -> Response:
aprs_last_packet_time = None
aprs_stations = {}
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
try:
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type)
rtl_cmd = builder.build_fm_demod_command(
device=sdr_device,
frequency_mhz=float(frequency),
sample_rate=22050,
gain=float(gain) if gain and str(gain) != '0' else None,
ppm=int(ppm) if ppm and str(ppm) != '0' else None,
modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm',
squelch=None,
bias_t=bool(data.get('bias_t', False)),
)
if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-':
# APRS benefits from DC blocking + fast AGC on rtl_fm.
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
except Exception as e:
if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device)
aprs_active_device = None
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
try:
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type)
rtl_cmd = builder.build_fm_demod_command(
device=sdr_device,
frequency_mhz=float(frequency),
sample_rate=22050,
gain=float(gain) if gain and str(gain) != '0' else None,
ppm=int(ppm) if ppm and str(ppm) != '0' else None,
modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm',
squelch=None,
bias_t=bool(data.get('bias_t', False)),
)
if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-':
# APRS benefits from DC blocking + fast AGC on rtl_fm.
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
except Exception as e:
if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device)
aprs_active_device = None
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
# Build decoder command
if direwolf_path:
@@ -1690,14 +1704,14 @@ def start_aprs() -> Response:
)
thread.start()
return jsonify({
'status': 'started',
'frequency': frequency,
'region': region,
'device': device,
'sdr_type': sdr_type.value,
'decoder': decoder_name
})
return jsonify({
'status': 'started',
'frequency': frequency,
'region': region,
'device': device,
'sdr_type': sdr_type.value,
'decoder': decoder_name
})
except Exception as e:
logger.error(f"Failed to start APRS decoder: {e}")
+16
View File
@@ -1051,3 +1051,19 @@ def request_store_forward():
'status': 'error',
'message': error or 'Failed to request S&F history'
}), 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(),
})
+24
View File
@@ -28,6 +28,10 @@ sensor_bp = Blueprint('sensor', __name__)
# Track which device is being used
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:
"""Stream rtl_433 JSON output to queue."""
@@ -45,6 +49,17 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
data['type'] = 'sensor'
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
rssi = data.get('rssi')
snr = data.get('snr')
@@ -283,3 +298,12 @@ def stream_sensor() -> Response:
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
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})
+300
View File
@@ -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'})
+364
View File
@@ -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'],
}
})
+234 -68
View File
@@ -226,6 +226,7 @@ check_tools() {
check_optional "hackrf_sweep" "HackRF spectrum analyzer" hackrf_sweep
check_required "dump1090" "ADS-B decoder" dump1090
check_required "acarsdec" "ACARS decoder" acarsdec
check_optional "dumpvdl2" "VDL2 decoder" dumpvdl2
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
echo
@@ -312,28 +313,41 @@ install_python_deps() {
# shellcheck disable=SC1091
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"
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)
# System apt packages may have older Flask 2.x which is incompatible
python -m pip install --upgrade "flask>=3.0.0" >/dev/null 2>&1 || true
# Install critical packages first to avoid all-or-nothing failures
# (C extension packages like scipy/numpy can fail on newer Python versions
# 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
}
@@ -616,6 +630,7 @@ install_acarsdec_from_source_macos() {
info "Compiling acarsdec..."
build_log="$tmp_dir/acarsdec-build.log"
if cmake .. -Drtl=ON \
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
-DCMAKE_C_FLAGS="-I${HOMEBREW_PREFIX}/include" \
-DCMAKE_EXE_LINKER_FLAGS="-L${HOMEBREW_PREFIX}/lib" \
>"$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() {
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; }
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
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() {
info "Building SatDump v1.2.2 from source (weather satellite decoder)..."
install_satdump_macos() {
info "Installing SatDump v1.2.2 from pre-built release (weather satellite decoder)..."
brew_install cmake
brew_install libpng
brew_install libtiff
brew_install jemalloc
brew_install libvolk
brew_install nng
brew_install zstd
brew_install soapysdr
brew_install hackrf
brew_install fftw
# Determine architecture
local arch
arch="$(uname -m)"
local dmg_name
if [ "$arch" = "arm64" ]; then
dmg_name="SatDump-macOS-Silicon.dmg"
else
dmg_name="SatDump-macOS-Intel.dmg"
fi
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
(
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..."
git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git "$tmp_dir/SatDump" >/dev/null 2>&1 \
|| { warn "Failed to clone SatDump"; exit 1; }
info "Downloading ${dmg_name}..."
if ! curl -sL -o "$tmp_dir/satdump.dmg" "$dmg_url"; then
warn "Failed to download SatDump. Weather satellite decoding will not be available."
exit 1
fi
cd "$tmp_dir/SatDump"
mkdir -p build && cd build
info "Installing SatDump..."
# 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)..."
build_log="$tmp_dir/satdump-build.log"
local app_dir="$tmp_dir/mnt/SatDump.app"
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
(
while true; do
sleep 30
if [ -f "$build_log" ]; then
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=$!
# Install: copy app contents to /usr/local/lib/satdump
refresh_sudo
$SUDO mkdir -p "$install_dir"
$SUDO cp -R "$app_dir/Contents/MacOS/"* "$install_dir/"
$SUDO cp -R "$app_dir/Contents/Resources/"* "$install_dir/"
if cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF .. >"$build_log" 2>&1 \
&& make -j "$(sysctl -n hw.ncpu)" >>"$build_log" 2>&1; then
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
if [[ -w /usr/local/bin ]]; then
make install >/dev/null 2>&1
else
refresh_sudo
$SUDO make install >/dev/null 2>&1
fi
ok "SatDump installed successfully."
# Create wrapper script so satdump can find its resources via @executable_path
$SUDO tee /usr/local/bin/satdump >/dev/null <<'WRAPPER'
#!/bin/sh
exec /usr/local/lib/satdump/satdump "$@"
WRAPPER
$SUDO chmod +x /usr/local/bin/satdump
hdiutil detach "$tmp_dir/mnt" -quiet 2>/dev/null
# Verify installation
if /usr/local/lib/satdump/satdump 2>&1 | grep -q "Usage"; then
ok "SatDump v1.2.2 installed successfully."
else
kill $progress_pid 2>/dev/null; wait $progress_pid 2>/dev/null
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
warn "SatDump installed but may not work correctly."
fi
)
}
@@ -800,7 +906,7 @@ install_macos_packages() {
sudo -v || { fail "sudo authentication failed"; exit 1; }
fi
TOTAL_STEPS=19
TOTAL_STEPS=22
CURRENT_STEP=0
progress "Checking Homebrew"
@@ -873,6 +979,13 @@ install_macos_packages() {
ok "acarsdec already installed"
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"
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
(brew_install aiscatcher) || install_aiscatcher_from_source_macos || warn "AIS-catcher not available"
@@ -885,7 +998,7 @@ install_macos_packages() {
echo
info "SatDump is used for weather satellite imagery (NOAA APT & Meteor LRPT)."
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
warn "Skipping SatDump installation. You can install it later if needed."
fi
@@ -977,7 +1090,7 @@ install_dump1090_from_source_debian() {
cd "$tmp_dir/dump1090"
# 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..."
if make BLADERF=no RTLSDR=yes >/dev/null 2>&1; then
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
@@ -1018,7 +1131,7 @@ install_acarsdec_from_source_debian() {
mkdir -p build && cd build
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
ok "acarsdec installed successfully."
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() {
info "AIS-catcher not available via APT. Building from source..."
@@ -1190,7 +1349,7 @@ install_debian_packages() {
export NEEDRESTART_MODE=a
fi
TOTAL_STEPS=26
TOTAL_STEPS=28
CURRENT_STEP=0
progress "Updating APT package lists"
@@ -1332,7 +1491,7 @@ install_debian_packages() {
fi
if ! cmd_exists dump1090; 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
cmd_exists dump1090 || install_dump1090_from_source_debian
@@ -1343,6 +1502,13 @@ install_debian_packages() {
fi
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"
if ! cmd_exists AIS-catcher && ! cmd_exists aiscatcher; then
install_aiscatcher_from_source_debian
+410 -97
View File
@@ -5,8 +5,8 @@
}
:root {
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: '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: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
--bg-dark: #0b1118;
--bg-panel: #101823;
--bg-card: #151f2b;
@@ -31,8 +31,11 @@ body {
font-family: var(--font-sans);
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
height: 100dvh;
height: 100vh; /* Fallback */
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Animated radar sweep background */
@@ -227,16 +230,14 @@ body {
}
/* Main dashboard grid - Mobile first */
/* Header ~52px + Nav 44px + Stats strip ~55px = ~151px, using 160px for safety */
.dashboard {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
gap: 0;
height: calc(100dvh - 160px);
height: calc(100vh - 160px); /* Fallback */
min-height: 400px;
flex: 1;
min-height: 0;
}
/* 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) {
.dashboard {
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 {
display: none;
@@ -267,12 +284,10 @@ body {
min-height: 0;
}
/* Show ACARS sidebar on desktop */
@media (min-width: 1024px) {
.acars-sidebar {
display: flex;
max-height: calc(100dvh - 160px);
}
/* Show ACARS sidebar inside wrapper */
.left-sidebars .acars-sidebar {
display: flex;
height: 100%;
}
.acars-collapse-btn {
@@ -419,6 +434,335 @@ body {
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 */
.panel {
background: var(--bg-panel);
@@ -495,6 +839,8 @@ body {
position: relative;
flex: 1;
min-height: 300px;
min-width: 0;
overflow: hidden;
}
@media (min-width: 768px) {
@@ -526,42 +872,6 @@ body {
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 */
.sidebar {
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 {
flex-shrink: 0;
max-height: 480px;
max-height: 280px;
overflow-y: auto;
}
@media (min-height: 900px) {
.selected-aircraft {
max-height: 340px;
}
}
.selected-info {
padding: 12px;
padding: 8px;
}
#aircraftPhotoContainer {
@@ -640,7 +920,7 @@ body {
}
#aircraftPhotoContainer img {
max-height: 140px;
max-height: 100px;
width: 100%;
object-fit: cover;
border-radius: 6px;
@@ -649,24 +929,24 @@ body {
.selected-callsign {
font-family: 'Orbitron', monospace;
font-size: 20px;
font-size: 16px;
font-weight: 700;
color: var(--accent-cyan);
text-shadow: 0 0 15px var(--accent-cyan);
text-align: center;
margin-bottom: 12px;
margin-bottom: 6px;
}
.telemetry-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
gap: 4px;
}
.telemetry-item {
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
padding: 8px;
padding: 5px 8px;
border-left: 2px solid var(--accent-cyan);
}
@@ -776,9 +1056,10 @@ body {
gap: 8px;
padding: 8px 15px;
background: var(--bg-panel);
border-top: 1px solid rgba(74, 158, 255, 0.3);
border-top: none;
font-size: 11px;
overflow: hidden;
overflow-x: auto;
overflow-y: hidden;
}
.controls-bar > .control-group {
@@ -907,6 +1188,15 @@ body {
.control-group.airband-group {
background: rgba(245, 158, 11, 0.05);
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 {
@@ -1010,6 +1300,7 @@ body {
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@@ -1021,6 +1312,15 @@ body {
border-radius: 3px;
}
/* Hide scrollbar on controls bar */
.controls-bar::-webkit-scrollbar {
display: none;
}
.controls-bar {
scrollbar-width: none;
}
/* No aircraft message */
.no-aircraft {
text-align: center;
@@ -1289,7 +1589,7 @@ body {
display: flex !important;
flex-direction: column !important;
height: auto !important;
min-height: calc(100dvh - 160px);
min-height: 400px;
overflow-y: auto !important;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
@@ -1489,6 +1789,10 @@ body {
margin-top: 1px;
}
.strip-stat.source-stat .strip-value {
font-size: 14px;
}
.strip-stat.session-stat {
background: rgba(34, 197, 94, 0.05);
border-color: rgba(34, 197, 94, 0.2);
@@ -1779,6 +2083,9 @@ body {
.strip-btn {
position: relative;
z-index: 10;
display: inline-flex;
align-items: center;
gap: 4px;
background: rgba(74, 158, 255, 0.1);
border: 1px solid rgba(74, 158, 255, 0.2);
color: var(--text-primary);
@@ -1789,6 +2096,12 @@ body {
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
text-decoration: none;
}
.strip-btn svg {
flex-shrink: 0;
opacity: 0.7;
}
.strip-btn:hover:not(:disabled) {
+2 -2
View File
@@ -5,8 +5,8 @@
}
:root {
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: '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: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
--bg-dark: #0b1118;
--bg-panel: #101823;
--bg-card: #151f2b;
+6 -6
View File
@@ -8,8 +8,8 @@
}
:root {
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: '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: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
--bg-dark: #0b1118;
--bg-panel: #101823;
--bg-card: #151f2b;
@@ -496,7 +496,7 @@ body {
padding: 10px 15px;
background: rgba(74, 158, 255, 0.05);
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-weight: 500;
letter-spacing: 2px;
@@ -568,7 +568,7 @@ body {
}
.vessel-name {
font-family: 'Orbitron', 'Space Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 16px;
font-weight: 700;
color: var(--accent-cyan);
@@ -662,7 +662,7 @@ body {
}
.vessel-item-name {
font-family: 'Orbitron', 'Space Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 12px;
font-weight: 600;
color: var(--accent-cyan);
@@ -1223,7 +1223,7 @@ body {
}
.dsc-distress-alert .dsc-alert-header {
font-family: 'Orbitron', 'Space Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 24px;
font-weight: 700;
color: var(--accent-red);
+2 -2
View File
@@ -78,8 +78,8 @@
/* ============================================
TYPOGRAPHY
============================================ */
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: '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: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
/* Font sizes */
--text-xs: 10px;
+9 -7
View File
@@ -1,18 +1,20 @@
/* Local font declarations for offline mode */
/* Roboto Condensed - variable font, one file covers all weights */
/* Space Mono - Console font */
@font-face {
font-family: 'Space Mono';
font-family: 'Roboto Condensed';
font-style: normal;
font-weight: 400;
font-weight: 300 700;
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-family: 'Space Mono';
font-family: 'Roboto Condensed';
font-style: normal;
font-weight: 700;
font-weight: 300 700;
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;
}
+2 -2
View File
@@ -30,7 +30,7 @@
border-bottom: 1px solid var(--border-color, #202833);
padding: 0 20px;
position: relative;
z-index: 100;
z-index: 1100;
backdrop-filter: blur(10px);
}
@@ -434,6 +434,6 @@ a.nav-dashboard-btn:hover {
}
.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;
}
+49 -27
View File
@@ -14,6 +14,7 @@
z-index: 10000;
overflow-y: auto;
padding: 40px 20px;
font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif);
}
.help-modal.active {
@@ -26,37 +27,41 @@
background: var(--bg-card, var(--bg-secondary, #0f1218));
border: 1px solid var(--border-color, #1f2937);
border-radius: 8px;
padding: 30px;
padding: 24px;
position: relative;
}
.help-content h2 {
color: var(--accent-cyan, #4a9eff);
margin-bottom: 20px;
font-size: 24px;
margin-bottom: 16px;
font-size: 15px;
letter-spacing: 2px;
text-transform: uppercase;
font-weight: 600;
}
.help-content h3 {
color: var(--text-primary, #e8eaed);
margin: 25px 0 15px 0;
font-size: 14px;
margin: 20px 0 10px 0;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
border-bottom: 1px solid var(--border-color, #1f2937);
padding-bottom: 8px;
padding-bottom: 6px;
font-weight: 600;
}
.help-close {
position: absolute;
top: 15px;
right: 15px;
top: 12px;
right: 12px;
background: none;
border: none;
color: var(--text-dim, #4b5563);
font-size: 24px;
font-size: 20px;
cursor: pointer;
transition: color 0.2s;
line-height: 1;
}
.help-close:hover {
@@ -66,43 +71,54 @@
.help-modal .icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
margin: 15px 0;
gap: 8px;
margin: 10px 0;
}
.help-modal .icon-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
gap: 8px;
padding: 6px 8px;
background: var(--bg-primary, #0a0c10);
border: 1px solid var(--border-color, #1f2937);
border-radius: 4px;
font-size: 12px;
font-size: 11px;
}
.help-modal .icon-item .icon {
font-size: 18px;
width: 30px;
text-align: center;
width: 20px;
height: 20px;
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 {
color: var(--text-secondary, #9ca3af);
font-size: 10.5px;
line-height: 1.3;
}
.help-modal .tip-list {
list-style: none;
padding: 0;
margin: 15px 0;
margin: 10px 0;
}
.help-modal .tip-list li {
padding: 8px 0;
padding-left: 20px;
padding: 5px 0;
padding-left: 16px;
position: relative;
color: var(--text-secondary, #9ca3af);
font-size: 13px;
font-size: 11px;
line-height: 1.5;
border-bottom: 1px solid var(--border-color, #1f2937);
}
@@ -118,10 +134,15 @@
font-weight: bold;
}
.help-modal .tip-list li strong {
color: var(--text-primary, #e8eaed);
font-weight: 600;
}
.help-tabs {
display: flex;
gap: 0;
margin-bottom: 20px;
margin-bottom: 16px;
border: 1px solid var(--border-color, #1f2937);
border-radius: 4px;
overflow: hidden;
@@ -129,12 +150,13 @@
.help-tab {
flex: 1;
padding: 10px;
padding: 8px;
background: var(--bg-primary, #0a0c10);
border: none;
color: var(--text-secondary, #9ca3af);
cursor: pointer;
font-size: 11px;
font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.15s ease;
@@ -176,9 +198,9 @@
/* Ensure code tags are styled */
.help-modal code {
background: var(--bg-tertiary, #151a23);
padding: 2px 6px;
padding: 1px 5px;
border-radius: 3px;
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 11px;
font-family: var(--font-mono, 'Roboto Condensed', 'Arial Narrow', sans-serif);
font-size: 10.5px;
color: var(--accent-cyan, #4a9eff);
}
+6 -4
View File
@@ -5,8 +5,8 @@
}
:root {
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: '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: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
/* Tactical dark palette */
--bg-primary: #0b1118;
--bg-secondary: #101823;
@@ -706,6 +706,8 @@ header h1 {
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
padding: 0 20px;
position: relative;
z-index: 1100;
}
@media (min-width: 1024px) {
@@ -6370,7 +6372,7 @@ body::before {
}
.module-header {
font-family: 'Orbitron', 'Space Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px;
font-weight: 600;
color: var(--accent-cyan);
@@ -6548,7 +6550,7 @@ body::before {
/* Listening Mode Selector Buttons */
.radio-mode-btn {
padding: 12px 24px;
font-family: 'Orbitron', 'Space Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
+266
View File
@@ -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;
}
+467
View File
@@ -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;
}
}
+57 -57
View File
@@ -5,7 +5,7 @@
padding: 8px 10px;
background: var(--bg-tertiary, #1a1f2e);
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 11px;
}
@@ -86,7 +86,7 @@
border-radius: 4px;
background: var(--bg-tertiary, #1a1f2e);
color: var(--text-primary, #e0e0e0);
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 11px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
@@ -113,7 +113,7 @@
border-bottom: 2px solid transparent;
background: transparent;
color: var(--text-dim, #666);
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 11px;
text-transform: uppercase;
cursor: pointer;
@@ -153,7 +153,7 @@
display: inline-flex;
align-items: center;
gap: 7px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px;
color: var(--text-secondary, #999);
text-transform: uppercase;
@@ -168,7 +168,7 @@
}
.subghz-trigger-grid label {
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 9px;
color: var(--text-dim, #666);
text-transform: uppercase;
@@ -182,7 +182,7 @@
border-radius: 4px;
background: var(--bg-primary, #0d1117);
color: var(--text-primary, #e0e0e0);
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px;
}
@@ -192,7 +192,7 @@
.subghz-trigger-help {
margin: 0;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 9px;
color: var(--text-dim, #666);
line-height: 1.4;
@@ -207,7 +207,7 @@
background: var(--bg-tertiary, #1a1f2e);
border-radius: 4px;
margin-bottom: 10px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 11px;
}
@@ -264,7 +264,7 @@
border-radius: 4px;
background: transparent;
color: var(--text-primary, #e0e0e0);
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 12px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
@@ -369,7 +369,7 @@
background: var(--bg-tertiary, #1a1f2e);
border: 1px solid var(--border-color, #2a3040);
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 11px;
min-width: 0;
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
@@ -416,7 +416,7 @@
display: inline-flex;
align-items: center;
gap: 8px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.5px;
@@ -446,7 +446,7 @@
padding: 1px 6px;
border-radius: 999px;
border: 1px solid var(--border-color, #2a3040);
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 9px;
letter-spacing: 0.35px;
color: var(--text-dim, #666);
@@ -512,7 +512,7 @@
color: var(--text-dim, #666);
font-size: 10px;
cursor: pointer;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
.subghz-capture-actions button:hover {
@@ -554,7 +554,7 @@
border-radius: 4px;
color: var(--accent-red, #ff4444);
font-size: 10px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
line-height: 1.4;
margin-bottom: 8px;
}
@@ -591,7 +591,7 @@
border-radius: 6px;
padding: 12px;
overflow-y: auto;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 12px;
color: var(--text-primary, #e0e0e0);
min-height: 200px;
@@ -695,12 +695,12 @@
.subghz-tx-modal .tx-freq {
color: var(--accent-cyan, #00d4ff);
font-weight: 600;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
.subghz-tx-modal .tx-duration {
color: var(--text-dim, #666);
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
.subghz-tx-segment-box {
@@ -742,7 +742,7 @@
border-radius: 4px;
background: var(--bg-primary, #0d1117);
color: var(--text-primary, #e0e0e0);
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 11px;
}
@@ -755,7 +755,7 @@
margin-bottom: 0 !important;
font-size: 11px !important;
color: var(--accent-cyan, #00d4ff) !important;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
.subghz-tx-burst-assist {
@@ -768,7 +768,7 @@
}
.subghz-tx-burst-title {
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px;
color: var(--text-dim, #666);
text-transform: uppercase;
@@ -805,7 +805,7 @@
.subghz-tx-burst-range {
margin: 0 0 8px 0;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px;
color: var(--accent-cyan, #00d4ff);
}
@@ -839,7 +839,7 @@
padding: 6px;
border: 1px dashed var(--border-color, #2a3040);
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px;
color: var(--text-dim, #666);
line-height: 1.4;
@@ -854,7 +854,7 @@
border: 1px solid var(--border-color, #2a3040);
border-radius: 4px;
background: rgba(0, 0, 0, 0.15);
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px;
color: var(--text-secondary, #999);
}
@@ -865,7 +865,7 @@
border-radius: 3px;
background: transparent;
color: #00d4ff;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px;
cursor: pointer;
}
@@ -884,7 +884,7 @@
.subghz-tx-modal-actions button {
padding: 8px 20px;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 12px;
cursor: pointer;
border: 1px solid;
@@ -926,7 +926,7 @@
color: var(--text-dim, #666);
font-size: 12px;
padding: 24px 12px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
.subghz-captures-list-main .subghz-empty {
@@ -943,7 +943,7 @@
border: 1px solid #2a3040;
border-radius: 4px;
padding: 5px 9px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 11px;
z-index: 9999;
display: none;
@@ -970,7 +970,7 @@
min-width: 180px;
padding: 4px 0;
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;
}
@@ -1029,7 +1029,7 @@
border-radius: 3px;
background: transparent;
color: var(--text-primary, #e0e0e0);
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px;
cursor: pointer;
transition: background 0.12s, border-color 0.12s, color 0.12s;
@@ -1068,7 +1068,7 @@
content: 'No peaks detected';
color: var(--text-dim, #666);
font-size: 10px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
padding: 6px 0;
text-align: center;
}
@@ -1082,7 +1082,7 @@
border: 1px solid var(--border-color, #2a3040);
border-radius: 3px;
cursor: pointer;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px;
transition: border-color 0.12s;
}
@@ -1108,7 +1108,7 @@
border: 1px solid var(--border-color, #2a3040);
border-radius: 6px;
padding: 6px 12px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 11px;
flex-shrink: 0;
flex-wrap: wrap;
@@ -1192,7 +1192,7 @@
border-radius: 3px;
background: transparent;
color: #22c55e;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px;
cursor: pointer;
text-transform: uppercase;
@@ -1211,7 +1211,7 @@
padding: 2px 8px;
border: 1px solid var(--border-color, #2a3040);
border-radius: 3px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px;
color: var(--text-secondary, #999);
letter-spacing: 0.3px;
@@ -1263,7 +1263,7 @@
display: flex;
align-items: center;
gap: 6px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px;
}
@@ -1300,7 +1300,7 @@
border-radius: 999px;
margin-left: auto;
margin-right: 8px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px;
color: var(--text-dim, #666);
background: rgba(0, 0, 0, 0.15);
@@ -1365,7 +1365,7 @@
padding: 6px 12px;
overflow-y: auto;
max-height: 114px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px;
line-height: 1.6;
}
@@ -1402,7 +1402,7 @@
}
.subghz-hub-header-title {
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 20px;
font-weight: 700;
color: var(--accent-cyan, #00d4ff);
@@ -1410,7 +1410,7 @@
}
.subghz-hub-header-sub {
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 11px;
color: var(--text-dim, #666);
margin-top: 2px;
@@ -1472,14 +1472,14 @@
}
.subghz-hub-title {
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
}
.subghz-hub-desc {
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px;
color: var(--text-dim, #666);
}
@@ -1526,7 +1526,7 @@
}
.subghz-saved-selection-count {
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px;
color: var(--accent-cyan, #00d4ff);
margin-right: 4px;
@@ -1538,7 +1538,7 @@
border-radius: 4px;
background: transparent;
color: var(--text-secondary, #999);
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 11px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
@@ -1550,7 +1550,7 @@
}
.subghz-op-panel-title {
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 12px;
color: var(--text-primary, #e0e0e0);
text-transform: uppercase;
@@ -1620,7 +1620,7 @@
display: flex;
align-items: center;
gap: 8px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 14px;
font-weight: 600;
color: var(--accent-red, #ff4444);
@@ -1654,14 +1654,14 @@
}
.subghz-rx-info-label {
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 9px;
color: var(--text-dim, #666);
letter-spacing: 0.5px;
}
.subghz-rx-info-value {
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
@@ -1688,7 +1688,7 @@
border: 1px solid var(--border-color, #2a3040);
border-radius: 4px;
background: rgba(0, 0, 0, 0.22);
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
.subghz-rx-hint-label {
@@ -1722,7 +1722,7 @@
padding: 2px 8px;
border: 1px solid var(--border-color, #2a3040);
border-radius: 999px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px;
color: var(--text-dim, #666);
background: rgba(0, 0, 0, 0.2);
@@ -1741,7 +1741,7 @@
}
.subghz-rx-level-label {
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 9px;
color: var(--text-dim, #666);
letter-spacing: 0.5px;
@@ -1772,7 +1772,7 @@
}
.subghz-rx-scope-label {
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 9px;
color: var(--text-dim, #666);
letter-spacing: 0.5px;
@@ -1832,7 +1832,7 @@
display: flex;
align-items: center;
gap: 6px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 9px;
color: var(--text-dim, #666);
letter-spacing: 0.4px;
@@ -1854,7 +1854,7 @@
border-radius: 4px;
background: transparent;
color: var(--text-secondary, #999);
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
@@ -1938,7 +1938,7 @@
}
.subghz-tx-label {
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 14px;
font-weight: 600;
color: var(--accent-red, #ff4444);
@@ -1958,14 +1958,14 @@
}
.subghz-tx-info-label {
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 9px;
color: var(--text-dim, #666);
letter-spacing: 0.5px;
}
.subghz-tx-info-value {
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
@@ -1998,7 +1998,7 @@
}
.subghz-sweep-peaks-title {
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px;
color: var(--text-dim, #666);
text-transform: uppercase;
+31
View File
@@ -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); }
}
+22 -22
View File
@@ -49,7 +49,7 @@
.wxsat-strip-status-text {
font-size: 12px;
color: var(--text-secondary, #999);
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
.wxsat-strip-btn {
@@ -59,7 +59,7 @@
background: transparent;
color: var(--text-primary, #e0e0e0);
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
cursor: pointer;
transition: all 0.2s;
}
@@ -92,7 +92,7 @@
.wxsat-strip-value {
font-size: 13px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
color: var(--text-primary, #e0e0e0);
}
@@ -114,7 +114,7 @@
gap: 6px;
cursor: pointer;
font-size: 10px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
color: var(--text-dim, #666);
text-transform: uppercase;
letter-spacing: 0.5px;
@@ -146,7 +146,7 @@
border-radius: 3px;
color: var(--text-primary, #e0e0e0);
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
.wxsat-loc-input:focus {
@@ -225,7 +225,7 @@
.wxsat-cd-value {
font-size: 16px;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
color: var(--text-primary, #e0e0e0);
line-height: 1;
}
@@ -248,13 +248,13 @@
font-size: 12px;
font-weight: 600;
color: var(--accent-cyan, #00d4ff);
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
.wxsat-countdown-detail {
font-size: 10px;
color: var(--text-dim, #666);
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
/* ===== Timeline ===== */
@@ -314,7 +314,7 @@
justify-content: space-between;
font-size: 8px;
color: var(--text-dim, #666);
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
/* ===== Pass Predictions Panel ===== */
@@ -349,7 +349,7 @@
.wxsat-passes-count {
font-size: 11px;
color: var(--accent-cyan, #00d4ff);
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
.wxsat-passes-list {
@@ -387,7 +387,7 @@
background: rgba(255, 187, 0, 0.15);
color: #ffbb00;
margin-left: 6px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
text-transform: uppercase;
letter-spacing: 0.5px;
}
@@ -409,7 +409,7 @@
font-size: 10px;
padding: 2px 6px;
border-radius: 3px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
.wxsat-pass-mode.apt {
@@ -428,7 +428,7 @@
gap: 4px;
font-size: 11px;
color: var(--text-dim, #666);
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
.wxsat-pass-detail-label {
@@ -499,7 +499,7 @@
.wxsat-panel-subtitle {
font-size: 10px;
color: var(--accent-cyan, #00d4ff);
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
#wxsatPolarCanvas {
@@ -547,7 +547,7 @@
.wxsat-gallery-count {
font-size: 11px;
color: var(--accent-cyan, #00d4ff);
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
.wxsat-gallery-grid {
@@ -636,7 +636,7 @@
.wxsat-image-product {
font-size: 10px;
color: var(--accent-cyan, #00d4ff);
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
.wxsat-image-timestamp {
@@ -649,7 +649,7 @@
.wxsat-date-header {
grid-column: 1 / -1;
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
color: var(--text-dim, #666);
text-transform: uppercase;
letter-spacing: 0.5px;
@@ -708,7 +708,7 @@
.wxsat-capture-message {
font-size: 11px;
color: var(--text-secondary, #999);
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -719,7 +719,7 @@
.wxsat-capture-elapsed {
font-size: 11px;
color: var(--text-dim, #666);
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
flex-shrink: 0;
}
@@ -785,7 +785,7 @@
border-radius: 4px;
color: var(--text-secondary, #999);
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
text-align: center;
}
@@ -941,7 +941,7 @@
display: flex;
align-items: center;
gap: 4px;
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
.wxsat-phase-step {
@@ -1012,7 +1012,7 @@
max-height: 160px;
padding: 6px 12px;
background: var(--bg-primary, #0d1117);
font-family: 'JetBrains Mono', monospace;
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
font-size: 10px;
line-height: 1.6;
}
+2 -2
View File
@@ -5,8 +5,8 @@
}
:root {
--font-sans: 'IBM Plex Mono', 'Space Mono', ui-monospace, 'SF Mono', 'Consolas', 'Menlo', monospace;
--font-mono: '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: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
--bg-dark: #0b1118;
--bg-panel: #101823;
--bg-card: #151f2b;
+9 -8
View File
@@ -423,7 +423,7 @@ async function syncAgentModeStates(agentId) {
});
// 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 => {
if (!agentRunningModes.includes(mode)) {
syncModeUI(mode, false, agentId);
@@ -704,6 +704,7 @@ function syncModeUI(mode, isRunning, agentId = null) {
'wifi': 'setWiFiRunning',
'bluetooth': 'setBluetoothRunning',
'acars': 'setAcarsRunning',
'vdl2': 'setVdl2Running',
'listening_post': 'setListeningPostRunning'
};
@@ -865,12 +866,12 @@ function connectAgentStream(mode, onMessage) {
}
let streamUrl;
if (currentAgent === 'local') {
streamUrl = `/${mode}/stream`;
} else {
// For remote agents, proxy SSE through controller
streamUrl = `/controller/agents/${currentAgent}/${mode}/stream`;
}
if (currentAgent === 'local') {
streamUrl = `/${mode}/stream`;
} else {
// For remote agents, proxy SSE through controller
streamUrl = `/controller/agents/${currentAgent}/${mode}/stream`;
}
agentEventSource = new EventSource(streamUrl);
@@ -878,7 +879,7 @@ function connectAgentStream(mode, onMessage) {
try {
const data = JSON.parse(event.data);
onMessage(data);
onMessage(data);
} catch (e) {
console.error('Error parsing SSE message:', e);
}
+2 -2
View File
@@ -373,7 +373,7 @@ function showInfo(text) {
const infoEl = document.createElement('div');
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;
output.insertBefore(infoEl, output.firstChild);
}
@@ -387,7 +387,7 @@ function showError(text) {
const errorEl = document.createElement('div');
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;
output.insertBefore(errorEl, output.firstChild);
}
+2 -2
View File
@@ -833,11 +833,11 @@ function renderUpdateStatus(data) {
<div style="display: grid; gap: 8px; font-size: 12px;">
<div style="display: flex; justify-content: space-between;">
<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 style="display: flex; justify-content: space-between;">
<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>
${data.last_check ? `
<div style="display: flex; justify-content: space-between;">
+215
View File
@@ -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)">&#8596;</span>' +
'<span>' + _esc(p.bt_mac || '') + '</span>' +
'<div class="confidence-bar"><div class="confidence-fill" style="width:' + conf + '%"></div></div>' +
'<span style="color:var(--text-dim)">' + conf + '%</span>' +
'</div>';
}).join('');
}
function renderGeofences(zones) {
const container = document.getElementById('analyticsGeofenceList');
if (!container) return;
if (!zones || zones.length === 0) {
container.innerHTML = '<div class="analytics-empty">No geofence zones defined</div>';
return;
}
container.innerHTML = zones.map(z =>
'<div class="geofence-zone-item">' +
'<span class="zone-name">' + _esc(z.name) + '</span>' +
'<span class="zone-radius">' + z.radius_m + 'm</span>' +
'<button class="zone-delete" onclick="Analytics.deleteGeofence(' + z.id + ')">DEL</button>' +
'</div>'
).join('');
}
function addGeofence() {
const name = prompt('Zone name:');
if (!name) return;
const lat = parseFloat(prompt('Latitude:', '0'));
const lon = parseFloat(prompt('Longitude:', '0'));
const radius = parseFloat(prompt('Radius (meters):', '1000'));
if (isNaN(lat) || isNaN(lon) || isNaN(radius)) {
alert('Invalid input');
return;
}
fetch('/analytics/geofences', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, lat, lon, radius_m: radius }),
})
.then(r => r.json())
.then(() => refresh());
}
function deleteGeofence(id) {
if (!confirm('Delete this geofence zone?')) return;
fetch('/analytics/geofences/' + id, { method: 'DELETE' })
.then(r => r.json())
.then(() => refresh());
}
function exportData(mode) {
const m = mode || (document.getElementById('exportMode') || {}).value || 'adsb';
const f = (document.getElementById('exportFormat') || {}).value || 'json';
window.open('/analytics/export/' + encodeURIComponent(m) + '?format=' + encodeURIComponent(f), '_blank');
}
// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
return { init, destroy, refresh, addGeofence, deleteGeofence, exportData };
})();
+36 -4
View File
@@ -8,6 +8,7 @@ const GPS = (function() {
let connected = false;
let lastPosition = null;
let lastSky = null;
let skyPollTimer = null;
// Constellation color map
const CONST_COLORS = {
@@ -41,6 +42,7 @@ const GPS = (function() {
updateSkyUI(data.sky);
}
subscribeToStream();
startSkyPolling();
// Ensure the global GPS stream is running
if (typeof startGpsStream === 'function' && !gpsEventSource) {
startGpsStream();
@@ -58,6 +60,7 @@ const GPS = (function() {
function disconnect() {
unsubscribeFromStream();
stopSkyPolling();
fetch('/gps/stop', { method: 'POST' })
.then(() => {
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() {
// Subscribe to the global GPS stream instead of opening a separate SSE connection
if (typeof addGpsStreamSubscriber === 'function') {
@@ -260,7 +291,7 @@ const GPS = (function() {
// PRN label
ctx.fillStyle = color;
ctx.font = '8px JetBrains Mono, monospace';
ctx.font = '8px Roboto Condensed, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(sat.prn, px, py - dotSize - 2);
@@ -268,7 +299,7 @@ const GPS = (function() {
// SNR value
if (sat.snr != null) {
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = '7px JetBrains Mono, monospace';
ctx.font = '7px Roboto Condensed, monospace';
ctx.textBaseline = 'top';
ctx.fillText(Math.round(sat.snr), px, py + dotSize + 1);
}
@@ -300,7 +331,7 @@ const GPS = (function() {
ctx.stroke();
// Label
ctx.fillStyle = '#555';
ctx.font = '9px JetBrains Mono, monospace';
ctx.font = '9px Roboto Condensed, monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
@@ -315,7 +346,7 @@ const GPS = (function() {
// Cardinal directions
ctx.fillStyle = '#888';
ctx.font = 'bold 11px JetBrains Mono, monospace';
ctx.font = 'bold 11px Roboto Condensed, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('N', cx, cy - r - 12);
@@ -395,6 +426,7 @@ const GPS = (function() {
function destroy() {
unsubscribeFromStream();
stopSkyPolling();
}
return {
+1 -1
View File
@@ -1483,7 +1483,7 @@ function drawAudioVisualizer() {
}
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('4kHz', canvas.width / 4, canvas.height - 2);
ctx.fillText('8kHz', canvas.width / 2, canvas.height - 2);
+677
View File
@@ -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
};
})();
+1 -1
View File
@@ -84,7 +84,7 @@ const SpyStations = (function() {
modeContainer.innerHTML = modes.map(m => `
<label class="inline-checkbox">
<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>
`).join('');
}
+2 -2
View File
@@ -1754,7 +1754,7 @@ const SubGhz = (function() {
// Grid
ctx.strokeStyle = '#1a1f2e';
ctx.lineWidth = 1;
ctx.font = '10px JetBrains Mono, monospace';
ctx.font = '10px Roboto Condensed, monospace';
ctx.fillStyle = '#666';
for (let db = powerMin; db <= powerMax; db += 20) {
@@ -1824,7 +1824,7 @@ const SubGhz = (function() {
ctx.lineTo(x + 4, y - 2);
ctx.closePath();
ctx.fill();
ctx.font = '9px JetBrains Mono, monospace';
ctx.font = '9px Roboto Condensed, monospace';
ctx.fillStyle = 'rgba(255, 170, 0, 0.8)';
ctx.textAlign = 'center';
ctx.fillText(peak.freq.toFixed(1), x, y - 10);
+28 -8
View File
@@ -399,16 +399,20 @@ const WeatherSat = (function() {
addConsoleEntry('Capture complete', 'signal');
updatePhaseIndicator('complete');
if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer);
consoleAutoHideTimer = setTimeout(() => showConsole(false), 30000);
}
} else if (data.status === 'error') {
isRunning = false;
if (!schedulerEnabled) stopStream();
updateStatusUI('idle', 'Error');
showNotification('Weather Sat', data.message || 'Capture error');
if (captureStatus) captureStatus.classList.remove('active');
if (data.message) addConsoleEntry(data.message, 'error');
updatePhaseIndicator('error');
if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer);
consoleAutoHideTimer = setTimeout(() => showConsole(false), 15000);
}
}
@@ -566,7 +570,7 @@ const WeatherSat = (function() {
</div>
<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 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 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>
@@ -610,7 +614,7 @@ const WeatherSat = (function() {
ctx.stroke();
// Label
ctx.fillStyle = '#555';
ctx.font = '9px JetBrains Mono, monospace';
ctx.font = '9px Roboto Condensed, monospace';
ctx.textAlign = 'left';
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
});
@@ -624,7 +628,7 @@ const WeatherSat = (function() {
// Cardinal directions
ctx.fillStyle = '#666';
ctx.font = '10px JetBrains Mono, monospace';
ctx.font = '10px Roboto Condensed, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
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.fill();
ctx.fillStyle = color;
ctx.font = '9px JetBrains Mono, monospace';
ctx.font = '9px Roboto Condensed, monospace';
ctx.textAlign = 'center';
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);
// Observer marker
const lat = parseFloat(localStorage.getItem('observerLat'));
const lon = parseFloat(localStorage.getItem('observerLon'));
let obsLat, obsLon;
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
const shared = ObserverLocation.getShared();
obsLat = shared?.lat;
obsLon = shared?.lon;
} else {
obsLat = parseFloat(localStorage.getItem('observerLat'));
obsLon = parseFloat(localStorage.getItem('observerLon'));
}
const lat = obsLat;
const lon = obsLon;
if (!isNaN(lat) && !isNaN(lon)) {
L.circleMarker([lat, lon], {
radius: 6, color: '#ffbb00', fillColor: '#ffbb00', fillOpacity: 0.8, weight: 1,
@@ -946,8 +959,15 @@ const WeatherSat = (function() {
* Enable auto-scheduler
*/
async function enableScheduler() {
const lat = parseFloat(localStorage.getItem('observerLat'));
const lon = parseFloat(localStorage.getItem('observerLon'));
let lat, lon;
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)) {
showNotification('Weather Sat', 'Set observer location first');
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -7,7 +7,7 @@
{% if offline_settings.fonts_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
{% 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 %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
@@ -472,7 +472,7 @@
if (!points.length) {
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);
return;
}
@@ -480,7 +480,7 @@
const series = points.map(p => p[field]).filter(v => v !== null && v !== undefined);
if (!series.length) {
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);
return;
}
@@ -521,7 +521,7 @@
}
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(`${minVal} ${unit}`, 12, height - padding);
}
+1 -1
View File
@@ -8,7 +8,7 @@
{% if offline_settings.fonts_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
{% 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 %}
<!-- Leaflet.js - Conditional CDN/Local loading -->
{% if offline_settings.assets_source == 'local' %}
+201 -327
View File
@@ -29,7 +29,7 @@
{% if offline_settings.fonts_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
{% 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 %}
<!-- Leaflet.js for APRS map - Conditional CDN/Local loading -->
{% 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/global-nav.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/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-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/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/space-weather.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/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-name">GPS</span>
</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>
@@ -551,10 +556,14 @@
{% include 'partials/modes/gps.html' %}
{% include 'partials/modes/space-weather.html' %}
{% include 'partials/modes/listening-post.html' %}
{% include 'partials/modes/tscm.html' %}
{% include 'partials/modes/analytics.html' %}
{% include 'partials/modes/ais.html' %}
{% include 'partials/modes/spy-stations.html' %}
@@ -569,6 +578,8 @@
{% include 'partials/modes/bt_locate.html' %}
<button class="preset-btn" onclick="killAll()"
style="width: 100%; margin-top: 10px; border-color: #ff3366; color: #ff3366;">
Kill All Processes
@@ -1005,6 +1016,7 @@
<option value="brazil">Brazil (145.570)</option>
<option value="japan">Japan (144.640)</option>
<option value="china">China (144.640)</option>
<option value="iss">ISS (145.825)</option>
<option value="custom">Custom Frequency</option>
</select>
</div>
@@ -2561,7 +2573,7 @@
<!-- Signal Scope -->
<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;">
<span>Signal Scope</span>
<div style="display: flex; gap: 14px;">
@@ -2828,7 +2840,7 @@
<!-- Signal Scope -->
<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;">
<span>Signal Scope</span>
<div style="display: flex; gap: 14px;">
@@ -2898,6 +2910,141 @@
</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&#197;</button>
<button class="sw-image-tab sw-solar-tab" data-key="sdo_304" onclick="SpaceWeather.selectSolarImage('sdo_304')">304&#197;</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) -->
<div class="recon-panel collapsed" id="reconPanel">
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
@@ -2920,7 +3067,7 @@
<!-- Pager Signal Scope -->
<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;">
<span>Signal Scope</span>
<div style="display: flex; gap: 14px;">
@@ -2938,7 +3085,7 @@
<!-- Sensor Signal Scope -->
<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;">
<span>Signal Scope</span>
<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/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/analytics.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script>
<script>
// ============================================
@@ -3158,7 +3307,8 @@
const validModes = new Set([
'pager', 'sensor', 'rtlamr', 'aprs', 'listening',
'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() {
@@ -3615,11 +3765,11 @@
'pager': 'sdr', 'sensor': 'sdr',
'aprs': 'sdr', 'listening': 'sdr',
'wifi': 'wireless', 'bluetooth': 'wireless', 'bt_locate': 'wireless',
'tscm': 'security',
'tscm': 'security', 'analytics': 'security',
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr',
'meshtastic': 'sdr',
'satellite': 'space', 'sstv': 'space', 'weathersat': 'space', 'sstv_general': 'space', 'gps': 'space',
'subghz': 'sdr'
'spaceweather': 'space', 'subghz': 'sdr'
};
// Remove has-active from all dropdowns
@@ -3694,7 +3844,8 @@
'pager': 'pager', 'sensor': '433',
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth', 'bt_locate': 'bt locate',
'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 => {
const label = btn.querySelector('.nav-label');
@@ -3722,6 +3873,10 @@
document.getElementById('dmrMode')?.classList.toggle('active', mode === 'dmr');
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
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 sensorStats = document.getElementById('sensorStats');
const satelliteStats = document.getElementById('satelliteStats');
@@ -3762,7 +3917,9 @@
'meshtastic': 'MESHTASTIC',
'dmr': 'DIGITAL VOICE',
'websdr': 'WEBSDR',
'subghz': 'SUBGHZ'
'subghz': 'SUBGHZ',
'analytics': 'ANALYTICS',
'spaceweather': 'SPACE WX'
};
const activeModeIndicator = document.getElementById('activeModeIndicator');
if (activeModeIndicator) activeModeIndicator.innerHTML = '<span class="pulse-dot"></span>' + (modeNames[mode] || mode.toUpperCase());
@@ -3782,6 +3939,7 @@
const websdrVisuals = document.getElementById('websdrVisuals');
const subghzVisuals = document.getElementById('subghzVisuals');
const btLocateVisuals = document.getElementById('btLocateVisuals');
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
@@ -3798,6 +3956,7 @@
if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none';
if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? '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
const mainContent = document.querySelector('.main-content');
@@ -3807,6 +3966,8 @@
} else {
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
@@ -3836,7 +3997,9 @@
'meshtastic': 'Meshtastic Mesh Monitor',
'dmr': 'Digital Voice Decoder',
'websdr': 'HF/Shortwave WebSDR',
'subghz': 'SubGHz Transceiver'
'subghz': 'SubGHz Transceiver',
'analytics': 'Cross-Mode Analytics',
'spaceweather': 'Space Weather Monitor'
};
const outputTitle = document.getElementById('outputTitle');
if (outputTitle) outputTitle.textContent = titles[mode] || 'Signal Monitor';
@@ -3850,11 +4013,25 @@
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)
const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
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 (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none';
@@ -3869,7 +4046,7 @@
// Show agent selector for modes that support remote agents
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';
// Show RTL-SDR device section for modes that use it
@@ -3892,8 +4069,8 @@
// Hide output console for modes with their own visualizations
const outputEl = document.getElementById('output');
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 (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'dmr' || mode === 'subghz') ? 'none' : 'flex';
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' || mode === 'spaceweather') ? 'none' : 'flex';
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
if (mode !== 'meshtastic') {
@@ -3959,6 +4136,8 @@
SubGhz.init();
} else if (mode === 'bt_locate') {
BtLocate.init();
} else if (mode === 'spaceweather') {
SpaceWeather.init();
}
}
@@ -5849,7 +6028,7 @@
const infoEl = document.createElement('div');
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;
output.insertBefore(infoEl, output.firstChild);
}
@@ -5865,7 +6044,7 @@
const errorEl = document.createElement('div');
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;
output.insertBefore(errorEl, output.firstChild);
}
@@ -8519,7 +8698,7 @@
// Draw total in center
ctx.fillStyle = '#fff';
ctx.font = 'bold 16px Space Mono';
ctx.font = 'bold 16px Roboto Condensed';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(total, cx, cy);
@@ -10339,7 +10518,7 @@
// Label
if (el > 0) {
ctx.fillStyle = '#444';
ctx.font = '10px Space Mono';
ctx.font = '10px Roboto Condensed';
ctx.textAlign = 'center';
ctx.fillText(el + '°', cx, cy - r + 12);
}
@@ -10412,7 +10591,7 @@
// Label
ctx.fillStyle = '#fff';
ctx.font = '11px Space Mono';
ctx.font = '11px Roboto Condensed';
ctx.fillText(pass.satellite, maxX + 10, maxY - 5);
}
}
@@ -15453,313 +15632,8 @@
<!-- Scanner/Audio code moved to static/js/modes/listening-post.js -->
<!-- Help Modal -->
<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>
{% include 'partials/help-modal.html' %}
<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 &amp; history</span></div>
<div class="icon-item"><span class="icon">🚢</span><span class="desc">Vessels - AIS &amp; 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 -->
<div id="satModal" class="help-modal" onclick="if(event.target === this) closeSatModal()">
+1 -1
View File
@@ -10,7 +10,7 @@
{% if offline_settings and offline_settings.fonts_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
{% 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 %}
{# Core CSS (Design System) #}
+3 -3
View File
@@ -7,7 +7,7 @@
{% if offline_settings.fonts_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
{% 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 %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/agents.css') }}">
@@ -18,8 +18,8 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--font-sans: 'Space Mono', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'Space Mono', 'Fira Code', 'Consolas', monospace;
--font-sans: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'Roboto Condensed', 'Arial Narrow', Roboto, 'Helvetica Neue', Arial, sans-serif;
--bg-primary: #0a0c10;
--bg-secondary: #0f1218;
--bg-tertiary: #151a23;
+135 -51
View File
@@ -20,35 +20,44 @@
<div id="help-icons" class="help-section active">
<h3>Stats Bar Icons</h3>
<div class="icon-grid">
<div class="icon-item"><span class="icon">&#128223;</span><span class="desc">POCSAG messages decoded</span></div>
<div class="icon-item"><span class="icon">&#128224;</span><span class="desc">FLEX messages decoded</span></div>
<div class="icon-item"><span class="icon">&#128232;</span><span class="desc">Total messages received</span></div>
<div class="icon-item"><span class="icon">&#127777;&#65039;</span><span class="desc">Unique sensors detected</span></div>
<div class="icon-item"><span class="icon">&#128202;</span><span class="desc">Device types found</span></div>
<div class="icon-item"><span class="icon">&#128752;&#65039;</span><span class="desc">Satellites monitored</span></div>
<div class="icon-item"><span class="icon">&#128225;</span><span class="desc">WiFi Access Points</span></div>
<div class="icon-item"><span class="icon">&#128100;</span><span class="desc">Connected WiFi clients</span></div>
<div class="icon-item"><span class="icon">&#129309;</span><span class="desc">Captured handshakes</span></div>
<div class="icon-item"><span class="icon">&#128641;</span><span class="desc">Detected drones (click for details)</span></div>
<div class="icon-item"><span class="icon">&#9888;&#65039;</span><span class="desc">Rogue APs (click for details)</span></div>
<div class="icon-item"><span class="icon">&#128309;</span><span class="desc">Bluetooth devices</span></div>
<div class="icon-item"><span class="icon">&#128205;</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"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span><span class="desc">POCSAG messages decoded</span></div>
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span><span class="desc">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"><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 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 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 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 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 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 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 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 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 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 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>
<h3>Mode Tab Icons</h3>
<div class="icon-grid">
<div class="icon-item"><span class="icon">&#128223;</span><span class="desc">Pager - POCSAG/FLEX decoder</span></div>
<div class="icon-item"><span class="icon">&#128225;</span><span class="desc">433MHz - Sensor decoder</span></div>
<div class="icon-item"><span class="icon">&#9889;</span><span class="desc">Meters - Utility meter decoder</span></div>
<div class="icon-item"><span class="icon">&#9992;&#65039;</span><span class="desc">Aircraft - ADS-B tracking &amp; history</span></div>
<div class="icon-item"><span class="icon">&#128674;</span><span class="desc">Vessels - AIS &amp; VHF DSC distress</span></div>
<div class="icon-item"><span class="icon">&#128251;</span><span class="desc">Spy Stations - Number stations database</span></div>
<div class="icon-item"><span class="icon">&#128205;</span><span class="desc">APRS - Amateur radio tracking</span></div>
<div class="icon-item"><span class="icon">&#128752;&#65039;</span><span class="desc">Satellite - Pass prediction</span></div>
<div class="icon-item"><span class="icon">&#128246;</span><span class="desc">WiFi - Network scanner</span></div>
<div class="icon-item"><span class="icon">&#128309;</span><span class="desc">Bluetooth - BT/BLE scanner</span></div>
<div class="icon-item"><span class="icon">&#128251;</span><span class="desc">Listening Post - SDR scanner</span></div>
<div class="icon-item"><span class="icon">&#128269;</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"><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 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 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 icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span><span class="desc">Aircraft - ADS-B tracking &amp; history</span></div>
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg></span><span class="desc">Vessels - AIS &amp; VHF DSC distress</span></div>
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span><span class="desc">APRS - Amateur radio tracking</span></div>
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span><span class="desc">Listening Post - SDR scanner</span></div>
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span><span class="desc">Spy Stations - Number stations database</span></div>
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span><span class="desc">Meshtastic - LoRa mesh networking</span></div>
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span><span class="desc">WebSDR - Remote SDR receivers</span></div>
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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 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 &amp; 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 &amp; geomagnetic data</span></div>
</div>
</div>
@@ -96,15 +105,6 @@
<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>
@@ -114,6 +114,50 @@
<li>Interactive map shows station positions in real-time</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>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>
<ul class="tip-list">
<li>Track satellites using TLE (Two-Line Element) data</li>
@@ -122,6 +166,47 @@
<li>View next pass predictions with elevation and duration</li>
</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 &mdash; all data from public APIs</li>
</ul>
<h3>WiFi Mode</h3>
<ul class="tip-list">
<li>Requires a WiFi adapter capable of monitor mode</li>
@@ -140,13 +225,12 @@
<li>Radar visualization shows device proximity</li>
</ul>
<h3>Listening Post Mode</h3>
<h3>BT Locate 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>
<li>Locate and track specific Bluetooth devices by signal strength</li>
<li>Directional signal strength indicator for physical device finding</li>
<li>Detects known tracker signatures (AirTag, Tile, SmartTag)</li>
<li>Useful for finding lost devices or detecting unwanted trackers</li>
</ul>
<h3>TSCM Mode</h3>
@@ -159,16 +243,6 @@
<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>Network Monitor</h3>
<ul class="tip-list">
<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>Vessels (AIS):</strong> RTL-SDR, AIS-catcher</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>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>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>Run as root/sudo for full hardware access</li>
</ul>
+208
View File
@@ -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 &amp; 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) &mdash; 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>
+144
View File
@@ -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">&#9660;</span>
</h3>
<div class="section-content">
<div class="analytics-grid" id="analyticsSummaryCards">
<div class="analytics-card" data-mode="adsb">
<div class="card-count" id="analyticsCountAdsb">0</div>
<div class="card-label">Aircraft</div>
<div class="card-sparkline" id="analyticsSparkAdsb"></div>
</div>
<div class="analytics-card" data-mode="ais">
<div class="card-count" id="analyticsCountAis">0</div>
<div class="card-label">Vessels</div>
<div class="card-sparkline" id="analyticsSparkAis"></div>
</div>
<div class="analytics-card" data-mode="wifi">
<div class="card-count" id="analyticsCountWifi">0</div>
<div class="card-label">WiFi</div>
<div class="card-sparkline" id="analyticsSparkWifi"></div>
</div>
<div class="analytics-card" data-mode="bluetooth">
<div class="card-count" id="analyticsCountBt">0</div>
<div class="card-label">Bluetooth</div>
<div class="card-sparkline" id="analyticsSparkBt"></div>
</div>
<div class="analytics-card" data-mode="dsc">
<div class="card-count" id="analyticsCountDsc">0</div>
<div class="card-label">DSC</div>
<div class="card-sparkline" id="analyticsSparkDsc"></div>
</div>
<div class="analytics-card" data-mode="acars">
<div class="card-count" id="analyticsCountAcars">0</div>
<div class="card-label">ACARS</div>
<div class="card-sparkline" id="analyticsSparkAcars"></div>
</div>
<div class="analytics-card" data-mode="vdl2">
<div class="card-count" id="analyticsCountVdl2">0</div>
<div class="card-label">VDL2</div>
<div class="card-sparkline" id="analyticsSparkVdl2"></div>
</div>
<div class="analytics-card" data-mode="aprs">
<div class="card-count" id="analyticsCountAprs">0</div>
<div class="card-label">APRS</div>
<div class="card-sparkline" id="analyticsSparkAprs"></div>
</div>
<div class="analytics-card" data-mode="meshtastic">
<div class="card-count" id="analyticsCountMesh">0</div>
<div class="card-label">Mesh</div>
<div class="card-sparkline" id="analyticsSparkMesh"></div>
</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Mode Health</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="analytics-health" id="analyticsHealth"></div>
</div>
</div>
<div class="section" id="analyticsSquawkSection" style="display:none;">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Emergency Squawks</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="squawk-emergency" id="analyticsSquawkPanel">
<div class="squawk-title">Active Emergency Codes</div>
<div id="analyticsSquawkList"></div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Recent Alerts</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="analytics-alert-feed" id="analyticsAlertFeed">
<div class="analytics-empty">No recent alerts</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Correlations</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div id="analyticsCorrelations">
<div class="analytics-empty">No correlations detected</div>
</div>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Geofences</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div id="analyticsGeofenceList"></div>
<button class="btn btn-sm" onclick="Analytics.addGeofence()" style="margin-top:8px; font-size:10px; padding:4px 10px; background:var(--accent-cyan); color:#fff; border:none; border-radius:4px; cursor:pointer;">
+ Add Zone
</button>
</div>
</div>
<div class="section">
<h3 class="section-header collapsible" onclick="toggleSection(this)">
<span>Export Data</span>
<span class="collapse-icon">&#9660;</span>
</h3>
<div class="section-content">
<div class="export-controls">
<select id="exportMode">
<option value="adsb">ADS-B</option>
<option value="ais">AIS</option>
<option value="wifi">WiFi</option>
<option value="bluetooth">Bluetooth</option>
<option value="dsc">DSC</option>
</select>
<select id="exportFormat">
<option value="json">JSON</option>
<option value="csv">CSV</option>
</select>
<button onclick="Analytics.exportData()">Export</button>
</div>
</div>
</div>
</div>
@@ -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>
+4 -4
View File
@@ -23,7 +23,7 @@
</div>
<div class="form-group" style="margin-top: 8px;">
<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>
@@ -55,12 +55,12 @@
<div class="form-group">
<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">
<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 class="form-group">
<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">
<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 class="form-group">
<label>Sample Rate</label>
@@ -143,7 +143,7 @@
<div class="form-group">
<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">
<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 class="form-group">
<label>Max Duration (seconds)</label>
+228
View File
@@ -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 &amp; 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) &mdash; 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;">
<strong style="color: var(--accent-cyan); font-size: 12px;">V-Dipole (Easiest &mdash; ~$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
/ \
@@ -80,7 +80,7 @@
<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>
<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
&lt;---------&gt;
====+==== 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;">
<strong style="color: #00ff88; font-size: 12px;">QFH &mdash; Quadrifilar Helix (Best &mdash; ~$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
| | | twisted 90 deg
| | | around a mast
@@ -200,7 +200,7 @@
</div>
<div class="form-group">
<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 class="form-group">
<label>Sample Rate</label>
@@ -230,7 +230,7 @@
Enable Auto-Capture
</label>
</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
</div>
</div>
+8
View File
@@ -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('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }}
{{ mode_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }}
{{ mode_item('aprs', 'APRS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>') }}
{{ mode_item('listening', 'Listening Post', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
{{ mode_item('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">
{{ 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>
@@ -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('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
{{ mode_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
{{ mode_item('spaceweather', 'Space Weather', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>') }}
</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('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('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('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('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 %}
{{ 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 %}
@@ -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('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
{{ mobile_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
{{ mobile_item('spaceweather', 'SpaceWx', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/></svg>') }}
{{ mobile_item('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('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>') }}
+2 -2
View File
@@ -54,7 +54,7 @@
<div class="settings-row">
<div class="settings-label">
<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>
<select id="fontsSource" class="settings-select" onchange="Settings.setFontsSource(this.value)">
<option value="cdn">Google Fonts (Online)</option>
@@ -108,7 +108,7 @@
<span class="asset-badge checking" id="statusInter">Checking...</span>
</div>
<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>
</div>
</div>
+5 -5
View File
@@ -8,7 +8,7 @@
{% if offline_settings.fonts_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
{% 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 %}
<!-- Leaflet.js - Conditional CDN/Local loading -->
{% if offline_settings.assets_source == 'local' %}
@@ -622,7 +622,7 @@
ctx.stroke();
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);
}
@@ -990,7 +990,7 @@
ctx.stroke();
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);
}
@@ -1057,7 +1057,7 @@
ctx.textAlign = 'center';
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.fillText(el.toFixed(1) + '°', x, y + 25);
} else {
@@ -1106,7 +1106,7 @@
ctx.textAlign = 'center';
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.fillText(el.toFixed(1) + '°', x, y + 25);
}
+202
View File
@@ -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)
+99
View File
@@ -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
+114
View File
@@ -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
+231
View File
@@ -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
+84
View File
@@ -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
+126
View File
@@ -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
+30 -14
View File
@@ -318,6 +318,8 @@ class GPSDClient:
except json.JSONDecodeError:
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:
continue
@@ -371,19 +373,33 @@ class GPSDClient:
self._update_position(position)
def _handle_sky(self, msg: dict) -> None:
"""Handle SKY (satellite sky view) message from gpsd."""
sats = []
for sat in msg.get('satellites', []):
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),
))
"""Handle SKY (satellite sky view) message from gpsd.
gpsd sends multiple SKY messages per cycle: some contain only DOP
values while others include the full satellites array. When a
DOP-only SKY arrives, preserve the most recent satellite list
instead of overwriting it with an empty one.
"""
raw_sats = msg.get('satellites', [])
has_satellites = len(raw_sats) > 0
if has_satellites:
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(
satellites=sats,
@@ -490,7 +506,7 @@ def get_current_position() -> GPSPosition | None:
# ============================================
_gpsd_process: 'subprocess.Popen | None' = None
_gpsd_process_lock = threading.Lock()
_gpsd_process_lock = threading.RLock()
def detect_gps_devices() -> list[dict]:
+40
View File
@@ -306,6 +306,9 @@ class MeshtasticClient:
self._range_test_running: bool = False
self._range_test_results: list[dict] = []
# Topology tracking: node_id -> {neighbors, hop_count, msg_count, last_seen}
self._topology: dict[str, dict] = {}
@property
def is_running(self) -> bool:
return self._running
@@ -326,6 +329,35 @@ class MeshtasticClient:
"""Set callback for received messages."""
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',
hostname: str | None = None) -> bool:
"""
@@ -463,6 +495,14 @@ class MeshtasticClient:
# Track node from packet (always, even for filtered messages)
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
if portnum == 'TRACEROUTE_APP':
self._handle_traceroute_response(packet, decoded)
+93
View File
@@ -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
+118 -74
View File
@@ -156,7 +156,9 @@ class WeatherSatDecoder:
self._process: subprocess.Popen | None = None
self._running = False
self._lock = threading.Lock()
self._pty_lock = threading.Lock()
self._images_lock = threading.Lock()
self._stop_event = threading.Event()
self._callback: Callable[[CaptureProgress], None] | None = None
self._output_dir = Path(output_dir) if output_dir else Path('data/weather_sat')
self._images: list[WeatherSatImage] = []
@@ -212,6 +214,16 @@ class WeatherSatDecoder:
)
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:
"""Set callback for capture progress updates."""
self._callback = callback
@@ -292,6 +304,7 @@ class WeatherSatDecoder:
self._current_mode = sat_info['mode']
self._capture_start_time = time.time()
self._capture_phase = 'decoding'
self._stop_event.clear()
try:
self._running = True
@@ -372,6 +385,7 @@ class WeatherSatDecoder:
self._device_index = device_index
self._capture_start_time = time.time()
self._capture_phase = 'tuning'
self._stop_event.clear()
try:
self._running = True
@@ -781,13 +795,11 @@ class WeatherSatDecoder:
except Exception as e:
logger.error(f"Error reading SatDump output: {e}")
finally:
# Close PTY master fd
if self._pty_master_fd is not None:
try:
os.close(self._pty_master_fd)
except OSError:
pass
self._pty_master_fd = None
# Close PTY master fd (thread-safe)
self._close_pty()
# Signal watcher thread to do final scan and exit
self._stop_event.set()
# Process ended — release resources
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
if was_running:
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',
))
# Collect exit status (returncode is only set after poll/wait)
if self._process and self._process.returncode is None:
try:
self._process.wait(timeout=5)
except subprocess.TimeoutExpired:
self._process.kill()
self._process.wait()
retcode = self._process.returncode if self._process else None
if retcode and retcode != 0:
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
if self._on_complete_callback:
@@ -822,63 +855,79 @@ class WeatherSatDecoder:
known_files: set[str] = set()
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:
# 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:
# Final scan — SatDump writes images at the end of processing,
# often after the process has already exited. Do multiple scans
# with a short delay to catch late-written files.
for _ in range(3):
time.sleep(0.5)
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
except OSError:
continue
# Skip tiny files (likely incomplete)
try:
stat = filepath.stat()
if stat.st_size < 1000:
continue
except OSError:
continue
# Determine product type from filename/path
product = self._parse_product_name(filepath)
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
product = self._parse_product_name(filepath)
# Only mark as known after successful copy
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:
serve_path = filepath
serve_name = filepath.name
image = WeatherSatImage(
filename=serve_name,
path=serve_path,
satellite=self._current_satellite,
mode=self._current_mode,
timestamp=datetime.now(timezone.utc),
frequency=self._current_frequency,
size_bytes=stat.st_size,
product=product,
)
with self._images_lock:
self._images.append(image)
image = WeatherSatImage(
filename=serve_name,
path=serve_path,
satellite=self._current_satellite,
mode=self._current_mode,
timestamp=datetime.now(timezone.utc),
frequency=self._current_frequency,
size_bytes=stat.st_size,
product=product,
)
with self._images_lock:
self._images.append(image)
logger.info(f"New weather satellite image: {serve_name} ({product})")
self._emit_progress(CaptureProgress(
status='complete',
satellite=self._current_satellite,
frequency=self._current_frequency,
mode=self._current_mode,
message=f'Image decoded: {product}',
image=image,
))
logger.info(f"New weather satellite image: {serve_name} ({product})")
self._emit_progress(CaptureProgress(
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}")
except Exception as e:
logger.error(f"Error scanning for images: {e}")
def _parse_product_name(self, filepath: Path) -> str:
"""Parse a human-readable product name from the image filepath."""
@@ -916,13 +965,8 @@ class WeatherSatDecoder:
"""Stop weather satellite capture."""
with self._lock:
self._running = False
if self._pty_master_fd is not None:
try:
os.close(self._pty_master_fd)
except OSError:
pass
self._pty_master_fd = None
self._stop_event.set()
self._close_pty()
if self._process:
safe_terminate(self._process)