Compare commits

...

12 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
38 changed files with 4051 additions and 206 deletions
+1
View File
@@ -48,6 +48,7 @@ Support the developer of this open-source project
- **GPS** - Real-time GPS position tracking with live map, speed, altitude, and satellite info - **GPS** - Real-time GPS position tracking with live map, speed, altitude, and satellite info
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection - **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
- **Meshtastic** - LoRa mesh network integration - **Meshtastic** - LoRa mesh network integration
- **Space Weather** - Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL (no SDR required)
- **Spy Stations** - Number stations and diplomatic HF network database - **Spy Stations** - Number stations and diplomatic HF network database
- **Remote Agents** - Distributed SIGINT with remote sensor nodes - **Remote Agents** - Distributed SIGINT with remote sensor nodes
- **Offline Mode** - Bundled assets for air-gapped/field deployments - **Offline Mode** - Bundled assets for air-gapped/field deployments
+12 -1
View File
@@ -7,10 +7,21 @@ import os
import sys import sys
# Application version # Application version
VERSION = "2.19.0" VERSION = "2.20.0"
# Changelog - latest release notes (shown on welcome screen) # Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [ CHANGELOG = [
{
"version": "2.20.0",
"date": "February 2026",
"highlights": [
"Space Weather mode: real-time solar and geomagnetic monitoring from NOAA SWPC, NASA SDO, and HamQSL",
"Kp index, solar wind, X-ray flux charts with Chart.js visualization",
"HF band conditions, D-RAP absorption maps, aurora forecast, and solar imagery",
"NOAA Space Weather Scales (G/S/R), flare probability, and active solar regions",
"No SDR hardware required — all data from public APIs with server-side caching",
]
},
{ {
"version": "2.19.0", "version": "2.19.0",
"date": "February 2026", "date": "February 2026",
+16
View File
@@ -165,6 +165,22 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
- **Real-time JSON output** with meter ID, consumption, and signal data - **Real-time JSON output** with meter ID, consumption, and signal data
- **Multiple meter protocol support** via rtl_tcp integration - **Multiple meter protocol support** via rtl_tcp integration
## Space Weather
- **Real-time solar indices** - Solar Flux Index (SFI), Kp index, A-index, sunspot number
- **NOAA Space Weather Scales** - Geomagnetic storms (G), solar radiation (S), radio blackouts (R)
- **HF band conditions** - Day/night propagation from HamQSL for 80m through 10m bands
- **Solar wind monitoring** - Speed, density, and IMF Bz from DSCOVR satellite
- **X-ray flux chart** - GOES X-ray data with flare class scale (A/B/C/M/X)
- **Flare probability** - 1-day and 3-day C/M/X-class flare forecasts
- **Solar imagery** - NASA SDO 193A, 304A, and magnetogram images
- **D-RAP absorption maps** - HF radio absorption at 5-30 MHz frequency bands
- **Aurora forecast** - OVATION aurora oval visualization
- **SWPC alerts** - Real-time space weather alerts and warnings
- **Active solar regions** - Current sunspot region data with location and area
- **Auto-refresh** - 5-minute polling with manual refresh option
- **No SDR required** - Data fetched from NOAA SWPC, NASA SDO, and HamQSL public APIs
## Satellite Tracking ## Satellite Tracking
- **Full-screen dashboard** - dedicated popout with polar plot and ground track - **Full-screen dashboard** - dedicated popout with polar plot and ground track
+13 -4
View File
@@ -206,14 +206,23 @@ Extended base for full-screen dashboards (maps, visualizations).
| `listening` | Listening post | | `listening` | Listening post |
| `spystations` | Spy stations | | `spystations` | Spy stations |
| `meshtastic` | Mesh networking | | `meshtastic` | Mesh networking |
| `weathersat` | Weather satellites |
| `sstv_general` | HF SSTV |
| `gps` | GPS tracking |
| `websdr` | WebSDR |
| `subghz` | Sub-GHz analyzer |
| `bt_locate` | BT Locate |
| `analytics` | Analytics dashboard |
| `spaceweather` | Space weather |
| `dmr` | DMR/P25 digital voice |
### Navigation Groups ### Navigation Groups
The navigation is organized into groups: The navigation is organized into groups:
- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic - **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic, WebSDR, SubGHz
- **Wireless**: WiFi, Bluetooth - **Wireless**: WiFi, Bluetooth, BT Locate
- **Security**: TSCM - **Security**: TSCM, Analytics
- **Space**: Satellite, ISS SSTV - **Space**: Satellite, ISS SSTV, Weather Sat, HF SSTV, GPS, Space Weather
--- ---
+19
View File
@@ -239,6 +239,25 @@ Enable the auto-scheduler to automatically capture passes:
- Starts SatDump at the correct time and frequency - Starts SatDump at the correct time and frequency
- Decoded images are saved with timestamps - Decoded images are saved with timestamps
## Space Weather
1. **Switch to Space Weather mode** - Select "Space Weather" from the Space navigation group
2. **View Dashboard** - Solar indices, NOAA scales, band conditions, and charts load automatically
3. **Solar Imagery** - Toggle between SDO 193A, 304A, and Magnetogram views
4. **D-RAP Maps** - Select frequency (5-30 MHz) to view HF radio absorption maps
5. **Aurora Forecast** - View the OVATION aurora oval for the northern hemisphere
6. **Alerts** - Review current SWPC space weather alerts and warnings
7. **Active Regions** - View solar active region data (number, location, area)
8. **Refresh** - Data auto-refreshes every 5 minutes, or click "Refresh Now"
### Tips
- No SDR hardware required — all data comes from public APIs (NOAA SWPC, NASA SDO, HamQSL)
- Check HF band conditions before operating on shortwave frequencies
- Kp >= 5 indicates geomagnetic storm conditions that may affect HF propagation
- D-RAP maps show where HF absorption is highest — useful for path planning
- Solar imagery updates approximately every 15 minutes from NASA SDO
## AIS Vessel Tracking ## AIS Vessel Tracking
1. **Select Hardware** - Choose your SDR type 1. **Select Hardware** - Choose your SDR type
+5
View File
@@ -156,6 +156,11 @@
<h3>GPS Tracking</h3> <h3>GPS Tracking</h3>
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p> <p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
</div> </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-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> <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> <h3>WiFi Scanning</h3>
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "intercept" name = "intercept"
version = "2.19.0" version = "2.20.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth" description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
+4
View File
@@ -35,6 +35,8 @@ def register_blueprints(app):
from .recordings import recordings_bp from .recordings import recordings_bp
from .subghz import subghz_bp from .subghz import subghz_bp
from .bt_locate import bt_locate_bp from .bt_locate import bt_locate_bp
from .analytics import analytics_bp
from .space_weather import space_weather_bp
app.register_blueprint(pager_bp) app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp) app.register_blueprint(sensor_bp)
@@ -69,6 +71,8 @@ def register_blueprints(app):
app.register_blueprint(recordings_bp) # Session recordings app.register_blueprint(recordings_bp) # Session recordings
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF) app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
app.register_blueprint(analytics_bp) # Cross-mode analytics dashboard
app.register_blueprint(space_weather_bp) # Space weather monitoring
# Initialize TSCM state with queue and lock from app # Initialize TSCM state with queue and lock from app
import app as app_module import app as app_module
+10 -6
View File
@@ -35,11 +35,8 @@ acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
# Default VHF ACARS frequencies (MHz) - common worldwide # Default VHF ACARS frequencies (MHz) - common worldwide
DEFAULT_ACARS_FREQUENCIES = [ DEFAULT_ACARS_FREQUENCIES = [
'131.550', # Primary worldwide '131.725', # North America
'130.025', # Secondary USA/Canada '131.825', # North America
'129.125', # USA
'131.525', # Europe
'131.725', # Europe secondary
] ]
# Message counter for statistics # Message counter for statistics
@@ -129,6 +126,13 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
app_module.acars_queue.put(data) app_module.acars_queue.put(data)
# Feed flight correlator
try:
from utils.flight_correlator import get_flight_correlator
get_flight_correlator().add_acars_message(data)
except Exception:
pass
# Log if enabled # Log if enabled
if app_module.logging_enabled: if app_module.logging_enabled:
try: try:
@@ -440,7 +444,7 @@ def get_frequencies() -> Response:
return jsonify({ return jsonify({
'default': DEFAULT_ACARS_FREQUENCIES, 'default': DEFAULT_ACARS_FREQUENCIES,
'regions': { 'regions': {
'north_america': ['129.125', '130.025', '130.450', '131.550'], 'north_america': ['131.725', '131.825'],
'europe': ['131.525', '131.725', '131.550'], 'europe': ['131.525', '131.725', '131.550'],
'asia_pacific': ['131.550', '131.450'], 'asia_pacific': ['131.550', '131.450'],
} }
+41
View File
@@ -439,6 +439,12 @@ def parse_sbs_stream(service_addr):
if parts[16]: if parts[16]:
try: try:
aircraft['vertical_rate'] = int(float(parts[16])) aircraft['vertical_rate'] = int(float(parts[16]))
if abs(aircraft['vertical_rate']) > 4000:
process_event('adsb', {
'type': 'vertical_rate_anomaly', 'icao': icao,
'callsign': aircraft.get('callsign', ''),
'vertical_rate': aircraft['vertical_rate'],
}, 'vertical_rate_anomaly')
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
@@ -456,6 +462,14 @@ def parse_sbs_stream(service_addr):
elif msg_type == '6' and len(parts) > 17: elif msg_type == '6' and len(parts) > 17:
if parts[17]: if parts[17]:
aircraft['squawk'] = parts[17] aircraft['squawk'] = parts[17]
sq = parts[17].strip()
_EMERGENCY_SQUAWKS = {'7700': 'General Emergency', '7600': 'Comms Failure', '7500': 'Hijack'}
if sq in _EMERGENCY_SQUAWKS:
process_event('adsb', {
'type': 'squawk_emergency', 'icao': icao,
'callsign': aircraft.get('callsign', ''),
'squawk': sq, 'meaning': _EMERGENCY_SQUAWKS[sq],
}, 'squawk_emergency')
app_module.adsb_aircraft.set(icao, aircraft) app_module.adsb_aircraft.set(icao, aircraft)
pending_updates.add(icao) pending_updates.add(icao)
@@ -488,6 +502,19 @@ def parse_sbs_stream(service_addr):
'source_host': service_addr, 'source_host': service_addr,
'snapshot': snapshot, 'snapshot': snapshot,
}) })
# Geofence check
_gf_lat = snapshot.get('lat')
_gf_lon = snapshot.get('lon')
if _gf_lat and _gf_lon:
try:
from utils.geofence import get_geofence_manager
for _gf_evt in get_geofence_manager().check_position(
update_icao, 'aircraft', _gf_lat, _gf_lon,
{'callsign': snapshot.get('callsign'), 'altitude': snapshot.get('altitude')}
):
process_event('adsb', _gf_evt, 'geofence')
except Exception:
pass
pending_updates.clear() pending_updates.clear()
last_update = now last_update = now
@@ -1103,3 +1130,17 @@ def aircraft_photo(registration: str):
except Exception as e: except Exception as e:
logger.debug(f"Error fetching aircraft photo: {e}") logger.debug(f"Error fetching aircraft photo: {e}")
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@adsb_bp.route('/aircraft/<icao>/messages')
def get_aircraft_messages(icao: str):
"""Get correlated ACARS/VDL2 messages for an aircraft."""
if not icao or not all(c in '0123456789ABCDEFabcdef' for c in icao):
return jsonify({'status': 'error', 'message': 'Invalid ICAO'}), 400
aircraft = app_module.adsb_aircraft.get(icao.upper())
callsign = aircraft.get('callsign') if aircraft else None
from utils.flight_correlator import get_flight_correlator
messages = get_flight_correlator().get_messages_for_aircraft(icao=icao.upper(), callsign=callsign)
return jsonify({'status': 'success', 'icao': icao.upper(), **messages})
+42 -1
View File
@@ -124,13 +124,27 @@ def parse_ais_stream(port: int):
if now - last_update >= AIS_UPDATE_INTERVAL: if now - last_update >= AIS_UPDATE_INTERVAL:
for mmsi in pending_updates: for mmsi in pending_updates:
if mmsi in app_module.ais_vessels: if mmsi in app_module.ais_vessels:
_vessel_snap = app_module.ais_vessels[mmsi]
try: try:
app_module.ais_queue.put_nowait({ app_module.ais_queue.put_nowait({
'type': 'vessel', 'type': 'vessel',
**app_module.ais_vessels[mmsi] **_vessel_snap
}) })
except queue.Full: except queue.Full:
pass pass
# Geofence check
_v_lat = _vessel_snap.get('lat')
_v_lon = _vessel_snap.get('lon')
if _v_lat and _v_lon:
try:
from utils.geofence import get_geofence_manager
for _gf_evt in get_geofence_manager().check_position(
mmsi, 'vessel', _v_lat, _v_lon,
{'name': _vessel_snap.get('name'), 'ship_type': _vessel_snap.get('ship_type_text')}
):
process_event('ais', _gf_evt, 'geofence')
except Exception:
pass
pending_updates.clear() pending_updates.clear()
last_update = now last_update = now
@@ -282,6 +296,16 @@ def process_ais_message(msg: dict) -> dict | None:
# Timestamp # Timestamp
vessel['last_seen'] = time.time() vessel['last_seen'] = time.time()
# Check for DSC DISTRESS matching this MMSI
try:
for _dsc_key, _dsc_msg in app_module.dsc_messages.items():
if (str(_dsc_msg.get('source_mmsi', '')) == mmsi
and _dsc_msg.get('category', '').upper() == 'DISTRESS'):
vessel['dsc_distress'] = True
break
except Exception:
pass
return vessel return vessel
@@ -502,6 +526,23 @@ def stream_ais():
return response return response
@ais_bp.route('/vessel/<mmsi>/dsc')
def get_vessel_dsc(mmsi: str):
"""Get DSC messages associated with a vessel MMSI."""
if not mmsi or not mmsi.isdigit():
return jsonify({'status': 'error', 'message': 'Invalid MMSI'}), 400
matches = []
try:
for key, msg in app_module.dsc_messages.items():
if str(msg.get('source_mmsi', '')) == mmsi:
matches.append(dict(msg))
except Exception:
pass
return jsonify({'status': 'success', 'mmsi': mmsi, 'dsc_messages': matches})
@ais_bp.route('/dashboard') @ais_bp.route('/dashboard')
def ais_dashboard(): def ais_dashboard():
"""Popout AIS dashboard.""" """Popout AIS dashboard."""
+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 from flask import Blueprint, jsonify, request, Response
import app as app_module import app as app_module
from utils.logging import sensor_logger as logger from utils.logging import sensor_logger as logger
from utils.validation import validate_device_index, validate_gain, validate_ppm from utils.validation import validate_device_index, validate_gain, validate_ppm
from utils.sse import format_sse from utils.sse import format_sse
from utils.event_pipeline import process_event from utils.event_pipeline import process_event
from utils.sdr import SDRFactory, SDRType from utils.sdr import SDRFactory, SDRType
from utils.constants import ( from utils.constants import (
PROCESS_TERMINATE_TIMEOUT, PROCESS_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL, SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT, SSE_QUEUE_TIMEOUT,
PROCESS_START_WAIT, PROCESS_START_WAIT,
) )
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs') aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
@@ -47,6 +47,7 @@ APRS_FREQUENCIES = {
'brazil': '145.570', 'brazil': '145.570',
'japan': '144.640', 'japan': '144.640',
'china': '144.640', 'china': '144.640',
'iss': '145.825',
} }
# Statistics # Statistics
@@ -73,19 +74,19 @@ def find_multimon_ng() -> Optional[str]:
return shutil.which('multimon-ng') return shutil.which('multimon-ng')
def find_rtl_fm() -> Optional[str]: def find_rtl_fm() -> Optional[str]:
"""Find rtl_fm binary.""" """Find rtl_fm binary."""
return shutil.which('rtl_fm') return shutil.which('rtl_fm')
def find_rx_fm() -> Optional[str]: def find_rx_fm() -> Optional[str]:
"""Find SoapySDR rx_fm binary.""" """Find SoapySDR rx_fm binary."""
return shutil.which('rx_fm') return shutil.which('rx_fm')
def find_rtl_power() -> Optional[str]: def find_rtl_power() -> Optional[str]:
"""Find rtl_power binary for spectrum scanning.""" """Find rtl_power binary for spectrum scanning."""
return shutil.which('rtl_power') return shutil.which('rtl_power')
# Path to direwolf config file # Path to direwolf config file
@@ -1378,6 +1379,19 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
'last_seen': packet.get('timestamp'), 'last_seen': packet.get('timestamp'),
'packet_type': packet.get('packet_type'), 'packet_type': packet.get('packet_type'),
} }
# Geofence check
_aprs_lat = packet.get('lat')
_aprs_lon = packet.get('lon')
if _aprs_lat and _aprs_lon:
try:
from utils.geofence import get_geofence_manager
for _gf_evt in get_geofence_manager().check_position(
callsign, 'aprs_station', _aprs_lat, _aprs_lon,
{'callsign': callsign}
):
process_event('aprs', _gf_evt, 'geofence')
except Exception:
pass
# Evict oldest stations when limit is exceeded # Evict oldest stations when limit is exceeded
if len(aprs_stations) > APRS_MAX_STATIONS: if len(aprs_stations) > APRS_MAX_STATIONS:
oldest = min( oldest = min(
@@ -1420,22 +1434,22 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
@aprs_bp.route('/tools') @aprs_bp.route('/tools')
def check_aprs_tools() -> Response: def check_aprs_tools() -> Response:
"""Check for APRS decoding tools.""" """Check for APRS decoding tools."""
has_rtl_fm = find_rtl_fm() is not None has_rtl_fm = find_rtl_fm() is not None
has_rx_fm = find_rx_fm() is not None has_rx_fm = find_rx_fm() is not None
has_direwolf = find_direwolf() is not None has_direwolf = find_direwolf() is not None
has_multimon = find_multimon_ng() is not None has_multimon = find_multimon_ng() is not None
has_fm_demod = has_rtl_fm or has_rx_fm has_fm_demod = has_rtl_fm or has_rx_fm
return jsonify({ return jsonify({
'rtl_fm': has_rtl_fm, 'rtl_fm': has_rtl_fm,
'rx_fm': has_rx_fm, 'rx_fm': has_rx_fm,
'direwolf': has_direwolf, 'direwolf': has_direwolf,
'multimon_ng': has_multimon, 'multimon_ng': has_multimon,
'ready': has_fm_demod and (has_direwolf or has_multimon), 'ready': has_fm_demod and (has_direwolf or has_multimon),
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None) 'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
}) })
@aprs_bp.route('/status') @aprs_bp.route('/status')
@@ -1476,12 +1490,12 @@ def start_aprs() -> Response:
'message': 'APRS decoder already running' 'message': 'APRS decoder already running'
}), 409 }), 409
# Check for decoder (prefer direwolf, fallback to multimon-ng) # Check for decoder (prefer direwolf, fallback to multimon-ng)
direwolf_path = find_direwolf() direwolf_path = find_direwolf()
multimon_path = find_multimon_ng() multimon_path = find_multimon_ng()
if not direwolf_path and not multimon_path: if not direwolf_path and not multimon_path:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'No APRS decoder found. Install direwolf or multimon-ng' 'message': 'No APRS decoder found. Install direwolf or multimon-ng'
}), 400 }), 400
@@ -1489,31 +1503,31 @@ def start_aprs() -> Response:
data = request.json or {} data = request.json or {}
# Validate inputs # Validate inputs
try: try:
device = validate_device_index(data.get('device', '0')) device = validate_device_index(data.get('device', '0'))
gain = validate_gain(data.get('gain', '40')) gain = validate_gain(data.get('gain', '40'))
ppm = validate_ppm(data.get('ppm', '0')) ppm = validate_ppm(data.get('ppm', '0'))
except ValueError as e: except ValueError as e:
return jsonify({'status': 'error', 'message': str(e)}), 400 return jsonify({'status': 'error', 'message': str(e)}), 400
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower() sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
try: try:
sdr_type = SDRType(sdr_type_str) sdr_type = SDRType(sdr_type_str)
except ValueError: except ValueError:
sdr_type = SDRType.RTL_SDR sdr_type = SDRType.RTL_SDR
if sdr_type == SDRType.RTL_SDR: if sdr_type == SDRType.RTL_SDR:
if find_rtl_fm() is None: if find_rtl_fm() is None:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr' 'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
}), 400 }), 400
else: else:
if find_rx_fm() is None: if find_rx_fm() is None:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.' 'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
}), 400 }), 400
# Reserve SDR device to prevent conflicts with other modes # Reserve SDR device to prevent conflicts with other modes
error = app_module.claim_sdr_device(device, 'aprs') error = app_module.claim_sdr_device(device, 'aprs')
@@ -1545,29 +1559,29 @@ def start_aprs() -> Response:
aprs_last_packet_time = None aprs_last_packet_time = None
aprs_stations = {} aprs_stations = {}
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction. # Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
try: try:
sdr_device = SDRFactory.create_default_device(sdr_type, index=device) sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
builder = SDRFactory.get_builder(sdr_type) builder = SDRFactory.get_builder(sdr_type)
rtl_cmd = builder.build_fm_demod_command( rtl_cmd = builder.build_fm_demod_command(
device=sdr_device, device=sdr_device,
frequency_mhz=float(frequency), frequency_mhz=float(frequency),
sample_rate=22050, sample_rate=22050,
gain=float(gain) if gain and str(gain) != '0' else None, gain=float(gain) if gain and str(gain) != '0' else None,
ppm=int(ppm) if ppm and str(ppm) != '0' else None, ppm=int(ppm) if ppm and str(ppm) != '0' else None,
modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm', modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm',
squelch=None, squelch=None,
bias_t=bool(data.get('bias_t', False)), bias_t=bool(data.get('bias_t', False)),
) )
if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-': if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-':
# APRS benefits from DC blocking + fast AGC on rtl_fm. # APRS benefits from DC blocking + fast AGC on rtl_fm.
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-'] rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
except Exception as e: except Exception as e:
if aprs_active_device is not None: if aprs_active_device is not None:
app_module.release_sdr_device(aprs_active_device) app_module.release_sdr_device(aprs_active_device)
aprs_active_device = None aprs_active_device = None
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500 return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
# Build decoder command # Build decoder command
if direwolf_path: if direwolf_path:
@@ -1690,14 +1704,14 @@ def start_aprs() -> Response:
) )
thread.start() thread.start()
return jsonify({ return jsonify({
'status': 'started', 'status': 'started',
'frequency': frequency, 'frequency': frequency,
'region': region, 'region': region,
'device': device, 'device': device,
'sdr_type': sdr_type.value, 'sdr_type': sdr_type.value,
'decoder': decoder_name 'decoder': decoder_name
}) })
except Exception as e: except Exception as e:
logger.error(f"Failed to start APRS decoder: {e}") logger.error(f"Failed to start APRS decoder: {e}")
+16
View File
@@ -1051,3 +1051,19 @@ def request_store_forward():
'status': 'error', 'status': 'error',
'message': error or 'Failed to request S&F history' 'message': error or 'Failed to request S&F history'
}), 500 }), 500
@meshtastic_bp.route('/topology')
def mesh_topology():
"""Return mesh network topology graph."""
if not is_meshtastic_available():
return jsonify({'status': 'error', 'message': 'Meshtastic SDK not installed'}), 400
client = get_meshtastic_client()
if not client or not client.is_running:
return jsonify({'status': 'error', 'message': 'Not connected'}), 400
return jsonify({
'status': 'success',
'topology': client.get_topology(),
})
+24
View File
@@ -28,6 +28,10 @@ sensor_bp = Blueprint('sensor', __name__)
# Track which device is being used # Track which device is being used
sensor_active_device: int | None = None sensor_active_device: int | None = None
# RSSI history per device (model_id -> list of (timestamp, rssi))
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
_MAX_RSSI_HISTORY = 60
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
"""Stream rtl_433 JSON output to queue.""" """Stream rtl_433 JSON output to queue."""
@@ -45,6 +49,17 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
data['type'] = 'sensor' data['type'] = 'sensor'
app_module.sensor_queue.put(data) app_module.sensor_queue.put(data)
# Track RSSI history per device
_model = data.get('model', '')
_dev_id = data.get('id', '')
_rssi_val = data.get('rssi')
if _rssi_val is not None and _model:
_hist_key = f"{_model}_{_dev_id}"
hist = sensor_rssi_history.setdefault(_hist_key, [])
hist.append((time.time(), float(_rssi_val)))
if len(hist) > _MAX_RSSI_HISTORY:
del hist[: len(hist) - _MAX_RSSI_HISTORY]
# Push scope event when signal level data is present # Push scope event when signal level data is present
rssi = data.get('rssi') rssi = data.get('rssi')
snr = data.get('snr') snr = data.get('snr')
@@ -283,3 +298,12 @@ def stream_sensor() -> Response:
response.headers['X-Accel-Buffering'] = 'no' response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive' response.headers['Connection'] = 'keep-alive'
return response return response
@sensor_bp.route('/sensor/rssi_history')
def get_rssi_history() -> Response:
"""Return RSSI history for all tracked sensor devices."""
result = {}
for key, entries in sensor_rssi_history.items():
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
return jsonify({'status': 'success', 'devices': result})
+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'})
+7
View File
@@ -76,6 +76,13 @@ def stream_vdl2_output(process: subprocess.Popen) -> None:
app_module.vdl2_queue.put(data) 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 # Log if enabled
if app_module.logging_enabled: if app_module.logging_enabled:
try: try:
+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;
}
}
+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 };
})();
+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
};
})();
+24 -4
View File
@@ -399,16 +399,20 @@ const WeatherSat = (function() {
addConsoleEntry('Capture complete', 'signal'); addConsoleEntry('Capture complete', 'signal');
updatePhaseIndicator('complete'); updatePhaseIndicator('complete');
if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer);
consoleAutoHideTimer = setTimeout(() => showConsole(false), 30000); consoleAutoHideTimer = setTimeout(() => showConsole(false), 30000);
} }
} else if (data.status === 'error') { } else if (data.status === 'error') {
isRunning = false;
if (!schedulerEnabled) stopStream();
updateStatusUI('idle', 'Error'); updateStatusUI('idle', 'Error');
showNotification('Weather Sat', data.message || 'Capture error'); showNotification('Weather Sat', data.message || 'Capture error');
if (captureStatus) captureStatus.classList.remove('active'); if (captureStatus) captureStatus.classList.remove('active');
if (data.message) addConsoleEntry(data.message, 'error'); if (data.message) addConsoleEntry(data.message, 'error');
updatePhaseIndicator('error'); updatePhaseIndicator('error');
if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer);
consoleAutoHideTimer = setTimeout(() => showConsole(false), 15000); consoleAutoHideTimer = setTimeout(() => showConsole(false), 15000);
} }
} }
@@ -761,8 +765,17 @@ const WeatherSat = (function() {
}).addTo(groundTrackLayer); }).addTo(groundTrackLayer);
// Observer marker // Observer marker
const lat = parseFloat(localStorage.getItem('observerLat')); let obsLat, obsLon;
const lon = parseFloat(localStorage.getItem('observerLon')); if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
const shared = ObserverLocation.getShared();
obsLat = shared?.lat;
obsLon = shared?.lon;
} else {
obsLat = parseFloat(localStorage.getItem('observerLat'));
obsLon = parseFloat(localStorage.getItem('observerLon'));
}
const lat = obsLat;
const lon = obsLon;
if (!isNaN(lat) && !isNaN(lon)) { if (!isNaN(lat) && !isNaN(lon)) {
L.circleMarker([lat, lon], { L.circleMarker([lat, lon], {
radius: 6, color: '#ffbb00', fillColor: '#ffbb00', fillOpacity: 0.8, weight: 1, radius: 6, color: '#ffbb00', fillColor: '#ffbb00', fillOpacity: 0.8, weight: 1,
@@ -946,8 +959,15 @@ const WeatherSat = (function() {
* Enable auto-scheduler * Enable auto-scheduler
*/ */
async function enableScheduler() { async function enableScheduler() {
const lat = parseFloat(localStorage.getItem('observerLat')); let lat, lon;
const lon = parseFloat(localStorage.getItem('observerLon')); if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
const shared = ObserverLocation.getShared();
lat = shared?.lat;
lon = shared?.lon;
} else {
lat = parseFloat(localStorage.getItem('observerLat'));
lon = parseFloat(localStorage.getItem('observerLon'));
}
if (isNaN(lat) || isNaN(lon)) { if (isNaN(lat) || isNaN(lon)) {
showNotification('Weather Sat', 'Set observer location first'); showNotification('Weather Sat', 'Set observer location first');
+44 -2
View File
@@ -3332,7 +3332,7 @@ sudo make install</code>
let acarsMessageCount = 0; let acarsMessageCount = 0;
let acarsSidebarCollapsed = localStorage.getItem('acarsSidebarCollapsed') !== 'false'; let acarsSidebarCollapsed = localStorage.getItem('acarsSidebarCollapsed') !== 'false';
let acarsFrequencies = { let acarsFrequencies = {
'na': ['129.125', '130.025', '130.450', '131.550'], 'na': ['131.725', '131.825'],
'eu': ['131.525', '131.725', '131.550'], 'eu': ['131.525', '131.725', '131.550'],
'ap': ['131.550', '131.450'] 'ap': ['131.550', '131.450']
}; };
@@ -3359,6 +3359,22 @@ sudo make install</code>
sidebar.classList.add('collapsed'); sidebar.classList.add('collapsed');
} }
updateAcarsFreqCheckboxes(); updateAcarsFreqCheckboxes();
// Check if ACARS is already running (e.g. after page reload)
fetch('/acars/status')
.then(r => r.json())
.then(data => {
if (data.running) {
isAcarsRunning = true;
acarsMessageCount = data.message_count || 0;
document.getElementById('acarsCount').textContent = acarsMessageCount;
document.getElementById('acarsToggleBtn').textContent = '■ STOP ACARS';
document.getElementById('acarsToggleBtn').classList.add('active');
document.getElementById('acarsPanelIndicator').classList.add('active');
startAcarsStream(false);
}
})
.catch(() => {});
}); });
function updateAcarsFreqCheckboxes() { function updateAcarsFreqCheckboxes() {
@@ -3542,6 +3558,11 @@ sudo make install</code>
acarsEventSource.onerror = function() { acarsEventSource.onerror = function() {
console.error('ACARS stream error'); console.error('ACARS stream error');
setTimeout(() => {
if (isAcarsRunning) {
startAcarsStream(acarsCurrentAgent !== null);
}
}, 2000);
}; };
// Start polling fallback for agent mode // Start polling fallback for agent mode
@@ -3872,6 +3893,11 @@ sudo make install</code>
vdl2EventSource.onerror = function() { vdl2EventSource.onerror = function() {
console.error('VDL2 stream error'); console.error('VDL2 stream error');
setTimeout(() => {
if (isVdl2Running) {
startVdl2Stream(vdl2CurrentAgent !== null);
}
}, 2000);
}; };
// Start polling fallback for agent mode // Start polling fallback for agent mode
@@ -4163,7 +4189,7 @@ sudo make install</code>
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
// Populate VDL2 device selector // Populate VDL2 device selector and check running status
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
fetch('/devices') fetch('/devices')
.then(r => r.json()) .then(r => r.json())
@@ -4185,6 +4211,22 @@ sudo make install</code>
} }
} }
}); });
// Check if VDL2 is already running (e.g. after page reload)
fetch('/vdl2/status')
.then(r => r.json())
.then(data => {
if (data.running) {
isVdl2Running = true;
vdl2MessageCount = data.message_count || 0;
document.getElementById('vdl2Count').textContent = vdl2MessageCount;
document.getElementById('vdl2ToggleBtn').innerHTML = '&#9632; STOP VDL2';
document.getElementById('vdl2ToggleBtn').classList.add('active');
document.getElementById('vdl2PanelIndicator').classList.add('active');
startVdl2Stream(false);
}
})
.catch(() => {});
}); });
// ============================================ // ============================================
+185 -9
View File
@@ -52,6 +52,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/aprs.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/modes/aprs.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/tscm.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/modes/tscm.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/analytics.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-cards.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-cards.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-timeline.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-timeline.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/activity-timeline.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/components/activity-timeline.css') }}">
@@ -65,6 +66,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/gps.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/modes/gps.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9"> <link rel="stylesheet" href="{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate2"> <link rel="stylesheet" href="{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate2">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/space-weather.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/function-strip.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/components/function-strip.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}">
@@ -257,6 +259,10 @@
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg></span> <span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg></span>
<span class="mode-name">GPS</span> <span class="mode-name">GPS</span>
</button> </button>
<button class="mode-card mode-card-sm" onclick="selectMode('spaceweather')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span>
<span class="mode-name">Space Wx</span>
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -550,10 +556,14 @@
{% include 'partials/modes/gps.html' %} {% include 'partials/modes/gps.html' %}
{% include 'partials/modes/space-weather.html' %}
{% include 'partials/modes/listening-post.html' %} {% include 'partials/modes/listening-post.html' %}
{% include 'partials/modes/tscm.html' %} {% include 'partials/modes/tscm.html' %}
{% include 'partials/modes/analytics.html' %}
{% include 'partials/modes/ais.html' %} {% include 'partials/modes/ais.html' %}
{% include 'partials/modes/spy-stations.html' %} {% include 'partials/modes/spy-stations.html' %}
@@ -1006,6 +1016,7 @@
<option value="brazil">Brazil (145.570)</option> <option value="brazil">Brazil (145.570)</option>
<option value="japan">Japan (144.640)</option> <option value="japan">Japan (144.640)</option>
<option value="china">China (144.640)</option> <option value="china">China (144.640)</option>
<option value="iss">ISS (145.825)</option>
<option value="custom">Custom Frequency</option> <option value="custom">Custom Frequency</option>
</select> </select>
</div> </div>
@@ -2899,6 +2910,141 @@
</div> </div>
</div> </div>
<!-- Space Weather Dashboard -->
<div id="spaceWeatherVisuals" class="sw-visuals-container" style="display: none;">
<!-- Header metrics strip -->
<div class="sw-header-strip">
<div class="sw-header-stat">
<span class="sw-header-value accent-cyan" id="swStripSfi">--</span>
<span class="sw-header-label">SFI</span>
</div>
<div class="sw-header-stat">
<span class="sw-header-value" id="swStripKp">--</span>
<span class="sw-header-label">Kp</span>
</div>
<div class="sw-header-stat">
<span class="sw-header-value" id="swStripA">--</span>
<span class="sw-header-label">A-Index</span>
</div>
<div class="sw-header-stat">
<span class="sw-header-value" id="swStripSsn">--</span>
<span class="sw-header-label">SSN</span>
</div>
<div class="sw-header-stat">
<span class="sw-header-value" id="swStripWind">--</span>
<span class="sw-header-label">Wind</span>
</div>
<div class="sw-header-stat">
<span class="sw-header-value" id="swStripBz">--</span>
<span class="sw-header-label">Bz</span>
</div>
</div>
<!-- NOAA G/S/R Scales -->
<div class="sw-scales-row">
<div class="sw-scale-card" id="swScaleG">
<div class="sw-scale-label">Geomagnetic</div>
<div class="sw-scale-value sw-scale-0">G0</div>
<div class="sw-scale-desc">Quiet</div>
</div>
<div class="sw-scale-card" id="swScaleS">
<div class="sw-scale-label">Solar Radiation</div>
<div class="sw-scale-value sw-scale-0">S0</div>
<div class="sw-scale-desc">None</div>
</div>
<div class="sw-scale-card" id="swScaleR">
<div class="sw-scale-label">Radio Blackouts</div>
<div class="sw-scale-value sw-scale-0">R0</div>
<div class="sw-scale-desc">None</div>
</div>
</div>
<!-- HF Band Conditions -->
<div class="sw-band-panel">
<div class="sw-band-title">HF Band Conditions</div>
<div class="sw-band-grid" id="swBandGrid">
<div class="sw-loading">Loading band data</div>
</div>
</div>
<!-- Charts row -->
<div class="sw-dashboard-grid">
<div class="sw-chart-card">
<div class="sw-chart-title">Kp Index (3-hourly)</div>
<div class="sw-chart-wrap"><canvas id="swKpChart"></canvas></div>
</div>
<div class="sw-chart-card">
<div class="sw-chart-title">Solar Wind</div>
<div class="sw-chart-wrap"><canvas id="swWindChart"></canvas></div>
</div>
<div class="sw-chart-card">
<div class="sw-chart-title">X-Ray Flux (GOES)</div>
<div class="sw-chart-wrap"><canvas id="swXrayChart"></canvas></div>
</div>
<div class="sw-chart-card">
<div class="sw-chart-title">Flare Probability</div>
<div id="swFlareProb"><div class="sw-loading">Loading</div></div>
</div>
</div>
<!-- Solar imagery gallery -->
<div class="sw-dashboard-grid">
<div class="sw-image-panel">
<div class="sw-chart-title">Solar Imagery (SDO)</div>
<div class="sw-image-tabs">
<button class="sw-image-tab sw-solar-tab active" data-key="sdo_193" onclick="SpaceWeather.selectSolarImage('sdo_193')">193&#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) --> <!-- Device Intelligence Dashboard (above waterfall for prominence) -->
<div class="recon-panel collapsed" id="reconPanel"> <div class="recon-panel collapsed" id="reconPanel">
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;"> <div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
@@ -3024,6 +3170,8 @@
<script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script> <script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script> <script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate2"></script> <script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate2"></script>
<script src="{{ url_for('static', filename='js/modes/analytics.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script>
<script> <script>
// ============================================ // ============================================
@@ -3159,7 +3307,8 @@
const validModes = new Set([ const validModes = new Set([
'pager', 'sensor', 'rtlamr', 'aprs', 'listening', 'pager', 'sensor', 'rtlamr', 'aprs', 'listening',
'spystations', 'meshtastic', 'wifi', 'bluetooth', 'bt_locate', 'spystations', 'meshtastic', 'wifi', 'bluetooth', 'bt_locate',
'tscm', 'satellite', 'sstv', 'weathersat', 'sstv_general', 'gps', 'websdr', 'subghz' 'tscm', 'satellite', 'sstv', 'weathersat', 'sstv_general', 'gps', 'websdr', 'subghz',
'analytics', 'spaceweather'
]); ]);
function getModeFromQuery() { function getModeFromQuery() {
@@ -3616,11 +3765,11 @@
'pager': 'sdr', 'sensor': 'sdr', 'pager': 'sdr', 'sensor': 'sdr',
'aprs': 'sdr', 'listening': 'sdr', 'aprs': 'sdr', 'listening': 'sdr',
'wifi': 'wireless', 'bluetooth': 'wireless', 'bt_locate': 'wireless', 'wifi': 'wireless', 'bluetooth': 'wireless', 'bt_locate': 'wireless',
'tscm': 'security', 'tscm': 'security', 'analytics': 'security',
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr', 'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr',
'meshtastic': 'sdr', 'meshtastic': 'sdr',
'satellite': 'space', 'sstv': 'space', 'weathersat': 'space', 'sstv_general': 'space', 'gps': 'space', 'satellite': 'space', 'sstv': 'space', 'weathersat': 'space', 'sstv_general': 'space', 'gps': 'space',
'subghz': 'sdr' 'spaceweather': 'space', 'subghz': 'sdr'
}; };
// Remove has-active from all dropdowns // Remove has-active from all dropdowns
@@ -3695,7 +3844,8 @@
'pager': 'pager', 'sensor': '433', 'pager': 'pager', 'sensor': '433',
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth', 'bt_locate': 'bt locate', 'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth', 'bt_locate': 'bt locate',
'listening': 'listening', 'aprs': 'aprs', 'tscm': 'tscm', 'meshtastic': 'meshtastic', 'listening': 'listening', 'aprs': 'aprs', 'tscm': 'tscm', 'meshtastic': 'meshtastic',
'dmr': 'dmr', 'websdr': 'websdr', 'sstv_general': 'hf sstv' 'dmr': 'dmr', 'websdr': 'websdr', 'sstv_general': 'hf sstv',
'analytics': 'analytics'
}; };
document.querySelectorAll('.mode-nav-btn').forEach(btn => { document.querySelectorAll('.mode-nav-btn').forEach(btn => {
const label = btn.querySelector('.nav-label'); const label = btn.querySelector('.nav-label');
@@ -3723,6 +3873,8 @@
document.getElementById('dmrMode')?.classList.toggle('active', mode === 'dmr'); document.getElementById('dmrMode')?.classList.toggle('active', mode === 'dmr');
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr'); document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz'); document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz');
document.getElementById('analyticsMode')?.classList.toggle('active', mode === 'analytics');
document.getElementById('spaceWeatherMode')?.classList.toggle('active', mode === 'spaceweather');
const pagerStats = document.getElementById('pagerStats'); const pagerStats = document.getElementById('pagerStats');
@@ -3765,7 +3917,9 @@
'meshtastic': 'MESHTASTIC', 'meshtastic': 'MESHTASTIC',
'dmr': 'DIGITAL VOICE', 'dmr': 'DIGITAL VOICE',
'websdr': 'WEBSDR', 'websdr': 'WEBSDR',
'subghz': 'SUBGHZ' 'subghz': 'SUBGHZ',
'analytics': 'ANALYTICS',
'spaceweather': 'SPACE WX'
}; };
const activeModeIndicator = document.getElementById('activeModeIndicator'); const activeModeIndicator = document.getElementById('activeModeIndicator');
if (activeModeIndicator) activeModeIndicator.innerHTML = '<span class="pulse-dot"></span>' + (modeNames[mode] || mode.toUpperCase()); if (activeModeIndicator) activeModeIndicator.innerHTML = '<span class="pulse-dot"></span>' + (modeNames[mode] || mode.toUpperCase());
@@ -3785,6 +3939,7 @@
const websdrVisuals = document.getElementById('websdrVisuals'); const websdrVisuals = document.getElementById('websdrVisuals');
const subghzVisuals = document.getElementById('subghzVisuals'); const subghzVisuals = document.getElementById('subghzVisuals');
const btLocateVisuals = document.getElementById('btLocateVisuals'); const btLocateVisuals = document.getElementById('btLocateVisuals');
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none'; if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none'; if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none'; if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
@@ -3801,6 +3956,7 @@
if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none'; if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none';
if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none'; if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none';
if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none'; if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none';
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
// Hide sidebar by default for Meshtastic mode, show for others // Hide sidebar by default for Meshtastic mode, show for others
const mainContent = document.querySelector('.main-content'); const mainContent = document.querySelector('.main-content');
@@ -3810,6 +3966,8 @@
} else { } else {
mainContent.classList.remove('mesh-sidebar-hidden'); mainContent.classList.remove('mesh-sidebar-hidden');
} }
// Analytics is sidebar-only — hide output panel and expand sidebar
mainContent.classList.toggle('analytics-active', mode === 'analytics');
} }
// Show/hide mode-specific timeline containers // Show/hide mode-specific timeline containers
@@ -3839,7 +3997,9 @@
'meshtastic': 'Meshtastic Mesh Monitor', 'meshtastic': 'Meshtastic Mesh Monitor',
'dmr': 'Digital Voice Decoder', 'dmr': 'Digital Voice Decoder',
'websdr': 'HF/Shortwave WebSDR', 'websdr': 'HF/Shortwave WebSDR',
'subghz': 'SubGHz Transceiver' 'subghz': 'SubGHz Transceiver',
'analytics': 'Cross-Mode Analytics',
'spaceweather': 'Space Weather Monitor'
}; };
const outputTitle = document.getElementById('outputTitle'); const outputTitle = document.getElementById('outputTitle');
if (outputTitle) outputTitle.textContent = titles[mode] || 'Signal Monitor'; if (outputTitle) outputTitle.textContent = titles[mode] || 'Signal Monitor';
@@ -3853,11 +4013,25 @@
refreshTscmDevices(); refreshTscmDevices();
} }
// Initialize/destroy Analytics mode
if (mode === 'analytics') {
// Expand all analytics sections (sidebar sections default to collapsed)
document.querySelectorAll('#analyticsMode .section.collapsed').forEach(s => s.classList.remove('collapsed'));
if (typeof Analytics !== 'undefined') Analytics.init();
} else {
if (typeof Analytics !== 'undefined' && Analytics.destroy) Analytics.destroy();
}
// Initialize/destroy Space Weather mode
if (mode !== 'spaceweather') {
if (typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy) SpaceWeather.destroy();
}
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm) // Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm)
const reconBtn = document.getElementById('reconBtn'); const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]'); const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
const reconPanel = document.getElementById('reconPanel'); const reconPanel = document.getElementById('reconPanel');
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz') { if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics' || mode === 'spaceweather') {
if (reconPanel) reconPanel.style.display = 'none'; if (reconPanel) reconPanel.style.display = 'none';
if (reconBtn) reconBtn.style.display = 'none'; if (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none'; if (intelBtn) intelBtn.style.display = 'none';
@@ -3895,8 +4069,8 @@
// Hide output console for modes with their own visualizations // Hide output console for modes with their own visualizations
const outputEl = document.getElementById('output'); const outputEl = document.getElementById('output');
const statusBar = document.querySelector('.status-bar'); const statusBar = document.querySelector('.status-bar');
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz') ? 'none' : 'block'; if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics' || mode === 'spaceweather') ? 'none' : 'block';
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'dmr' || mode === 'subghz') ? 'none' : 'flex'; if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'dmr' || mode === 'subghz' || mode === 'spaceweather') ? 'none' : 'flex';
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it) // Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
if (mode !== 'meshtastic') { if (mode !== 'meshtastic') {
@@ -3962,6 +4136,8 @@
SubGhz.init(); SubGhz.init();
} else if (mode === 'bt_locate') { } else if (mode === 'bt_locate') {
BtLocate.init(); BtLocate.init();
} else if (mode === 'spaceweather') {
SpaceWeather.init();
} }
} }
+11
View File
@@ -57,6 +57,7 @@
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"><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"><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="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>
</div> </div>
@@ -197,6 +198,15 @@
<li>Useful for evaluating GNSS reception and interference</li> <li>Useful for evaluating GNSS reception and interference</li>
</ul> </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> <h3>WiFi Mode</h3>
<ul class="tip-list"> <ul class="tip-list">
<li>Requires a WiFi adapter capable of monitor mode</li> <li>Requires a WiFi adapter capable of monitor mode</li>
@@ -330,6 +340,7 @@
<li><strong>Weather Sat:</strong> RTL-SDR, SatDump</li> <li><strong>Weather Sat:</strong> RTL-SDR, SatDump</li>
<li><strong>HF SSTV:</strong> RTL-SDR or SoapySDR-compatible hardware, slowrx</li> <li><strong>HF SSTV:</strong> RTL-SDR or SoapySDR-compatible hardware, slowrx</li>
<li><strong>GPS:</strong> RTL-SDR or GPS-capable SDR</li> <li><strong>GPS:</strong> RTL-SDR or GPS-capable SDR</li>
<li><strong>Space Weather:</strong> Internet connection (public APIs)</li>
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li> <li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>
<li><strong>Bluetooth:</strong> Bluetooth adapter, bluez (hcitool/bluetoothctl)</li> <li><strong>Bluetooth:</strong> Bluetooth adapter, bluez (hcitool/bluetoothctl)</li>
<li><strong>BT Locate:</strong> Bluetooth adapter, bluez</li> <li><strong>BT Locate:</strong> Bluetooth adapter, bluez</li>
+3 -3
View File
@@ -56,8 +56,8 @@
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong> <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;"> <table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid var(--border-color);"> <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-dim);">Primary (N. America)</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">131.550 MHz</td> <td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">131.725 / 131.825 MHz</td>
</tr> </tr>
<tr style="border-bottom: 1px solid var(--border-color);"> <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-dim);">Quarter-wave length</td>
@@ -89,7 +89,7 @@
let acarsMainMsgCount = 0; let acarsMainMsgCount = 0;
const acarsMainFrequencies = { const acarsMainFrequencies = {
'na': ['129.125', '130.025', '130.450', '131.550'], 'na': ['131.725', '131.825'],
'eu': ['131.525', '131.725', '131.550'], 'eu': ['131.525', '131.725', '131.550'],
'ap': ['131.550', '131.450'] 'ap': ['131.550', '131.450']
}; };
+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
View File
@@ -103,6 +103,7 @@
<div class="mode-nav-dropdown-menu"> <div class="mode-nav-dropdown-menu">
{{ mode_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }} {{ mode_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
{{ mode_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
</div> </div>
</div> </div>
@@ -124,6 +125,7 @@
{{ mode_item('weathersat', 'Weather Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }} {{ mode_item('weathersat', 'Weather Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mode_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }} {{ mode_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
{{ mode_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }} {{ mode_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
{{ mode_item('spaceweather', 'Space Weather', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>') }}
</div> </div>
</div> </div>
@@ -187,6 +189,7 @@
{{ mobile_item('bluetooth', 'BT', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }} {{ mobile_item('bluetooth', 'BT', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }}
{{ mobile_item('bt_locate', 'Locate', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }} {{ mobile_item('bt_locate', 'Locate', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }} {{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
{{ mobile_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
{% if is_index_page %} {% if is_index_page %}
{{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>') }} {{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>') }}
{% else %} {% else %}
@@ -196,6 +199,7 @@
{{ mobile_item('weathersat', 'WxSat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }} {{ mobile_item('weathersat', 'WxSat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{{ mobile_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }} {{ mobile_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
{{ mobile_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }} {{ mobile_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
{{ mobile_item('spaceweather', 'SpaceWx', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/></svg>') }}
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }} {{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }} {{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }} {{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
+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
+40
View File
@@ -306,6 +306,9 @@ class MeshtasticClient:
self._range_test_running: bool = False self._range_test_running: bool = False
self._range_test_results: list[dict] = [] self._range_test_results: list[dict] = []
# Topology tracking: node_id -> {neighbors, hop_count, msg_count, last_seen}
self._topology: dict[str, dict] = {}
@property @property
def is_running(self) -> bool: def is_running(self) -> bool:
return self._running return self._running
@@ -326,6 +329,35 @@ class MeshtasticClient:
"""Set callback for received messages.""" """Set callback for received messages."""
self._callback = callback self._callback = callback
def record_message_route(self, from_node: str, to_node: str, hops: int | None = None) -> None:
"""Record a message route for topology tracking."""
now = datetime.now(timezone.utc).isoformat()
for node_id in (from_node, to_node):
if node_id not in self._topology:
self._topology[node_id] = {
'neighbors': set(),
'hop_count': hops,
'msg_count': 0,
'last_seen': now,
}
entry = self._topology[node_id]
entry['msg_count'] += 1
entry['last_seen'] = now
self._topology[from_node]['neighbors'].add(to_node)
self._topology[to_node]['neighbors'].add(from_node)
def get_topology(self) -> dict:
"""Return topology dict with serializable sets."""
result = {}
for node_id, data in self._topology.items():
result[node_id] = {
'neighbors': list(data.get('neighbors', set())),
'hop_count': data.get('hop_count'),
'msg_count': data.get('msg_count', 0),
'last_seen': data.get('last_seen'),
}
return result
def connect(self, device: str | None = None, connection_type: str = 'serial', def connect(self, device: str | None = None, connection_type: str = 'serial',
hostname: str | None = None) -> bool: hostname: str | None = None) -> bool:
""" """
@@ -463,6 +495,14 @@ class MeshtasticClient:
# Track node from packet (always, even for filtered messages) # Track node from packet (always, even for filtered messages)
self._track_node_from_packet(packet, decoded, portnum) self._track_node_from_packet(packet, decoded, portnum)
# Record topology route
if from_num and to_num:
self.record_message_route(
self._format_node_id(from_num),
self._format_node_id(to_num),
packet.get('hopLimit'),
)
# Parse traceroute responses # Parse traceroute responses
if portnum == 'TRACEROUTE_APP': if portnum == 'TRACEROUTE_APP':
self._handle_traceroute_response(packet, decoded) self._handle_traceroute_response(packet, decoded)
+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._process: subprocess.Popen | None = None
self._running = False self._running = False
self._lock = threading.Lock() self._lock = threading.Lock()
self._pty_lock = threading.Lock()
self._images_lock = threading.Lock() self._images_lock = threading.Lock()
self._stop_event = threading.Event()
self._callback: Callable[[CaptureProgress], None] | None = None self._callback: Callable[[CaptureProgress], None] | None = None
self._output_dir = Path(output_dir) if output_dir else Path('data/weather_sat') self._output_dir = Path(output_dir) if output_dir else Path('data/weather_sat')
self._images: list[WeatherSatImage] = [] self._images: list[WeatherSatImage] = []
@@ -212,6 +214,16 @@ class WeatherSatDecoder:
) )
return None return None
def _close_pty(self) -> None:
"""Close the PTY master fd in a thread-safe manner."""
with self._pty_lock:
if self._pty_master_fd is not None:
try:
os.close(self._pty_master_fd)
except OSError:
pass
self._pty_master_fd = None
def set_callback(self, callback: Callable[[CaptureProgress], None]) -> None: def set_callback(self, callback: Callable[[CaptureProgress], None]) -> None:
"""Set callback for capture progress updates.""" """Set callback for capture progress updates."""
self._callback = callback self._callback = callback
@@ -292,6 +304,7 @@ class WeatherSatDecoder:
self._current_mode = sat_info['mode'] self._current_mode = sat_info['mode']
self._capture_start_time = time.time() self._capture_start_time = time.time()
self._capture_phase = 'decoding' self._capture_phase = 'decoding'
self._stop_event.clear()
try: try:
self._running = True self._running = True
@@ -372,6 +385,7 @@ class WeatherSatDecoder:
self._device_index = device_index self._device_index = device_index
self._capture_start_time = time.time() self._capture_start_time = time.time()
self._capture_phase = 'tuning' self._capture_phase = 'tuning'
self._stop_event.clear()
try: try:
self._running = True self._running = True
@@ -781,13 +795,11 @@ class WeatherSatDecoder:
except Exception as e: except Exception as e:
logger.error(f"Error reading SatDump output: {e}") logger.error(f"Error reading SatDump output: {e}")
finally: finally:
# Close PTY master fd # Close PTY master fd (thread-safe)
if self._pty_master_fd is not None: self._close_pty()
try:
os.close(self._pty_master_fd) # Signal watcher thread to do final scan and exit
except OSError: self._stop_event.set()
pass
self._pty_master_fd = None
# Process ended — release resources # Process ended — release resources
was_running = self._running was_running = self._running
@@ -795,17 +807,38 @@ class WeatherSatDecoder:
elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0 elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0
if was_running: if was_running:
self._capture_phase = 'complete' # Collect exit status (returncode is only set after poll/wait)
self._emit_progress(CaptureProgress( if self._process and self._process.returncode is None:
status='complete', try:
satellite=self._current_satellite, self._process.wait(timeout=5)
frequency=self._current_frequency, except subprocess.TimeoutExpired:
mode=self._current_mode, self._process.kill()
message=f"Capture complete ({elapsed}s)", self._process.wait()
elapsed_seconds=elapsed, retcode = self._process.returncode if self._process else None
log_type='info', if retcode and retcode != 0:
capture_phase='complete', self._capture_phase = 'error'
)) self._emit_progress(CaptureProgress(
status='error',
satellite=self._current_satellite,
frequency=self._current_frequency,
mode=self._current_mode,
message=f"SatDump crashed (exit code {retcode}). Check SatDump installation and SDR device.",
elapsed_seconds=elapsed,
log_type='error',
capture_phase='error',
))
else:
self._capture_phase = 'complete'
self._emit_progress(CaptureProgress(
status='complete',
satellite=self._current_satellite,
frequency=self._current_frequency,
mode=self._current_mode,
message=f"Capture complete ({elapsed}s)",
elapsed_seconds=elapsed,
log_type='info',
capture_phase='complete',
))
# Notify route layer to release SDR device # Notify route layer to release SDR device
if self._on_complete_callback: if self._on_complete_callback:
@@ -822,63 +855,79 @@ class WeatherSatDecoder:
known_files: set[str] = set() known_files: set[str] = set()
while self._running: while self._running:
time.sleep(2) self._scan_output_dir(known_files)
# Use stop_event for faster wakeup on process exit
if self._stop_event.wait(timeout=2):
break
try: # Final scan — SatDump writes images at the end of processing,
# Recursively scan for image files # often after the process has already exited. Do multiple scans
for ext in ('*.png', '*.jpg', '*.jpeg'): # with a short delay to catch late-written files.
for filepath in self._capture_output_dir.rglob(ext): for _ in range(3):
file_key = str(filepath) time.sleep(0.5)
if file_key in known_files: self._scan_output_dir(known_files)
def _scan_output_dir(self, known_files: set[str]) -> None:
"""Scan capture output directory for new image files."""
if not self._capture_output_dir:
return
try:
# Recursively scan for image files
for ext in ('*.png', '*.jpg', '*.jpeg'):
for filepath in self._capture_output_dir.rglob(ext):
file_key = str(filepath)
if file_key in known_files:
continue
# Skip tiny files (likely incomplete)
try:
stat = filepath.stat()
if stat.st_size < 1000:
continue continue
except OSError:
continue
# Skip tiny files (likely incomplete) # Determine product type from filename/path
try: product = self._parse_product_name(filepath)
stat = filepath.stat()
if stat.st_size < 1000:
continue
except OSError:
continue
known_files.add(file_key) # Copy image to main output dir for serving
serve_name = f"{self._current_satellite}_{filepath.stem}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
serve_path = self._output_dir / serve_name
try:
shutil.copy2(filepath, serve_path)
except OSError:
# Copy failed — don't mark as known so it can be retried
continue
# Determine product type from filename/path # Only mark as known after successful copy
product = self._parse_product_name(filepath) known_files.add(file_key)
# Copy image to main output dir for serving image = WeatherSatImage(
serve_name = f"{self._current_satellite}_{filepath.stem}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png" filename=serve_name,
serve_path = self._output_dir / serve_name path=serve_path,
try: satellite=self._current_satellite,
shutil.copy2(filepath, serve_path) mode=self._current_mode,
except OSError: timestamp=datetime.now(timezone.utc),
serve_path = filepath frequency=self._current_frequency,
serve_name = filepath.name size_bytes=stat.st_size,
product=product,
)
with self._images_lock:
self._images.append(image)
image = WeatherSatImage( logger.info(f"New weather satellite image: {serve_name} ({product})")
filename=serve_name, self._emit_progress(CaptureProgress(
path=serve_path, status='complete',
satellite=self._current_satellite, satellite=self._current_satellite,
mode=self._current_mode, frequency=self._current_frequency,
timestamp=datetime.now(timezone.utc), mode=self._current_mode,
frequency=self._current_frequency, message=f'Image decoded: {product}',
size_bytes=stat.st_size, image=image,
product=product, ))
)
with self._images_lock:
self._images.append(image)
logger.info(f"New weather satellite image: {serve_name} ({product})") except Exception as e:
self._emit_progress(CaptureProgress( logger.error(f"Error scanning for images: {e}")
status='complete',
satellite=self._current_satellite,
frequency=self._current_frequency,
mode=self._current_mode,
message=f'Image decoded: {product}',
image=image,
))
except Exception as e:
logger.error(f"Error watching images: {e}")
def _parse_product_name(self, filepath: Path) -> str: def _parse_product_name(self, filepath: Path) -> str:
"""Parse a human-readable product name from the image filepath.""" """Parse a human-readable product name from the image filepath."""
@@ -916,13 +965,8 @@ class WeatherSatDecoder:
"""Stop weather satellite capture.""" """Stop weather satellite capture."""
with self._lock: with self._lock:
self._running = False self._running = False
self._stop_event.set()
if self._pty_master_fd is not None: self._close_pty()
try:
os.close(self._pty_master_fd)
except OSError:
pass
self._pty_master_fd = None
if self._process: if self._process:
safe_terminate(self._process) safe_terminate(self._process)