mirror of
https://github.com/smittix/intercept.git
synced 2026-06-13 08:13:32 -07:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16239c1d31 | |||
| cae7a0586f | |||
| 23f28a8102 | |||
| 34ecec3800 | |||
| d40bd37406 | |||
| 4ed41434e2 | |||
| 6a0b54fa0e | |||
| b83ecfcc19 | |||
| 671bf38083 | |||
| 0f5a414a09 | |||
| 831426948f | |||
| df2c0a0d25 |
@@ -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
|
||||
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
|
||||
- **Meshtastic** - LoRa mesh network integration
|
||||
- **Space Weather** - Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL (no SDR required)
|
||||
- **Spy Stations** - Number stations and diplomatic HF network database
|
||||
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
||||
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
||||
|
||||
@@ -7,10 +7,21 @@ import os
|
||||
import sys
|
||||
|
||||
# Application version
|
||||
VERSION = "2.19.0"
|
||||
VERSION = "2.20.0"
|
||||
|
||||
# Changelog - latest release notes (shown on welcome screen)
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "2.20.0",
|
||||
"date": "February 2026",
|
||||
"highlights": [
|
||||
"Space Weather mode: real-time solar and geomagnetic monitoring from NOAA SWPC, NASA SDO, and HamQSL",
|
||||
"Kp index, solar wind, X-ray flux charts with Chart.js visualization",
|
||||
"HF band conditions, D-RAP absorption maps, aurora forecast, and solar imagery",
|
||||
"NOAA Space Weather Scales (G/S/R), flare probability, and active solar regions",
|
||||
"No SDR hardware required — all data from public APIs with server-side caching",
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "2.19.0",
|
||||
"date": "February 2026",
|
||||
|
||||
@@ -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
|
||||
- **Multiple meter protocol support** via rtl_tcp integration
|
||||
|
||||
## Space Weather
|
||||
|
||||
- **Real-time solar indices** - Solar Flux Index (SFI), Kp index, A-index, sunspot number
|
||||
- **NOAA Space Weather Scales** - Geomagnetic storms (G), solar radiation (S), radio blackouts (R)
|
||||
- **HF band conditions** - Day/night propagation from HamQSL for 80m through 10m bands
|
||||
- **Solar wind monitoring** - Speed, density, and IMF Bz from DSCOVR satellite
|
||||
- **X-ray flux chart** - GOES X-ray data with flare class scale (A/B/C/M/X)
|
||||
- **Flare probability** - 1-day and 3-day C/M/X-class flare forecasts
|
||||
- **Solar imagery** - NASA SDO 193A, 304A, and magnetogram images
|
||||
- **D-RAP absorption maps** - HF radio absorption at 5-30 MHz frequency bands
|
||||
- **Aurora forecast** - OVATION aurora oval visualization
|
||||
- **SWPC alerts** - Real-time space weather alerts and warnings
|
||||
- **Active solar regions** - Current sunspot region data with location and area
|
||||
- **Auto-refresh** - 5-minute polling with manual refresh option
|
||||
- **No SDR required** - Data fetched from NOAA SWPC, NASA SDO, and HamQSL public APIs
|
||||
|
||||
## Satellite Tracking
|
||||
|
||||
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
||||
|
||||
+13
-4
@@ -206,14 +206,23 @@ Extended base for full-screen dashboards (maps, visualizations).
|
||||
| `listening` | Listening post |
|
||||
| `spystations` | Spy stations |
|
||||
| `meshtastic` | Mesh networking |
|
||||
| `weathersat` | Weather satellites |
|
||||
| `sstv_general` | HF SSTV |
|
||||
| `gps` | GPS tracking |
|
||||
| `websdr` | WebSDR |
|
||||
| `subghz` | Sub-GHz analyzer |
|
||||
| `bt_locate` | BT Locate |
|
||||
| `analytics` | Analytics dashboard |
|
||||
| `spaceweather` | Space weather |
|
||||
| `dmr` | DMR/P25 digital voice |
|
||||
|
||||
### Navigation Groups
|
||||
|
||||
The navigation is organized into groups:
|
||||
- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic
|
||||
- **Wireless**: WiFi, Bluetooth
|
||||
- **Security**: TSCM
|
||||
- **Space**: Satellite, ISS SSTV
|
||||
- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic, WebSDR, SubGHz
|
||||
- **Wireless**: WiFi, Bluetooth, BT Locate
|
||||
- **Security**: TSCM, Analytics
|
||||
- **Space**: Satellite, ISS SSTV, Weather Sat, HF SSTV, GPS, Space Weather
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -239,6 +239,25 @@ Enable the auto-scheduler to automatically capture passes:
|
||||
- Starts SatDump at the correct time and frequency
|
||||
- Decoded images are saved with timestamps
|
||||
|
||||
## Space Weather
|
||||
|
||||
1. **Switch to Space Weather mode** - Select "Space Weather" from the Space navigation group
|
||||
2. **View Dashboard** - Solar indices, NOAA scales, band conditions, and charts load automatically
|
||||
3. **Solar Imagery** - Toggle between SDO 193A, 304A, and Magnetogram views
|
||||
4. **D-RAP Maps** - Select frequency (5-30 MHz) to view HF radio absorption maps
|
||||
5. **Aurora Forecast** - View the OVATION aurora oval for the northern hemisphere
|
||||
6. **Alerts** - Review current SWPC space weather alerts and warnings
|
||||
7. **Active Regions** - View solar active region data (number, location, area)
|
||||
8. **Refresh** - Data auto-refreshes every 5 minutes, or click "Refresh Now"
|
||||
|
||||
### Tips
|
||||
|
||||
- No SDR hardware required — all data comes from public APIs (NOAA SWPC, NASA SDO, HamQSL)
|
||||
- Check HF band conditions before operating on shortwave frequencies
|
||||
- Kp >= 5 indicates geomagnetic storm conditions that may affect HF propagation
|
||||
- D-RAP maps show where HF absorption is highest — useful for path planning
|
||||
- Solar imagery updates approximately every 15 minutes from NASA SDO
|
||||
|
||||
## AIS Vessel Tracking
|
||||
|
||||
1. **Select Hardware** - Choose your SDR type
|
||||
|
||||
@@ -156,6 +156,11 @@
|
||||
<h3>GPS Tracking</h3>
|
||||
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="space">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></div>
|
||||
<h3>Space Weather</h3>
|
||||
<p>Real-time solar and geomagnetic monitoring. Kp index, HF band conditions, solar imagery, D-RAP maps, and aurora forecasts from NOAA SWPC.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="wireless">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/></svg></div>
|
||||
<h3>WiFi Scanning</h3>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "intercept"
|
||||
version = "2.19.0"
|
||||
version = "2.20.0"
|
||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -35,6 +35,8 @@ def register_blueprints(app):
|
||||
from .recordings import recordings_bp
|
||||
from .subghz import subghz_bp
|
||||
from .bt_locate import bt_locate_bp
|
||||
from .analytics import analytics_bp
|
||||
from .space_weather import space_weather_bp
|
||||
|
||||
app.register_blueprint(pager_bp)
|
||||
app.register_blueprint(sensor_bp)
|
||||
@@ -69,6 +71,8 @@ def register_blueprints(app):
|
||||
app.register_blueprint(recordings_bp) # Session recordings
|
||||
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
|
||||
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
|
||||
app.register_blueprint(analytics_bp) # Cross-mode analytics dashboard
|
||||
app.register_blueprint(space_weather_bp) # Space weather monitoring
|
||||
|
||||
# Initialize TSCM state with queue and lock from app
|
||||
import app as app_module
|
||||
|
||||
+10
-6
@@ -35,11 +35,8 @@ acars_bp = Blueprint('acars', __name__, url_prefix='/acars')
|
||||
|
||||
# Default VHF ACARS frequencies (MHz) - common worldwide
|
||||
DEFAULT_ACARS_FREQUENCIES = [
|
||||
'131.550', # Primary worldwide
|
||||
'130.025', # Secondary USA/Canada
|
||||
'129.125', # USA
|
||||
'131.525', # Europe
|
||||
'131.725', # Europe secondary
|
||||
'131.725', # North America
|
||||
'131.825', # North America
|
||||
]
|
||||
|
||||
# Message counter for statistics
|
||||
@@ -129,6 +126,13 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) -
|
||||
|
||||
app_module.acars_queue.put(data)
|
||||
|
||||
# Feed flight correlator
|
||||
try:
|
||||
from utils.flight_correlator import get_flight_correlator
|
||||
get_flight_correlator().add_acars_message(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
@@ -440,7 +444,7 @@ def get_frequencies() -> Response:
|
||||
return jsonify({
|
||||
'default': DEFAULT_ACARS_FREQUENCIES,
|
||||
'regions': {
|
||||
'north_america': ['129.125', '130.025', '130.450', '131.550'],
|
||||
'north_america': ['131.725', '131.825'],
|
||||
'europe': ['131.525', '131.725', '131.550'],
|
||||
'asia_pacific': ['131.550', '131.450'],
|
||||
}
|
||||
|
||||
@@ -439,6 +439,12 @@ def parse_sbs_stream(service_addr):
|
||||
if parts[16]:
|
||||
try:
|
||||
aircraft['vertical_rate'] = int(float(parts[16]))
|
||||
if abs(aircraft['vertical_rate']) > 4000:
|
||||
process_event('adsb', {
|
||||
'type': 'vertical_rate_anomaly', 'icao': icao,
|
||||
'callsign': aircraft.get('callsign', ''),
|
||||
'vertical_rate': aircraft['vertical_rate'],
|
||||
}, 'vertical_rate_anomaly')
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
@@ -456,6 +462,14 @@ def parse_sbs_stream(service_addr):
|
||||
elif msg_type == '6' and len(parts) > 17:
|
||||
if parts[17]:
|
||||
aircraft['squawk'] = parts[17]
|
||||
sq = parts[17].strip()
|
||||
_EMERGENCY_SQUAWKS = {'7700': 'General Emergency', '7600': 'Comms Failure', '7500': 'Hijack'}
|
||||
if sq in _EMERGENCY_SQUAWKS:
|
||||
process_event('adsb', {
|
||||
'type': 'squawk_emergency', 'icao': icao,
|
||||
'callsign': aircraft.get('callsign', ''),
|
||||
'squawk': sq, 'meaning': _EMERGENCY_SQUAWKS[sq],
|
||||
}, 'squawk_emergency')
|
||||
|
||||
app_module.adsb_aircraft.set(icao, aircraft)
|
||||
pending_updates.add(icao)
|
||||
@@ -488,6 +502,19 @@ def parse_sbs_stream(service_addr):
|
||||
'source_host': service_addr,
|
||||
'snapshot': snapshot,
|
||||
})
|
||||
# Geofence check
|
||||
_gf_lat = snapshot.get('lat')
|
||||
_gf_lon = snapshot.get('lon')
|
||||
if _gf_lat and _gf_lon:
|
||||
try:
|
||||
from utils.geofence import get_geofence_manager
|
||||
for _gf_evt in get_geofence_manager().check_position(
|
||||
update_icao, 'aircraft', _gf_lat, _gf_lon,
|
||||
{'callsign': snapshot.get('callsign'), 'altitude': snapshot.get('altitude')}
|
||||
):
|
||||
process_event('adsb', _gf_evt, 'geofence')
|
||||
except Exception:
|
||||
pass
|
||||
pending_updates.clear()
|
||||
last_update = now
|
||||
|
||||
@@ -1103,3 +1130,17 @@ def aircraft_photo(registration: str):
|
||||
except Exception as e:
|
||||
logger.debug(f"Error fetching aircraft photo: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@adsb_bp.route('/aircraft/<icao>/messages')
|
||||
def get_aircraft_messages(icao: str):
|
||||
"""Get correlated ACARS/VDL2 messages for an aircraft."""
|
||||
if not icao or not all(c in '0123456789ABCDEFabcdef' for c in icao):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid ICAO'}), 400
|
||||
|
||||
aircraft = app_module.adsb_aircraft.get(icao.upper())
|
||||
callsign = aircraft.get('callsign') if aircraft else None
|
||||
|
||||
from utils.flight_correlator import get_flight_correlator
|
||||
messages = get_flight_correlator().get_messages_for_aircraft(icao=icao.upper(), callsign=callsign)
|
||||
return jsonify({'status': 'success', 'icao': icao.upper(), **messages})
|
||||
|
||||
+42
-1
@@ -124,13 +124,27 @@ def parse_ais_stream(port: int):
|
||||
if now - last_update >= AIS_UPDATE_INTERVAL:
|
||||
for mmsi in pending_updates:
|
||||
if mmsi in app_module.ais_vessels:
|
||||
_vessel_snap = app_module.ais_vessels[mmsi]
|
||||
try:
|
||||
app_module.ais_queue.put_nowait({
|
||||
'type': 'vessel',
|
||||
**app_module.ais_vessels[mmsi]
|
||||
**_vessel_snap
|
||||
})
|
||||
except queue.Full:
|
||||
pass
|
||||
# Geofence check
|
||||
_v_lat = _vessel_snap.get('lat')
|
||||
_v_lon = _vessel_snap.get('lon')
|
||||
if _v_lat and _v_lon:
|
||||
try:
|
||||
from utils.geofence import get_geofence_manager
|
||||
for _gf_evt in get_geofence_manager().check_position(
|
||||
mmsi, 'vessel', _v_lat, _v_lon,
|
||||
{'name': _vessel_snap.get('name'), 'ship_type': _vessel_snap.get('ship_type_text')}
|
||||
):
|
||||
process_event('ais', _gf_evt, 'geofence')
|
||||
except Exception:
|
||||
pass
|
||||
pending_updates.clear()
|
||||
last_update = now
|
||||
|
||||
@@ -282,6 +296,16 @@ def process_ais_message(msg: dict) -> dict | None:
|
||||
# Timestamp
|
||||
vessel['last_seen'] = time.time()
|
||||
|
||||
# Check for DSC DISTRESS matching this MMSI
|
||||
try:
|
||||
for _dsc_key, _dsc_msg in app_module.dsc_messages.items():
|
||||
if (str(_dsc_msg.get('source_mmsi', '')) == mmsi
|
||||
and _dsc_msg.get('category', '').upper() == 'DISTRESS'):
|
||||
vessel['dsc_distress'] = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return vessel
|
||||
|
||||
|
||||
@@ -502,6 +526,23 @@ def stream_ais():
|
||||
return response
|
||||
|
||||
|
||||
@ais_bp.route('/vessel/<mmsi>/dsc')
|
||||
def get_vessel_dsc(mmsi: str):
|
||||
"""Get DSC messages associated with a vessel MMSI."""
|
||||
if not mmsi or not mmsi.isdigit():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid MMSI'}), 400
|
||||
|
||||
matches = []
|
||||
try:
|
||||
for key, msg in app_module.dsc_messages.items():
|
||||
if str(msg.get('source_mmsi', '')) == mmsi:
|
||||
matches.append(dict(msg))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({'status': 'success', 'mmsi': mmsi, 'dsc_messages': matches})
|
||||
|
||||
|
||||
@ais_bp.route('/dashboard')
|
||||
def ais_dashboard():
|
||||
"""Popout AIS dashboard."""
|
||||
|
||||
@@ -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
@@ -19,16 +19,16 @@ from typing import Generator, Optional
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import validate_device_index, validate_gain, validate_ppm
|
||||
from utils.sse import format_sse
|
||||
from utils.event_pipeline import process_event
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
)
|
||||
|
||||
aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs')
|
||||
@@ -47,6 +47,7 @@ APRS_FREQUENCIES = {
|
||||
'brazil': '145.570',
|
||||
'japan': '144.640',
|
||||
'china': '144.640',
|
||||
'iss': '145.825',
|
||||
}
|
||||
|
||||
# Statistics
|
||||
@@ -73,19 +74,19 @@ def find_multimon_ng() -> Optional[str]:
|
||||
return shutil.which('multimon-ng')
|
||||
|
||||
|
||||
def find_rtl_fm() -> Optional[str]:
|
||||
"""Find rtl_fm binary."""
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def find_rx_fm() -> Optional[str]:
|
||||
"""Find SoapySDR rx_fm binary."""
|
||||
return shutil.which('rx_fm')
|
||||
|
||||
|
||||
def find_rtl_power() -> Optional[str]:
|
||||
"""Find rtl_power binary for spectrum scanning."""
|
||||
return shutil.which('rtl_power')
|
||||
def find_rtl_fm() -> Optional[str]:
|
||||
"""Find rtl_fm binary."""
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def find_rx_fm() -> Optional[str]:
|
||||
"""Find SoapySDR rx_fm binary."""
|
||||
return shutil.which('rx_fm')
|
||||
|
||||
|
||||
def find_rtl_power() -> Optional[str]:
|
||||
"""Find rtl_power binary for spectrum scanning."""
|
||||
return shutil.which('rtl_power')
|
||||
|
||||
|
||||
# Path to direwolf config file
|
||||
@@ -1378,6 +1379,19 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
'last_seen': packet.get('timestamp'),
|
||||
'packet_type': packet.get('packet_type'),
|
||||
}
|
||||
# Geofence check
|
||||
_aprs_lat = packet.get('lat')
|
||||
_aprs_lon = packet.get('lon')
|
||||
if _aprs_lat and _aprs_lon:
|
||||
try:
|
||||
from utils.geofence import get_geofence_manager
|
||||
for _gf_evt in get_geofence_manager().check_position(
|
||||
callsign, 'aprs_station', _aprs_lat, _aprs_lon,
|
||||
{'callsign': callsign}
|
||||
):
|
||||
process_event('aprs', _gf_evt, 'geofence')
|
||||
except Exception:
|
||||
pass
|
||||
# Evict oldest stations when limit is exceeded
|
||||
if len(aprs_stations) > APRS_MAX_STATIONS:
|
||||
oldest = min(
|
||||
@@ -1420,22 +1434,22 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces
|
||||
|
||||
|
||||
@aprs_bp.route('/tools')
|
||||
def check_aprs_tools() -> Response:
|
||||
"""Check for APRS decoding tools."""
|
||||
has_rtl_fm = find_rtl_fm() is not None
|
||||
has_rx_fm = find_rx_fm() is not None
|
||||
has_direwolf = find_direwolf() is not None
|
||||
has_multimon = find_multimon_ng() is not None
|
||||
has_fm_demod = has_rtl_fm or has_rx_fm
|
||||
|
||||
return jsonify({
|
||||
'rtl_fm': has_rtl_fm,
|
||||
'rx_fm': has_rx_fm,
|
||||
'direwolf': has_direwolf,
|
||||
'multimon_ng': has_multimon,
|
||||
'ready': has_fm_demod and (has_direwolf or has_multimon),
|
||||
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
|
||||
})
|
||||
def check_aprs_tools() -> Response:
|
||||
"""Check for APRS decoding tools."""
|
||||
has_rtl_fm = find_rtl_fm() is not None
|
||||
has_rx_fm = find_rx_fm() is not None
|
||||
has_direwolf = find_direwolf() is not None
|
||||
has_multimon = find_multimon_ng() is not None
|
||||
has_fm_demod = has_rtl_fm or has_rx_fm
|
||||
|
||||
return jsonify({
|
||||
'rtl_fm': has_rtl_fm,
|
||||
'rx_fm': has_rx_fm,
|
||||
'direwolf': has_direwolf,
|
||||
'multimon_ng': has_multimon,
|
||||
'ready': has_fm_demod and (has_direwolf or has_multimon),
|
||||
'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None)
|
||||
})
|
||||
|
||||
|
||||
@aprs_bp.route('/status')
|
||||
@@ -1476,12 +1490,12 @@ def start_aprs() -> Response:
|
||||
'message': 'APRS decoder already running'
|
||||
}), 409
|
||||
|
||||
# Check for decoder (prefer direwolf, fallback to multimon-ng)
|
||||
direwolf_path = find_direwolf()
|
||||
multimon_path = find_multimon_ng()
|
||||
|
||||
if not direwolf_path and not multimon_path:
|
||||
return jsonify({
|
||||
# Check for decoder (prefer direwolf, fallback to multimon-ng)
|
||||
direwolf_path = find_direwolf()
|
||||
multimon_path = find_multimon_ng()
|
||||
|
||||
if not direwolf_path and not multimon_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'No APRS decoder found. Install direwolf or multimon-ng'
|
||||
}), 400
|
||||
@@ -1489,31 +1503,31 @@ def start_aprs() -> Response:
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
gain = validate_gain(data.get('gain', '40'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
if find_rtl_fm() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
|
||||
}), 400
|
||||
else:
|
||||
if find_rx_fm() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
|
||||
}), 400
|
||||
try:
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
gain = validate_gain(data.get('gain', '40'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
if find_rtl_fm() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr'
|
||||
}), 400
|
||||
else:
|
||||
if find_rx_fm() is None:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.'
|
||||
}), 400
|
||||
|
||||
# Reserve SDR device to prevent conflicts with other modes
|
||||
error = app_module.claim_sdr_device(device, 'aprs')
|
||||
@@ -1545,29 +1559,29 @@ def start_aprs() -> Response:
|
||||
aprs_last_packet_time = None
|
||||
aprs_stations = {}
|
||||
|
||||
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
|
||||
try:
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
rtl_cmd = builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=float(frequency),
|
||||
sample_rate=22050,
|
||||
gain=float(gain) if gain and str(gain) != '0' else None,
|
||||
ppm=int(ppm) if ppm and str(ppm) != '0' else None,
|
||||
modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm',
|
||||
squelch=None,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
|
||||
if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-':
|
||||
# APRS benefits from DC blocking + fast AGC on rtl_fm.
|
||||
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
|
||||
except Exception as e:
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
aprs_active_device = None
|
||||
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
||||
# Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction.
|
||||
try:
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
rtl_cmd = builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=float(frequency),
|
||||
sample_rate=22050,
|
||||
gain=float(gain) if gain and str(gain) != '0' else None,
|
||||
ppm=int(ppm) if ppm and str(ppm) != '0' else None,
|
||||
modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm',
|
||||
squelch=None,
|
||||
bias_t=bool(data.get('bias_t', False)),
|
||||
)
|
||||
|
||||
if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-':
|
||||
# APRS benefits from DC blocking + fast AGC on rtl_fm.
|
||||
rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-']
|
||||
except Exception as e:
|
||||
if aprs_active_device is not None:
|
||||
app_module.release_sdr_device(aprs_active_device)
|
||||
aprs_active_device = None
|
||||
return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500
|
||||
|
||||
# Build decoder command
|
||||
if direwolf_path:
|
||||
@@ -1690,14 +1704,14 @@ def start_aprs() -> Response:
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'region': region,
|
||||
'device': device,
|
||||
'sdr_type': sdr_type.value,
|
||||
'decoder': decoder_name
|
||||
})
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'frequency': frequency,
|
||||
'region': region,
|
||||
'device': device,
|
||||
'sdr_type': sdr_type.value,
|
||||
'decoder': decoder_name
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start APRS decoder: {e}")
|
||||
|
||||
@@ -1051,3 +1051,19 @@ def request_store_forward():
|
||||
'status': 'error',
|
||||
'message': error or 'Failed to request S&F history'
|
||||
}), 500
|
||||
|
||||
|
||||
@meshtastic_bp.route('/topology')
|
||||
def mesh_topology():
|
||||
"""Return mesh network topology graph."""
|
||||
if not is_meshtastic_available():
|
||||
return jsonify({'status': 'error', 'message': 'Meshtastic SDK not installed'}), 400
|
||||
|
||||
client = get_meshtastic_client()
|
||||
if not client or not client.is_running:
|
||||
return jsonify({'status': 'error', 'message': 'Not connected'}), 400
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'topology': client.get_topology(),
|
||||
})
|
||||
|
||||
@@ -28,6 +28,10 @@ sensor_bp = Blueprint('sensor', __name__)
|
||||
# Track which device is being used
|
||||
sensor_active_device: int | None = None
|
||||
|
||||
# RSSI history per device (model_id -> list of (timestamp, rssi))
|
||||
sensor_rssi_history: dict[str, list[tuple[float, float]]] = {}
|
||||
_MAX_RSSI_HISTORY = 60
|
||||
|
||||
|
||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
"""Stream rtl_433 JSON output to queue."""
|
||||
@@ -45,6 +49,17 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
data['type'] = 'sensor'
|
||||
app_module.sensor_queue.put(data)
|
||||
|
||||
# Track RSSI history per device
|
||||
_model = data.get('model', '')
|
||||
_dev_id = data.get('id', '')
|
||||
_rssi_val = data.get('rssi')
|
||||
if _rssi_val is not None and _model:
|
||||
_hist_key = f"{_model}_{_dev_id}"
|
||||
hist = sensor_rssi_history.setdefault(_hist_key, [])
|
||||
hist.append((time.time(), float(_rssi_val)))
|
||||
if len(hist) > _MAX_RSSI_HISTORY:
|
||||
del hist[: len(hist) - _MAX_RSSI_HISTORY]
|
||||
|
||||
# Push scope event when signal level data is present
|
||||
rssi = data.get('rssi')
|
||||
snr = data.get('snr')
|
||||
@@ -283,3 +298,12 @@ def stream_sensor() -> Response:
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@sensor_bp.route('/sensor/rssi_history')
|
||||
def get_rssi_history() -> Response:
|
||||
"""Return RSSI history for all tracked sensor devices."""
|
||||
result = {}
|
||||
for key, entries in sensor_rssi_history.items():
|
||||
result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries]
|
||||
return jsonify({'status': 'success', 'devices': result})
|
||||
|
||||
@@ -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'})
|
||||
@@ -76,6 +76,13 @@ def stream_vdl2_output(process: subprocess.Popen) -> None:
|
||||
|
||||
app_module.vdl2_queue.put(data)
|
||||
|
||||
# Feed flight correlator
|
||||
try:
|
||||
from utils.flight_correlator import get_flight_correlator
|
||||
get_flight_correlator().add_vdl2_message(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
/* Analytics Dashboard Styles */
|
||||
|
||||
/* Analytics is a sidebar-only mode — hide the output panel and expand the sidebar */
|
||||
@media (min-width: 1024px) {
|
||||
.main-content.analytics-active {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
.main-content.analytics-active > .output-panel {
|
||||
display: none !important;
|
||||
}
|
||||
.main-content.analytics-active > .sidebar {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.main-content.analytics-active .sidebar-collapse-btn {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.main-content.analytics-active > .output-panel {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.analytics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: var(--space-3, 12px);
|
||||
margin-bottom: var(--space-4, 16px);
|
||||
}
|
||||
|
||||
.analytics-card {
|
||||
background: var(--bg-card, #151f2b);
|
||||
border: 1px solid var(--border-color, #1e2d3d);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: var(--space-3, 12px);
|
||||
text-align: center;
|
||||
transition: var(--transition-fast, 150ms ease);
|
||||
}
|
||||
|
||||
.analytics-card:hover {
|
||||
border-color: var(--accent-cyan, #4aa3ff);
|
||||
}
|
||||
|
||||
.analytics-card .card-count {
|
||||
font-size: var(--text-2xl, 24px);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #e0e6ed);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.analytics-card .card-label {
|
||||
font-size: var(--text-xs, 10px);
|
||||
color: var(--text-dim, #5a6a7a);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: var(--space-1, 4px);
|
||||
}
|
||||
|
||||
.analytics-card .card-sparkline {
|
||||
height: 24px;
|
||||
margin-top: var(--space-2, 8px);
|
||||
}
|
||||
|
||||
.analytics-card .card-sparkline svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.analytics-card .card-sparkline polyline {
|
||||
fill: none;
|
||||
stroke: var(--accent-cyan, #4aa3ff);
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Health indicators */
|
||||
.analytics-health {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2, 8px);
|
||||
margin-bottom: var(--space-4, 16px);
|
||||
}
|
||||
|
||||
.health-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1, 4px);
|
||||
font-size: var(--text-xs, 10px);
|
||||
color: var(--text-dim, #5a6a7a);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.health-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-red, #e25d5d);
|
||||
}
|
||||
|
||||
.health-dot.running {
|
||||
background: var(--accent-green, #38c180);
|
||||
}
|
||||
|
||||
/* Emergency squawk panel */
|
||||
.squawk-emergency {
|
||||
background: rgba(226, 93, 93, 0.1);
|
||||
border: 1px solid var(--accent-red, #e25d5d);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: var(--space-3, 12px);
|
||||
margin-bottom: var(--space-3, 12px);
|
||||
}
|
||||
|
||||
.squawk-emergency .squawk-title {
|
||||
color: var(--accent-red, #e25d5d);
|
||||
font-weight: 700;
|
||||
font-size: var(--text-sm, 12px);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: var(--space-2, 8px);
|
||||
}
|
||||
|
||||
.squawk-emergency .squawk-item {
|
||||
font-size: var(--text-sm, 12px);
|
||||
color: var(--text-primary, #e0e6ed);
|
||||
padding: var(--space-1, 4px) 0;
|
||||
border-bottom: 1px solid rgba(226, 93, 93, 0.2);
|
||||
}
|
||||
|
||||
.squawk-emergency .squawk-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Alert feed */
|
||||
.analytics-alert-feed {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: var(--space-4, 16px);
|
||||
}
|
||||
|
||||
.analytics-alert-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2, 8px);
|
||||
padding: var(--space-2, 8px);
|
||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||
font-size: var(--text-xs, 10px);
|
||||
}
|
||||
|
||||
.analytics-alert-item .alert-severity {
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.alert-severity.critical { background: var(--accent-red, #e25d5d); color: #fff; }
|
||||
.alert-severity.high { background: var(--accent-orange, #d6a85e); color: #000; }
|
||||
.alert-severity.medium { background: var(--accent-cyan, #4aa3ff); color: #fff; }
|
||||
.alert-severity.low { background: var(--border-color, #1e2d3d); color: var(--text-dim, #5a6a7a); }
|
||||
|
||||
/* Correlation panel */
|
||||
.analytics-correlation-pair {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2, 8px);
|
||||
padding: var(--space-2, 8px);
|
||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||
font-size: var(--text-xs, 10px);
|
||||
}
|
||||
|
||||
.analytics-correlation-pair .confidence-bar {
|
||||
height: 4px;
|
||||
background: var(--bg-secondary, #101823);
|
||||
border-radius: 2px;
|
||||
flex: 1;
|
||||
max-width: 60px;
|
||||
}
|
||||
|
||||
.analytics-correlation-pair .confidence-fill {
|
||||
height: 100%;
|
||||
background: var(--accent-green, #38c180);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Geofence zone list */
|
||||
.geofence-zone-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2, 8px);
|
||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||
font-size: var(--text-xs, 10px);
|
||||
}
|
||||
|
||||
.geofence-zone-item .zone-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e6ed);
|
||||
}
|
||||
|
||||
.geofence-zone-item .zone-radius {
|
||||
color: var(--text-dim, #5a6a7a);
|
||||
}
|
||||
|
||||
.geofence-zone-item .zone-delete {
|
||||
cursor: pointer;
|
||||
color: var(--accent-red, #e25d5d);
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--accent-red, #e25d5d);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: transparent;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
/* Export controls */
|
||||
.export-controls {
|
||||
display: flex;
|
||||
gap: var(--space-2, 8px);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.export-controls select,
|
||||
.export-controls button {
|
||||
font-size: var(--text-xs, 10px);
|
||||
padding: var(--space-1, 4px) var(--space-2, 8px);
|
||||
background: var(--bg-card, #151f2b);
|
||||
color: var(--text-primary, #e0e6ed);
|
||||
border: 1px solid var(--border-color, #1e2d3d);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.export-controls button {
|
||||
cursor: pointer;
|
||||
background: var(--accent-cyan, #4aa3ff);
|
||||
color: #fff;
|
||||
border-color: var(--accent-cyan, #4aa3ff);
|
||||
}
|
||||
|
||||
.export-controls button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Section headers */
|
||||
.analytics-section-header {
|
||||
font-size: var(--text-xs, 10px);
|
||||
font-weight: 600;
|
||||
color: var(--text-dim, #5a6a7a);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--space-2, 8px);
|
||||
padding-bottom: var(--space-1, 4px);
|
||||
border-bottom: 1px solid var(--border-color, #1e2d3d);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.analytics-empty {
|
||||
text-align: center;
|
||||
color: var(--text-dim, #5a6a7a);
|
||||
font-size: var(--text-xs, 10px);
|
||||
padding: var(--space-4, 16px);
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
/* Space Weather Mode Styles */
|
||||
|
||||
/* Main container */
|
||||
.sw-visuals-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Header metrics strip */
|
||||
.sw-header-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sw-header-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.sw-header-stat + .sw-header-stat {
|
||||
border-left: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sw-header-value {
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sw-header-label {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sw-header-value.accent-cyan { color: var(--accent-cyan); }
|
||||
.sw-header-value.accent-green { color: #00ff88; }
|
||||
.sw-header-value.accent-yellow { color: #ffcc00; }
|
||||
.sw-header-value.accent-orange { color: #ff8800; }
|
||||
.sw-header-value.accent-red { color: #ff3366; }
|
||||
|
||||
/* Refresh controls in strip */
|
||||
.sw-strip-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.sw-refresh-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.sw-refresh-btn:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.sw-last-update {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
}
|
||||
|
||||
/* NOAA G/S/R Scale cards */
|
||||
.sw-scales-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sw-scale-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sw-scale-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sw-scale-value {
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sw-scale-desc {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Scale severity colors */
|
||||
.sw-scale-0 { color: #00ff88; border-color: #00ff8833; }
|
||||
.sw-scale-1 { color: #88ff00; border-color: #88ff0033; }
|
||||
.sw-scale-2 { color: #ffcc00; border-color: #ffcc0033; }
|
||||
.sw-scale-3 { color: #ff8800; border-color: #ff880033; }
|
||||
.sw-scale-4 { color: #ff4400; border-color: #ff440033; }
|
||||
.sw-scale-5 { color: #ff0044; border-color: #ff004433; }
|
||||
|
||||
/* HF Band conditions grid */
|
||||
.sw-band-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sw-band-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sw-band-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto repeat(2, 1fr);
|
||||
gap: 4px 8px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
}
|
||||
|
||||
.sw-band-header {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sw-band-name {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sw-band-cond {
|
||||
text-align: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sw-band-good { color: #00ff88; background: #00ff8815; }
|
||||
.sw-band-fair { color: #ffcc00; background: #ffcc0015; }
|
||||
.sw-band-poor { color: #ff3366; background: #ff336615; }
|
||||
|
||||
/* 2-column dashboard grid */
|
||||
.sw-dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Chart containers */
|
||||
.sw-chart-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sw-chart-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sw-chart-wrap {
|
||||
position: relative;
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
.sw-chart-wrap canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Flare probability table */
|
||||
.sw-prob-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
}
|
||||
|
||||
.sw-prob-table th {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
text-align: left;
|
||||
padding: 4px 6px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sw-prob-table td {
|
||||
padding: 4px 6px;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Solar image gallery */
|
||||
.sw-image-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sw-image-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sw-image-tab {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-dim);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sw-image-tab:hover {
|
||||
border-color: var(--text-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sw-image-tab.active {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
background: var(--accent-cyan)10;
|
||||
}
|
||||
|
||||
.sw-image-frame {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sw-image-frame img {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* D-RAP frequency selector */
|
||||
.sw-drap-freqs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sw-drap-freq-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-dim);
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sw-drap-freq-btn:hover {
|
||||
border-color: var(--text-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sw-drap-freq-btn.active {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Alerts list */
|
||||
.sw-alerts-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sw-alert-item {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
}
|
||||
|
||||
.sw-alert-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sw-alert-type {
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.sw-alert-time {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sw-alert-msg {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Active regions table */
|
||||
.sw-regions-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sw-regions-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
}
|
||||
|
||||
.sw-regions-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
text-align: left;
|
||||
padding: 4px 6px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.sw-regions-table td {
|
||||
padding: 4px 6px;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Empty / loading states */
|
||||
.sw-empty {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono, 'Roboto Condensed', monospace);
|
||||
}
|
||||
|
||||
.sw-loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sw-loading::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-top-color: var(--accent-cyan);
|
||||
border-radius: 50%;
|
||||
animation: sw-spin 0.8s linear infinite;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes sw-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Full-width card */
|
||||
.sw-full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.sw-dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sw-scales-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sw-header-strip {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sw-header-stat {
|
||||
padding: 4px 8px;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.sw-header-value {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Analytics Dashboard Module
|
||||
* Cross-mode summary, sparklines, alerts, correlations, geofence management, export.
|
||||
*/
|
||||
const Analytics = (function () {
|
||||
'use strict';
|
||||
|
||||
let refreshTimer = null;
|
||||
|
||||
function init() {
|
||||
refresh();
|
||||
if (!refreshTimer) {
|
||||
refreshTimer = setInterval(refresh, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
Promise.all([
|
||||
fetch('/analytics/summary').then(r => r.json()).catch(() => null),
|
||||
fetch('/analytics/activity').then(r => r.json()).catch(() => null),
|
||||
fetch('/alerts/events?limit=20').then(r => r.json()).catch(() => null),
|
||||
fetch('/correlation').then(r => r.json()).catch(() => null),
|
||||
fetch('/analytics/geofences').then(r => r.json()).catch(() => null),
|
||||
]).then(([summary, activity, alerts, correlations, geofences]) => {
|
||||
if (summary) renderSummary(summary);
|
||||
if (activity) renderSparklines(activity.sparklines || {});
|
||||
if (alerts) renderAlerts(alerts.events || []);
|
||||
if (correlations) renderCorrelations(correlations);
|
||||
if (geofences) renderGeofences(geofences.zones || []);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSummary(data) {
|
||||
const counts = data.counts || {};
|
||||
_setText('analyticsCountAdsb', counts.adsb || 0);
|
||||
_setText('analyticsCountAis', counts.ais || 0);
|
||||
_setText('analyticsCountWifi', counts.wifi || 0);
|
||||
_setText('analyticsCountBt', counts.bluetooth || 0);
|
||||
_setText('analyticsCountDsc', counts.dsc || 0);
|
||||
_setText('analyticsCountAcars', counts.acars || 0);
|
||||
_setText('analyticsCountVdl2', counts.vdl2 || 0);
|
||||
_setText('analyticsCountAprs', counts.aprs || 0);
|
||||
_setText('analyticsCountMesh', counts.meshtastic || 0);
|
||||
|
||||
// Health
|
||||
const health = data.health || {};
|
||||
const container = document.getElementById('analyticsHealth');
|
||||
if (container) {
|
||||
let html = '';
|
||||
const modeLabels = {
|
||||
pager: 'Pager', sensor: '433MHz', adsb: 'ADS-B', ais: 'AIS',
|
||||
acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', wifi: 'WiFi',
|
||||
bluetooth: 'BT', dsc: 'DSC'
|
||||
};
|
||||
for (const [mode, info] of Object.entries(health)) {
|
||||
if (mode === 'sdr_devices') continue;
|
||||
const running = info && info.running;
|
||||
const label = modeLabels[mode] || mode;
|
||||
html += '<div class="health-item"><span class="health-dot' + (running ? ' running' : '') + '"></span>' + _esc(label) + '</div>';
|
||||
}
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// Squawks
|
||||
const squawks = data.squawks || [];
|
||||
const sqSection = document.getElementById('analyticsSquawkSection');
|
||||
const sqList = document.getElementById('analyticsSquawkList');
|
||||
if (sqSection && sqList) {
|
||||
if (squawks.length > 0) {
|
||||
sqSection.style.display = '';
|
||||
sqList.innerHTML = squawks.map(s =>
|
||||
'<div class="squawk-item"><strong>' + _esc(s.squawk) + '</strong> ' +
|
||||
_esc(s.meaning) + ' — ' + _esc(s.callsign || s.icao) + '</div>'
|
||||
).join('');
|
||||
} else {
|
||||
sqSection.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderSparklines(sparklines) {
|
||||
const map = {
|
||||
adsb: 'analyticsSparkAdsb',
|
||||
ais: 'analyticsSparkAis',
|
||||
wifi: 'analyticsSparkWifi',
|
||||
bluetooth: 'analyticsSparkBt',
|
||||
dsc: 'analyticsSparkDsc',
|
||||
};
|
||||
|
||||
for (const [mode, elId] of Object.entries(map)) {
|
||||
const el = document.getElementById(elId);
|
||||
if (!el) continue;
|
||||
const data = sparklines[mode] || [];
|
||||
if (data.length < 2) {
|
||||
el.innerHTML = '';
|
||||
continue;
|
||||
}
|
||||
const max = Math.max(...data, 1);
|
||||
const w = 100;
|
||||
const h = 24;
|
||||
const step = w / (data.length - 1);
|
||||
const points = data.map((v, i) =>
|
||||
(i * step).toFixed(1) + ',' + (h - (v / max) * (h - 2)).toFixed(1)
|
||||
).join(' ');
|
||||
el.innerHTML = '<svg viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none"><polyline points="' + points + '"/></svg>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderAlerts(events) {
|
||||
const container = document.getElementById('analyticsAlertFeed');
|
||||
if (!container) return;
|
||||
if (!events || events.length === 0) {
|
||||
container.innerHTML = '<div class="analytics-empty">No recent alerts</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = events.slice(0, 20).map(e => {
|
||||
const sev = e.severity || 'medium';
|
||||
const title = e.title || e.event_type || 'Alert';
|
||||
const time = e.created_at ? new Date(e.created_at).toLocaleTimeString() : '';
|
||||
return '<div class="analytics-alert-item">' +
|
||||
'<span class="alert-severity ' + _esc(sev) + '">' + _esc(sev) + '</span>' +
|
||||
'<span>' + _esc(title) + '</span>' +
|
||||
'<span style="margin-left:auto;color:var(--text-dim)">' + _esc(time) + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderCorrelations(data) {
|
||||
const container = document.getElementById('analyticsCorrelations');
|
||||
if (!container) return;
|
||||
const pairs = (data && data.correlations) || [];
|
||||
if (pairs.length === 0) {
|
||||
container.innerHTML = '<div class="analytics-empty">No correlations detected</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = pairs.slice(0, 20).map(p => {
|
||||
const conf = Math.round((p.confidence || 0) * 100);
|
||||
return '<div class="analytics-correlation-pair">' +
|
||||
'<span>' + _esc(p.wifi_mac || '') + '</span>' +
|
||||
'<span style="color:var(--text-dim)">↔</span>' +
|
||||
'<span>' + _esc(p.bt_mac || '') + '</span>' +
|
||||
'<div class="confidence-bar"><div class="confidence-fill" style="width:' + conf + '%"></div></div>' +
|
||||
'<span style="color:var(--text-dim)">' + conf + '%</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderGeofences(zones) {
|
||||
const container = document.getElementById('analyticsGeofenceList');
|
||||
if (!container) return;
|
||||
if (!zones || zones.length === 0) {
|
||||
container.innerHTML = '<div class="analytics-empty">No geofence zones defined</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = zones.map(z =>
|
||||
'<div class="geofence-zone-item">' +
|
||||
'<span class="zone-name">' + _esc(z.name) + '</span>' +
|
||||
'<span class="zone-radius">' + z.radius_m + 'm</span>' +
|
||||
'<button class="zone-delete" onclick="Analytics.deleteGeofence(' + z.id + ')">DEL</button>' +
|
||||
'</div>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
function addGeofence() {
|
||||
const name = prompt('Zone name:');
|
||||
if (!name) return;
|
||||
const lat = parseFloat(prompt('Latitude:', '0'));
|
||||
const lon = parseFloat(prompt('Longitude:', '0'));
|
||||
const radius = parseFloat(prompt('Radius (meters):', '1000'));
|
||||
if (isNaN(lat) || isNaN(lon) || isNaN(radius)) {
|
||||
alert('Invalid input');
|
||||
return;
|
||||
}
|
||||
fetch('/analytics/geofences', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, lat, lon, radius_m: radius }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(() => refresh());
|
||||
}
|
||||
|
||||
function deleteGeofence(id) {
|
||||
if (!confirm('Delete this geofence zone?')) return;
|
||||
fetch('/analytics/geofences/' + id, { method: 'DELETE' })
|
||||
.then(r => r.json())
|
||||
.then(() => refresh());
|
||||
}
|
||||
|
||||
function exportData(mode) {
|
||||
const m = mode || (document.getElementById('exportMode') || {}).value || 'adsb';
|
||||
const f = (document.getElementById('exportFormat') || {}).value || 'json';
|
||||
window.open('/analytics/export/' + encodeURIComponent(m) + '?format=' + encodeURIComponent(f), '_blank');
|
||||
}
|
||||
|
||||
// Helpers
|
||||
function _setText(id, val) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = val;
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
if (typeof s !== 'string') s = String(s == null ? '' : s);
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
return { init, destroy, refresh, addGeofence, deleteGeofence, exportData };
|
||||
})();
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -399,16 +399,20 @@ const WeatherSat = (function() {
|
||||
|
||||
addConsoleEntry('Capture complete', 'signal');
|
||||
updatePhaseIndicator('complete');
|
||||
if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer);
|
||||
consoleAutoHideTimer = setTimeout(() => showConsole(false), 30000);
|
||||
}
|
||||
|
||||
} else if (data.status === 'error') {
|
||||
isRunning = false;
|
||||
if (!schedulerEnabled) stopStream();
|
||||
updateStatusUI('idle', 'Error');
|
||||
showNotification('Weather Sat', data.message || 'Capture error');
|
||||
if (captureStatus) captureStatus.classList.remove('active');
|
||||
|
||||
if (data.message) addConsoleEntry(data.message, 'error');
|
||||
updatePhaseIndicator('error');
|
||||
if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer);
|
||||
consoleAutoHideTimer = setTimeout(() => showConsole(false), 15000);
|
||||
}
|
||||
}
|
||||
@@ -761,8 +765,17 @@ const WeatherSat = (function() {
|
||||
}).addTo(groundTrackLayer);
|
||||
|
||||
// Observer marker
|
||||
const lat = parseFloat(localStorage.getItem('observerLat'));
|
||||
const lon = parseFloat(localStorage.getItem('observerLon'));
|
||||
let obsLat, obsLon;
|
||||
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||
const shared = ObserverLocation.getShared();
|
||||
obsLat = shared?.lat;
|
||||
obsLon = shared?.lon;
|
||||
} else {
|
||||
obsLat = parseFloat(localStorage.getItem('observerLat'));
|
||||
obsLon = parseFloat(localStorage.getItem('observerLon'));
|
||||
}
|
||||
const lat = obsLat;
|
||||
const lon = obsLon;
|
||||
if (!isNaN(lat) && !isNaN(lon)) {
|
||||
L.circleMarker([lat, lon], {
|
||||
radius: 6, color: '#ffbb00', fillColor: '#ffbb00', fillOpacity: 0.8, weight: 1,
|
||||
@@ -946,8 +959,15 @@ const WeatherSat = (function() {
|
||||
* Enable auto-scheduler
|
||||
*/
|
||||
async function enableScheduler() {
|
||||
const lat = parseFloat(localStorage.getItem('observerLat'));
|
||||
const lon = parseFloat(localStorage.getItem('observerLon'));
|
||||
let lat, lon;
|
||||
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||
const shared = ObserverLocation.getShared();
|
||||
lat = shared?.lat;
|
||||
lon = shared?.lon;
|
||||
} else {
|
||||
lat = parseFloat(localStorage.getItem('observerLat'));
|
||||
lon = parseFloat(localStorage.getItem('observerLon'));
|
||||
}
|
||||
|
||||
if (isNaN(lat) || isNaN(lon)) {
|
||||
showNotification('Weather Sat', 'Set observer location first');
|
||||
|
||||
@@ -3332,7 +3332,7 @@ sudo make install</code>
|
||||
let acarsMessageCount = 0;
|
||||
let acarsSidebarCollapsed = localStorage.getItem('acarsSidebarCollapsed') !== 'false';
|
||||
let acarsFrequencies = {
|
||||
'na': ['129.125', '130.025', '130.450', '131.550'],
|
||||
'na': ['131.725', '131.825'],
|
||||
'eu': ['131.525', '131.725', '131.550'],
|
||||
'ap': ['131.550', '131.450']
|
||||
};
|
||||
@@ -3359,6 +3359,22 @@ sudo make install</code>
|
||||
sidebar.classList.add('collapsed');
|
||||
}
|
||||
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() {
|
||||
@@ -3542,6 +3558,11 @@ sudo make install</code>
|
||||
|
||||
acarsEventSource.onerror = function() {
|
||||
console.error('ACARS stream error');
|
||||
setTimeout(() => {
|
||||
if (isAcarsRunning) {
|
||||
startAcarsStream(acarsCurrentAgent !== null);
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
// Start polling fallback for agent mode
|
||||
@@ -3872,6 +3893,11 @@ sudo make install</code>
|
||||
|
||||
vdl2EventSource.onerror = function() {
|
||||
console.error('VDL2 stream error');
|
||||
setTimeout(() => {
|
||||
if (isVdl2Running) {
|
||||
startVdl2Stream(vdl2CurrentAgent !== null);
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
// Start polling fallback for agent mode
|
||||
@@ -4163,7 +4189,7 @@ sudo make install</code>
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Populate VDL2 device selector
|
||||
// Populate VDL2 device selector and check running status
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
fetch('/devices')
|
||||
.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 = '■ STOP VDL2';
|
||||
document.getElementById('vdl2ToggleBtn').classList.add('active');
|
||||
document.getElementById('vdl2PanelIndicator').classList.add('active');
|
||||
startVdl2Stream(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
|
||||
+185
-9
@@ -52,6 +52,7 @@
|
||||
<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/tscm.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/analytics.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-cards.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-timeline.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/activity-timeline.css') }}">
|
||||
@@ -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/subghz.css') }}?v={{ version }}&r=subghz_layout9">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate2">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/space-weather.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/function-strip.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}">
|
||||
@@ -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-name">GPS</span>
|
||||
</button>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('spaceweather')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span>
|
||||
<span class="mode-name">Space Wx</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -550,10 +556,14 @@
|
||||
|
||||
{% include 'partials/modes/gps.html' %}
|
||||
|
||||
{% include 'partials/modes/space-weather.html' %}
|
||||
|
||||
{% include 'partials/modes/listening-post.html' %}
|
||||
|
||||
{% include 'partials/modes/tscm.html' %}
|
||||
|
||||
{% include 'partials/modes/analytics.html' %}
|
||||
|
||||
{% include 'partials/modes/ais.html' %}
|
||||
|
||||
{% include 'partials/modes/spy-stations.html' %}
|
||||
@@ -1006,6 +1016,7 @@
|
||||
<option value="brazil">Brazil (145.570)</option>
|
||||
<option value="japan">Japan (144.640)</option>
|
||||
<option value="china">China (144.640)</option>
|
||||
<option value="iss">ISS (145.825)</option>
|
||||
<option value="custom">Custom Frequency</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -2899,6 +2910,141 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Space Weather Dashboard -->
|
||||
<div id="spaceWeatherVisuals" class="sw-visuals-container" style="display: none;">
|
||||
<!-- Header metrics strip -->
|
||||
<div class="sw-header-strip">
|
||||
<div class="sw-header-stat">
|
||||
<span class="sw-header-value accent-cyan" id="swStripSfi">--</span>
|
||||
<span class="sw-header-label">SFI</span>
|
||||
</div>
|
||||
<div class="sw-header-stat">
|
||||
<span class="sw-header-value" id="swStripKp">--</span>
|
||||
<span class="sw-header-label">Kp</span>
|
||||
</div>
|
||||
<div class="sw-header-stat">
|
||||
<span class="sw-header-value" id="swStripA">--</span>
|
||||
<span class="sw-header-label">A-Index</span>
|
||||
</div>
|
||||
<div class="sw-header-stat">
|
||||
<span class="sw-header-value" id="swStripSsn">--</span>
|
||||
<span class="sw-header-label">SSN</span>
|
||||
</div>
|
||||
<div class="sw-header-stat">
|
||||
<span class="sw-header-value" id="swStripWind">--</span>
|
||||
<span class="sw-header-label">Wind</span>
|
||||
</div>
|
||||
<div class="sw-header-stat">
|
||||
<span class="sw-header-value" id="swStripBz">--</span>
|
||||
<span class="sw-header-label">Bz</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NOAA G/S/R Scales -->
|
||||
<div class="sw-scales-row">
|
||||
<div class="sw-scale-card" id="swScaleG">
|
||||
<div class="sw-scale-label">Geomagnetic</div>
|
||||
<div class="sw-scale-value sw-scale-0">G0</div>
|
||||
<div class="sw-scale-desc">Quiet</div>
|
||||
</div>
|
||||
<div class="sw-scale-card" id="swScaleS">
|
||||
<div class="sw-scale-label">Solar Radiation</div>
|
||||
<div class="sw-scale-value sw-scale-0">S0</div>
|
||||
<div class="sw-scale-desc">None</div>
|
||||
</div>
|
||||
<div class="sw-scale-card" id="swScaleR">
|
||||
<div class="sw-scale-label">Radio Blackouts</div>
|
||||
<div class="sw-scale-value sw-scale-0">R0</div>
|
||||
<div class="sw-scale-desc">None</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HF Band Conditions -->
|
||||
<div class="sw-band-panel">
|
||||
<div class="sw-band-title">HF Band Conditions</div>
|
||||
<div class="sw-band-grid" id="swBandGrid">
|
||||
<div class="sw-loading">Loading band data</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts row -->
|
||||
<div class="sw-dashboard-grid">
|
||||
<div class="sw-chart-card">
|
||||
<div class="sw-chart-title">Kp Index (3-hourly)</div>
|
||||
<div class="sw-chart-wrap"><canvas id="swKpChart"></canvas></div>
|
||||
</div>
|
||||
<div class="sw-chart-card">
|
||||
<div class="sw-chart-title">Solar Wind</div>
|
||||
<div class="sw-chart-wrap"><canvas id="swWindChart"></canvas></div>
|
||||
</div>
|
||||
<div class="sw-chart-card">
|
||||
<div class="sw-chart-title">X-Ray Flux (GOES)</div>
|
||||
<div class="sw-chart-wrap"><canvas id="swXrayChart"></canvas></div>
|
||||
</div>
|
||||
<div class="sw-chart-card">
|
||||
<div class="sw-chart-title">Flare Probability</div>
|
||||
<div id="swFlareProb"><div class="sw-loading">Loading</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Solar imagery gallery -->
|
||||
<div class="sw-dashboard-grid">
|
||||
<div class="sw-image-panel">
|
||||
<div class="sw-chart-title">Solar Imagery (SDO)</div>
|
||||
<div class="sw-image-tabs">
|
||||
<button class="sw-image-tab sw-solar-tab active" data-key="sdo_193" onclick="SpaceWeather.selectSolarImage('sdo_193')">193Å</button>
|
||||
<button class="sw-image-tab sw-solar-tab" data-key="sdo_304" onclick="SpaceWeather.selectSolarImage('sdo_304')">304Å</button>
|
||||
<button class="sw-image-tab sw-solar-tab" data-key="sdo_magnetogram" onclick="SpaceWeather.selectSolarImage('sdo_magnetogram')">Magnetogram</button>
|
||||
</div>
|
||||
<div class="sw-image-frame" id="swSolarImageFrame">
|
||||
<div class="sw-loading">Loading</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sw-image-panel">
|
||||
<div class="sw-chart-title">Aurora Forecast (North)</div>
|
||||
<div class="sw-image-frame" id="swAuroraFrame">
|
||||
<div class="sw-loading">Loading</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- D-RAP absorption map -->
|
||||
<div class="sw-image-panel">
|
||||
<div class="sw-chart-title">D-Region Absorption (D-RAP)</div>
|
||||
<div class="sw-drap-freqs">
|
||||
<button class="sw-drap-freq-btn active" data-key="drap_global" onclick="SpaceWeather.selectDrapFreq('drap_global')">Global</button>
|
||||
<button class="sw-drap-freq-btn" data-key="drap_5" onclick="SpaceWeather.selectDrapFreq('drap_5')">5 MHz</button>
|
||||
<button class="sw-drap-freq-btn" data-key="drap_10" onclick="SpaceWeather.selectDrapFreq('drap_10')">10 MHz</button>
|
||||
<button class="sw-drap-freq-btn" data-key="drap_15" onclick="SpaceWeather.selectDrapFreq('drap_15')">15 MHz</button>
|
||||
<button class="sw-drap-freq-btn" data-key="drap_20" onclick="SpaceWeather.selectDrapFreq('drap_20')">20 MHz</button>
|
||||
<button class="sw-drap-freq-btn" data-key="drap_25" onclick="SpaceWeather.selectDrapFreq('drap_25')">25 MHz</button>
|
||||
<button class="sw-drap-freq-btn" data-key="drap_30" onclick="SpaceWeather.selectDrapFreq('drap_30')">30 MHz</button>
|
||||
</div>
|
||||
<div class="sw-image-frame" id="swDrapImageFrame">
|
||||
<div class="sw-loading">Loading</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alerts & Active Regions -->
|
||||
<div class="sw-dashboard-grid">
|
||||
<div class="sw-alerts-panel">
|
||||
<div class="sw-chart-title">Active Alerts</div>
|
||||
<div id="swAlertsList"><div class="sw-loading">Loading</div></div>
|
||||
</div>
|
||||
<div class="sw-regions-panel">
|
||||
<div class="sw-chart-title">Active Sunspot Regions</div>
|
||||
<table class="sw-regions-table">
|
||||
<thead>
|
||||
<tr><th>Region</th><th>Date</th><th>Loc</th><th>Lo</th><th>Area</th></tr>
|
||||
</thead>
|
||||
<tbody id="swRegionsBody">
|
||||
<tr><td colspan="5" class="sw-loading">Loading</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
|
||||
<div class="recon-panel collapsed" id="reconPanel">
|
||||
<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/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate2"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/analytics.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script>
|
||||
|
||||
<script>
|
||||
// ============================================
|
||||
@@ -3159,7 +3307,8 @@
|
||||
const validModes = new Set([
|
||||
'pager', 'sensor', 'rtlamr', 'aprs', 'listening',
|
||||
'spystations', 'meshtastic', 'wifi', 'bluetooth', 'bt_locate',
|
||||
'tscm', 'satellite', 'sstv', 'weathersat', 'sstv_general', 'gps', 'websdr', 'subghz'
|
||||
'tscm', 'satellite', 'sstv', 'weathersat', 'sstv_general', 'gps', 'websdr', 'subghz',
|
||||
'analytics', 'spaceweather'
|
||||
]);
|
||||
|
||||
function getModeFromQuery() {
|
||||
@@ -3616,11 +3765,11 @@
|
||||
'pager': 'sdr', 'sensor': 'sdr',
|
||||
'aprs': 'sdr', 'listening': 'sdr',
|
||||
'wifi': 'wireless', 'bluetooth': 'wireless', 'bt_locate': 'wireless',
|
||||
'tscm': 'security',
|
||||
'tscm': 'security', 'analytics': 'security',
|
||||
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr',
|
||||
'meshtastic': 'sdr',
|
||||
'satellite': 'space', 'sstv': 'space', 'weathersat': 'space', 'sstv_general': 'space', 'gps': 'space',
|
||||
'subghz': 'sdr'
|
||||
'spaceweather': 'space', 'subghz': 'sdr'
|
||||
};
|
||||
|
||||
// Remove has-active from all dropdowns
|
||||
@@ -3695,7 +3844,8 @@
|
||||
'pager': 'pager', 'sensor': '433',
|
||||
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth', 'bt_locate': 'bt locate',
|
||||
'listening': 'listening', 'aprs': 'aprs', 'tscm': 'tscm', 'meshtastic': 'meshtastic',
|
||||
'dmr': 'dmr', 'websdr': 'websdr', 'sstv_general': 'hf sstv'
|
||||
'dmr': 'dmr', 'websdr': 'websdr', 'sstv_general': 'hf sstv',
|
||||
'analytics': 'analytics'
|
||||
};
|
||||
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
||||
const label = btn.querySelector('.nav-label');
|
||||
@@ -3723,6 +3873,8 @@
|
||||
document.getElementById('dmrMode')?.classList.toggle('active', mode === 'dmr');
|
||||
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
|
||||
document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz');
|
||||
document.getElementById('analyticsMode')?.classList.toggle('active', mode === 'analytics');
|
||||
document.getElementById('spaceWeatherMode')?.classList.toggle('active', mode === 'spaceweather');
|
||||
|
||||
|
||||
const pagerStats = document.getElementById('pagerStats');
|
||||
@@ -3765,7 +3917,9 @@
|
||||
'meshtastic': 'MESHTASTIC',
|
||||
'dmr': 'DIGITAL VOICE',
|
||||
'websdr': 'WEBSDR',
|
||||
'subghz': 'SUBGHZ'
|
||||
'subghz': 'SUBGHZ',
|
||||
'analytics': 'ANALYTICS',
|
||||
'spaceweather': 'SPACE WX'
|
||||
};
|
||||
const activeModeIndicator = document.getElementById('activeModeIndicator');
|
||||
if (activeModeIndicator) activeModeIndicator.innerHTML = '<span class="pulse-dot"></span>' + (modeNames[mode] || mode.toUpperCase());
|
||||
@@ -3785,6 +3939,7 @@
|
||||
const websdrVisuals = document.getElementById('websdrVisuals');
|
||||
const subghzVisuals = document.getElementById('subghzVisuals');
|
||||
const btLocateVisuals = document.getElementById('btLocateVisuals');
|
||||
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
|
||||
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
|
||||
@@ -3801,6 +3956,7 @@
|
||||
if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none';
|
||||
if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none';
|
||||
if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none';
|
||||
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
|
||||
|
||||
// Hide sidebar by default for Meshtastic mode, show for others
|
||||
const mainContent = document.querySelector('.main-content');
|
||||
@@ -3810,6 +3966,8 @@
|
||||
} else {
|
||||
mainContent.classList.remove('mesh-sidebar-hidden');
|
||||
}
|
||||
// Analytics is sidebar-only — hide output panel and expand sidebar
|
||||
mainContent.classList.toggle('analytics-active', mode === 'analytics');
|
||||
}
|
||||
|
||||
// Show/hide mode-specific timeline containers
|
||||
@@ -3839,7 +3997,9 @@
|
||||
'meshtastic': 'Meshtastic Mesh Monitor',
|
||||
'dmr': 'Digital Voice Decoder',
|
||||
'websdr': 'HF/Shortwave WebSDR',
|
||||
'subghz': 'SubGHz Transceiver'
|
||||
'subghz': 'SubGHz Transceiver',
|
||||
'analytics': 'Cross-Mode Analytics',
|
||||
'spaceweather': 'Space Weather Monitor'
|
||||
};
|
||||
const outputTitle = document.getElementById('outputTitle');
|
||||
if (outputTitle) outputTitle.textContent = titles[mode] || 'Signal Monitor';
|
||||
@@ -3853,11 +4013,25 @@
|
||||
refreshTscmDevices();
|
||||
}
|
||||
|
||||
// Initialize/destroy Analytics mode
|
||||
if (mode === 'analytics') {
|
||||
// Expand all analytics sections (sidebar sections default to collapsed)
|
||||
document.querySelectorAll('#analyticsMode .section.collapsed').forEach(s => s.classList.remove('collapsed'));
|
||||
if (typeof Analytics !== 'undefined') Analytics.init();
|
||||
} else {
|
||||
if (typeof Analytics !== 'undefined' && Analytics.destroy) Analytics.destroy();
|
||||
}
|
||||
|
||||
// Initialize/destroy Space Weather mode
|
||||
if (mode !== 'spaceweather') {
|
||||
if (typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy) SpaceWeather.destroy();
|
||||
}
|
||||
|
||||
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm)
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||
const reconPanel = document.getElementById('reconPanel');
|
||||
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz') {
|
||||
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics' || mode === 'spaceweather') {
|
||||
if (reconPanel) reconPanel.style.display = 'none';
|
||||
if (reconBtn) reconBtn.style.display = 'none';
|
||||
if (intelBtn) intelBtn.style.display = 'none';
|
||||
@@ -3895,8 +4069,8 @@
|
||||
// Hide output console for modes with their own visualizations
|
||||
const outputEl = document.getElementById('output');
|
||||
const statusBar = document.querySelector('.status-bar');
|
||||
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz') ? 'none' : 'block';
|
||||
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'dmr' || mode === 'subghz') ? 'none' : 'flex';
|
||||
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics' || mode === 'spaceweather') ? 'none' : 'block';
|
||||
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'dmr' || mode === 'subghz' || mode === 'spaceweather') ? 'none' : 'flex';
|
||||
|
||||
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
|
||||
if (mode !== 'meshtastic') {
|
||||
@@ -3962,6 +4136,8 @@
|
||||
SubGhz.init();
|
||||
} else if (mode === 'bt_locate') {
|
||||
BtLocate.init();
|
||||
} else if (mode === 'spaceweather') {
|
||||
SpaceWeather.init();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 & Meteor imagery</span></div>
|
||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span><span class="desc">HF SSTV - Shortwave image decoder</span></div>
|
||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg></span><span class="desc">GPS - GNSS signal analysis</span></div>
|
||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span><span class="desc">Space Weather - Solar & geomagnetic data</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -197,6 +198,15 @@
|
||||
<li>Useful for evaluating GNSS reception and interference</li>
|
||||
</ul>
|
||||
|
||||
<h3>Space Weather Mode</h3>
|
||||
<ul class="tip-list">
|
||||
<li>Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL</li>
|
||||
<li>Solar indices (SFI, Kp, A-index, sunspot number) and NOAA G/S/R scales</li>
|
||||
<li>HF band conditions, X-ray flux, solar wind speed, and flare probability</li>
|
||||
<li>Solar imagery (SDO 193/304/Magnetogram), D-RAP absorption maps, aurora forecast</li>
|
||||
<li>No SDR hardware required — all data from public APIs</li>
|
||||
</ul>
|
||||
|
||||
<h3>WiFi Mode</h3>
|
||||
<ul class="tip-list">
|
||||
<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>HF SSTV:</strong> RTL-SDR or SoapySDR-compatible hardware, slowrx</li>
|
||||
<li><strong>GPS:</strong> RTL-SDR or GPS-capable SDR</li>
|
||||
<li><strong>Space Weather:</strong> Internet connection (public APIs)</li>
|
||||
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>
|
||||
<li><strong>Bluetooth:</strong> Bluetooth adapter, bluez (hcitool/bluetoothctl)</li>
|
||||
<li><strong>BT Locate:</strong> Bluetooth adapter, bluez</li>
|
||||
|
||||
@@ -56,8 +56,8 @@
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
||||
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Primary (worldwide)</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">131.550 MHz</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Primary (N. America)</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">131.725 / 131.825 MHz</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
|
||||
@@ -89,7 +89,7 @@
|
||||
let acarsMainMsgCount = 0;
|
||||
|
||||
const acarsMainFrequencies = {
|
||||
'na': ['129.125', '130.025', '130.450', '131.550'],
|
||||
'na': ['131.725', '131.825'],
|
||||
'eu': ['131.525', '131.725', '131.550'],
|
||||
'ap': ['131.550', '131.450']
|
||||
};
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<!-- ANALYTICS MODE -->
|
||||
<div id="analyticsMode" class="mode-content">
|
||||
{# Analytics Dashboard Sidebar Panel #}
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Summary</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div class="analytics-grid" id="analyticsSummaryCards">
|
||||
<div class="analytics-card" data-mode="adsb">
|
||||
<div class="card-count" id="analyticsCountAdsb">0</div>
|
||||
<div class="card-label">Aircraft</div>
|
||||
<div class="card-sparkline" id="analyticsSparkAdsb"></div>
|
||||
</div>
|
||||
<div class="analytics-card" data-mode="ais">
|
||||
<div class="card-count" id="analyticsCountAis">0</div>
|
||||
<div class="card-label">Vessels</div>
|
||||
<div class="card-sparkline" id="analyticsSparkAis"></div>
|
||||
</div>
|
||||
<div class="analytics-card" data-mode="wifi">
|
||||
<div class="card-count" id="analyticsCountWifi">0</div>
|
||||
<div class="card-label">WiFi</div>
|
||||
<div class="card-sparkline" id="analyticsSparkWifi"></div>
|
||||
</div>
|
||||
<div class="analytics-card" data-mode="bluetooth">
|
||||
<div class="card-count" id="analyticsCountBt">0</div>
|
||||
<div class="card-label">Bluetooth</div>
|
||||
<div class="card-sparkline" id="analyticsSparkBt"></div>
|
||||
</div>
|
||||
<div class="analytics-card" data-mode="dsc">
|
||||
<div class="card-count" id="analyticsCountDsc">0</div>
|
||||
<div class="card-label">DSC</div>
|
||||
<div class="card-sparkline" id="analyticsSparkDsc"></div>
|
||||
</div>
|
||||
<div class="analytics-card" data-mode="acars">
|
||||
<div class="card-count" id="analyticsCountAcars">0</div>
|
||||
<div class="card-label">ACARS</div>
|
||||
<div class="card-sparkline" id="analyticsSparkAcars"></div>
|
||||
</div>
|
||||
<div class="analytics-card" data-mode="vdl2">
|
||||
<div class="card-count" id="analyticsCountVdl2">0</div>
|
||||
<div class="card-label">VDL2</div>
|
||||
<div class="card-sparkline" id="analyticsSparkVdl2"></div>
|
||||
</div>
|
||||
<div class="analytics-card" data-mode="aprs">
|
||||
<div class="card-count" id="analyticsCountAprs">0</div>
|
||||
<div class="card-label">APRS</div>
|
||||
<div class="card-sparkline" id="analyticsSparkAprs"></div>
|
||||
</div>
|
||||
<div class="analytics-card" data-mode="meshtastic">
|
||||
<div class="card-count" id="analyticsCountMesh">0</div>
|
||||
<div class="card-label">Mesh</div>
|
||||
<div class="card-sparkline" id="analyticsSparkMesh"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Mode Health</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div class="analytics-health" id="analyticsHealth"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section" id="analyticsSquawkSection" style="display:none;">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Emergency Squawks</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div class="squawk-emergency" id="analyticsSquawkPanel">
|
||||
<div class="squawk-title">Active Emergency Codes</div>
|
||||
<div id="analyticsSquawkList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Recent Alerts</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div class="analytics-alert-feed" id="analyticsAlertFeed">
|
||||
<div class="analytics-empty">No recent alerts</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Correlations</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div id="analyticsCorrelations">
|
||||
<div class="analytics-empty">No correlations detected</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Geofences</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div id="analyticsGeofenceList"></div>
|
||||
<button class="btn btn-sm" onclick="Analytics.addGeofence()" style="margin-top:8px; font-size:10px; padding:4px 10px; background:var(--accent-cyan); color:#fff; border:none; border-radius:4px; cursor:pointer;">
|
||||
+ Add Zone
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-header collapsible" onclick="toggleSection(this)">
|
||||
<span>Export Data</span>
|
||||
<span class="collapse-icon">▼</span>
|
||||
</h3>
|
||||
<div class="section-content">
|
||||
<div class="export-controls">
|
||||
<select id="exportMode">
|
||||
<option value="adsb">ADS-B</option>
|
||||
<option value="ais">AIS</option>
|
||||
<option value="wifi">WiFi</option>
|
||||
<option value="bluetooth">Bluetooth</option>
|
||||
<option value="dsc">DSC</option>
|
||||
</select>
|
||||
<select id="exportFormat">
|
||||
<option value="json">JSON</option>
|
||||
<option value="csv">CSV</option>
|
||||
</select>
|
||||
<button onclick="Analytics.exportData()">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,71 @@
|
||||
<!-- SPACE WEATHER MODE -->
|
||||
<div id="spaceWeatherMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>Space Weather Monitor</h3>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
||||
Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL.
|
||||
No SDR hardware required — data is fetched from public APIs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Quick Status</h3>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; font-size: 11px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||||
<div style="display: flex; justify-content: space-between; padding: 4px 6px; background: var(--bg-primary); border-radius: 3px;">
|
||||
<span style="color: var(--text-dim);">SFI</span>
|
||||
<span id="swSidebarSfi" style="color: var(--accent-cyan); font-weight: 600;">--</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; padding: 4px 6px; background: var(--bg-primary); border-radius: 3px;">
|
||||
<span style="color: var(--text-dim);">Kp</span>
|
||||
<span id="swSidebarKp" style="color: var(--accent-cyan); font-weight: 600;">--</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; padding: 4px 6px; background: var(--bg-primary); border-radius: 3px;">
|
||||
<span style="color: var(--text-dim);">A-Index</span>
|
||||
<span id="swSidebarA" style="color: var(--accent-cyan); font-weight: 600;">--</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; padding: 4px 6px; background: var(--bg-primary); border-radius: 3px;">
|
||||
<span style="color: var(--text-dim);">SSN</span>
|
||||
<span id="swSidebarSsn" style="color: var(--accent-cyan); font-weight: 600;">--</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; padding: 4px 6px; background: var(--bg-primary); border-radius: 3px;">
|
||||
<span style="color: var(--text-dim);">Wind</span>
|
||||
<span id="swSidebarWind" style="color: var(--accent-cyan); font-weight: 600;">--</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; padding: 4px 6px; background: var(--bg-primary); border-radius: 3px;">
|
||||
<span style="color: var(--text-dim);">Bz</span>
|
||||
<span id="swSidebarBz" style="color: var(--accent-cyan); font-weight: 600;">--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Refresh</h3>
|
||||
<button class="mode-btn" onclick="SpaceWeather.refresh()" style="width: 100%; margin-bottom: 8px;">
|
||||
Refresh Now
|
||||
</button>
|
||||
<div class="form-group">
|
||||
<label style="display: flex; align-items: center; gap: 6px;">
|
||||
<input type="checkbox" id="swAutoRefresh" checked onchange="SpaceWeather.toggleAutoRefresh()" style="width: auto;">
|
||||
Auto-refresh (5 min)
|
||||
</label>
|
||||
</div>
|
||||
<div id="swLastUpdate" style="font-size: 10px; color: var(--text-dim); font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; margin-top: 4px;">
|
||||
Not yet loaded
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Resources</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<a href="https://www.swpc.noaa.gov/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||
NOAA Space Weather
|
||||
</a>
|
||||
<a href="https://sdo.gsfc.nasa.gov/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||
NASA SDO
|
||||
</a>
|
||||
<a href="https://www.hamqsl.com/solar.html" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||
HamQSL Solar Data
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,6 +103,7 @@
|
||||
|
||||
<div class="mode-nav-dropdown-menu">
|
||||
{{ mode_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
||||
{{ mode_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
||||
{{ mode_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
|
||||
{{ mode_item('spaceweather', 'Space Weather', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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('bt_locate', 'Locate', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
|
||||
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
||||
{{ mobile_item('analytics', 'Analytics', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>') }}
|
||||
{% if is_index_page %}
|
||||
{{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>') }}
|
||||
{% else %}
|
||||
@@ -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('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
|
||||
{{ mobile_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
|
||||
{{ mobile_item('spaceweather', 'SpaceWx', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/></svg>') }}
|
||||
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
|
||||
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Tests for analytics endpoints, export, and squawk detection."""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def app():
|
||||
"""Create application for testing."""
|
||||
import app as app_module
|
||||
import utils.database as db_mod
|
||||
from routes import register_blueprints
|
||||
|
||||
app_module.app.config['TESTING'] = True
|
||||
|
||||
# Use temp directory for test database
|
||||
tmp_dir = Path(tempfile.mkdtemp())
|
||||
db_mod.DB_DIR = tmp_dir
|
||||
db_mod.DB_PATH = tmp_dir / 'test_intercept.db'
|
||||
# Reset thread-local connection so it picks up new path
|
||||
if hasattr(db_mod._local, 'connection') and db_mod._local.connection:
|
||||
db_mod._local.connection.close()
|
||||
db_mod._local.connection = None
|
||||
|
||||
db_mod.init_db()
|
||||
|
||||
if 'pager' not in app_module.app.blueprints:
|
||||
register_blueprints(app_module.app)
|
||||
|
||||
return app_module.app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
client = app.test_client()
|
||||
# Set session login to bypass require_login before_request hook
|
||||
with client.session_transaction() as sess:
|
||||
sess['logged_in'] = True
|
||||
return client
|
||||
|
||||
|
||||
class TestAnalyticsSummary:
|
||||
"""Tests for /analytics/summary endpoint."""
|
||||
|
||||
def test_summary_returns_json(self, client):
|
||||
response = client.get('/analytics/summary')
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert 'counts' in data
|
||||
assert 'health' in data
|
||||
assert 'squawks' in data
|
||||
|
||||
def test_summary_counts_structure(self, client):
|
||||
response = client.get('/analytics/summary')
|
||||
data = json.loads(response.data)
|
||||
counts = data['counts']
|
||||
assert 'adsb' in counts
|
||||
assert 'ais' in counts
|
||||
assert 'wifi' in counts
|
||||
assert 'bluetooth' in counts
|
||||
assert 'dsc' in counts
|
||||
# All should be integers
|
||||
for val in counts.values():
|
||||
assert isinstance(val, int)
|
||||
|
||||
def test_summary_health_structure(self, client):
|
||||
response = client.get('/analytics/summary')
|
||||
data = json.loads(response.data)
|
||||
health = data['health']
|
||||
# Should have process statuses
|
||||
assert 'pager' in health
|
||||
assert 'sensor' in health
|
||||
assert 'adsb' in health
|
||||
# Each should have a running flag
|
||||
for mode_info in health.values():
|
||||
if isinstance(mode_info, dict) and 'running' in mode_info:
|
||||
assert isinstance(mode_info['running'], bool)
|
||||
|
||||
|
||||
class TestAnalyticsExport:
|
||||
"""Tests for /analytics/export/<mode> endpoint."""
|
||||
|
||||
def test_export_adsb_json(self, client):
|
||||
response = client.get('/analytics/export/adsb?format=json')
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['mode'] == 'adsb'
|
||||
assert 'data' in data
|
||||
assert isinstance(data['data'], list)
|
||||
|
||||
def test_export_adsb_csv(self, client):
|
||||
response = client.get('/analytics/export/adsb?format=csv')
|
||||
assert response.status_code == 200
|
||||
assert response.content_type.startswith('text/csv')
|
||||
assert 'Content-Disposition' in response.headers
|
||||
|
||||
def test_export_invalid_mode(self, client):
|
||||
response = client.get('/analytics/export/invalid_mode')
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'error'
|
||||
|
||||
def test_export_wifi_json(self, client):
|
||||
response = client.get('/analytics/export/wifi?format=json')
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['mode'] == 'wifi'
|
||||
|
||||
|
||||
class TestAnalyticsSquawks:
|
||||
"""Tests for squawk detection."""
|
||||
|
||||
def test_squawks_endpoint(self, client):
|
||||
response = client.get('/analytics/squawks')
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert isinstance(data['squawks'], list)
|
||||
|
||||
def test_get_emergency_squawks_detects_7700(self):
|
||||
from utils.analytics import get_emergency_squawks
|
||||
|
||||
# Mock the adsb_aircraft DataStore
|
||||
mock_store = MagicMock()
|
||||
mock_store.items.return_value = [
|
||||
('ABC123', {'squawk': '7700', 'callsign': 'TEST01', 'altitude': 35000}),
|
||||
('DEF456', {'squawk': '1200', 'callsign': 'TEST02'}),
|
||||
]
|
||||
|
||||
with patch('utils.analytics.app_module') as mock_app:
|
||||
mock_app.adsb_aircraft = mock_store
|
||||
squawks = get_emergency_squawks()
|
||||
|
||||
assert len(squawks) == 1
|
||||
assert squawks[0]['squawk'] == '7700'
|
||||
assert squawks[0]['meaning'] == 'General Emergency'
|
||||
assert squawks[0]['icao'] == 'ABC123'
|
||||
|
||||
|
||||
class TestGeofenceCRUD:
|
||||
"""Tests for geofence CRUD endpoints."""
|
||||
|
||||
def test_list_geofences(self, client):
|
||||
response = client.get('/analytics/geofences')
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert isinstance(data['zones'], list)
|
||||
|
||||
def test_create_geofence(self, client):
|
||||
response = client.post('/analytics/geofences',
|
||||
data=json.dumps({
|
||||
'name': 'Test Zone',
|
||||
'lat': 51.5074,
|
||||
'lon': -0.1278,
|
||||
'radius_m': 500,
|
||||
}),
|
||||
content_type='application/json')
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert 'zone_id' in data
|
||||
|
||||
def test_create_geofence_missing_fields(self, client):
|
||||
response = client.post('/analytics/geofences',
|
||||
data=json.dumps({'name': 'No coords'}),
|
||||
content_type='application/json')
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_create_geofence_invalid_coords(self, client):
|
||||
response = client.post('/analytics/geofences',
|
||||
data=json.dumps({
|
||||
'name': 'Bad',
|
||||
'lat': 100,
|
||||
'lon': 0,
|
||||
'radius_m': 100,
|
||||
}),
|
||||
content_type='application/json')
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_delete_geofence_not_found(self, client):
|
||||
response = client.delete('/analytics/geofences/99999')
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestAnalyticsActivity:
|
||||
"""Tests for /analytics/activity endpoint."""
|
||||
|
||||
def test_activity_returns_sparklines(self, client):
|
||||
response = client.get('/analytics/activity')
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert 'sparklines' in data
|
||||
assert isinstance(data['sparklines'], dict)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Tests for FlightCorrelator: ACARS/VDL2 message matching."""
|
||||
|
||||
import pytest
|
||||
|
||||
from utils.flight_correlator import FlightCorrelator
|
||||
|
||||
|
||||
class TestFlightCorrelator:
|
||||
"""Test ACARS/VDL2 message matching by callsign."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self):
|
||||
self.correlator = FlightCorrelator(max_messages=100)
|
||||
|
||||
def test_add_acars_message(self):
|
||||
self.correlator.add_acars_message({
|
||||
'flight': 'BAW123', 'tail': 'G-ABCD', 'text': 'Hello',
|
||||
})
|
||||
assert self.correlator.acars_count == 1
|
||||
|
||||
def test_add_vdl2_message(self):
|
||||
self.correlator.add_vdl2_message({
|
||||
'flight': 'DLH456', 'text': 'World',
|
||||
})
|
||||
assert self.correlator.vdl2_count == 1
|
||||
|
||||
def test_match_by_callsign(self):
|
||||
self.correlator.add_acars_message({
|
||||
'flight': 'BAW123', 'text': 'msg1',
|
||||
})
|
||||
self.correlator.add_acars_message({
|
||||
'flight': 'DLH456', 'text': 'msg2',
|
||||
})
|
||||
|
||||
result = self.correlator.get_messages_for_aircraft(callsign='BAW123')
|
||||
assert len(result['acars']) == 1
|
||||
assert result['acars'][0]['text'] == 'msg1'
|
||||
|
||||
def test_match_by_icao(self):
|
||||
self.correlator.add_vdl2_message({
|
||||
'icao': 'ABC123', 'text': 'vdl2 msg',
|
||||
})
|
||||
|
||||
result = self.correlator.get_messages_for_aircraft(icao='ABC123')
|
||||
assert len(result['vdl2']) == 1
|
||||
assert result['vdl2'][0]['text'] == 'vdl2 msg'
|
||||
|
||||
def test_no_match_returns_empty(self):
|
||||
self.correlator.add_acars_message({'flight': 'BAW123', 'text': 'msg'})
|
||||
|
||||
result = self.correlator.get_messages_for_aircraft(callsign='NOMATCH')
|
||||
assert result['acars'] == []
|
||||
assert result['vdl2'] == []
|
||||
|
||||
def test_empty_search_returns_empty(self):
|
||||
result = self.correlator.get_messages_for_aircraft()
|
||||
assert result == {'acars': [], 'vdl2': []}
|
||||
|
||||
def test_ring_buffer_limit(self):
|
||||
correlator = FlightCorrelator(max_messages=5)
|
||||
for i in range(10):
|
||||
correlator.add_acars_message({'flight': f'FL{i}', 'text': f'msg{i}'})
|
||||
|
||||
assert correlator.acars_count == 5
|
||||
# First 5 messages should have been evicted
|
||||
result = correlator.get_messages_for_aircraft(callsign='FL0')
|
||||
assert len(result['acars']) == 0
|
||||
# Last message should still be there
|
||||
result = correlator.get_messages_for_aircraft(callsign='FL9')
|
||||
assert len(result['acars']) == 1
|
||||
|
||||
def test_case_insensitive_matching(self):
|
||||
self.correlator.add_acars_message({'flight': 'baw123', 'text': 'lowercase'})
|
||||
|
||||
result = self.correlator.get_messages_for_aircraft(callsign='BAW123')
|
||||
assert len(result['acars']) == 1
|
||||
|
||||
def test_match_by_tail_field(self):
|
||||
self.correlator.add_acars_message({
|
||||
'tail': 'G-ABCD', 'text': 'tail match',
|
||||
})
|
||||
|
||||
result = self.correlator.get_messages_for_aircraft(callsign='G-ABCD')
|
||||
assert len(result['acars']) == 1
|
||||
|
||||
def test_internal_fields_not_returned(self):
|
||||
self.correlator.add_acars_message({'flight': 'TEST', 'text': 'msg'})
|
||||
|
||||
result = self.correlator.get_messages_for_aircraft(callsign='TEST')
|
||||
msg = result['acars'][0]
|
||||
assert '_corr_time' not in msg
|
||||
|
||||
def test_both_acars_and_vdl2_returned(self):
|
||||
self.correlator.add_acars_message({'flight': 'UAL789', 'text': 'acars'})
|
||||
self.correlator.add_vdl2_message({'flight': 'UAL789', 'text': 'vdl2'})
|
||||
|
||||
result = self.correlator.get_messages_for_aircraft(callsign='UAL789')
|
||||
assert len(result['acars']) == 1
|
||||
assert len(result['vdl2']) == 1
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Tests for geofence haversine, enter/exit detection, and persistence."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestHaversineDistance:
|
||||
"""Test haversine_distance accuracy."""
|
||||
|
||||
def test_same_point_zero_distance(self):
|
||||
from utils.geofence import haversine_distance
|
||||
assert haversine_distance(51.5, -0.1, 51.5, -0.1) == 0.0
|
||||
|
||||
def test_known_distance_london_paris(self):
|
||||
from utils.geofence import haversine_distance
|
||||
# London to Paris ~340km
|
||||
dist = haversine_distance(51.5074, -0.1278, 48.8566, 2.3522)
|
||||
assert 340_000 < dist < 345_000
|
||||
|
||||
def test_short_distance(self):
|
||||
from utils.geofence import haversine_distance
|
||||
# Two points ~111m apart (0.001 degrees latitude at equator)
|
||||
dist = haversine_distance(0.0, 0.0, 0.001, 0.0)
|
||||
assert 100 < dist < 120
|
||||
|
||||
def test_antipodal_distance(self):
|
||||
from utils.geofence import haversine_distance
|
||||
# North pole to south pole ~20015km
|
||||
dist = haversine_distance(90.0, 0.0, -90.0, 0.0)
|
||||
assert 20_000_000 < dist < 20_050_000
|
||||
|
||||
|
||||
class TestGeofenceManager:
|
||||
"""Test enter/exit detection logic."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self):
|
||||
"""Provide a fresh GeofenceManager with mocked DB."""
|
||||
from utils.geofence import GeofenceManager
|
||||
|
||||
with patch('utils.geofence._ensure_table'), patch('utils.geofence.get_db') as mock_db:
|
||||
# Mock the context manager
|
||||
mock_conn = MagicMock()
|
||||
mock_db.return_value.__enter__ = MagicMock(return_value=mock_conn)
|
||||
mock_db.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
self.manager = GeofenceManager()
|
||||
# Override list_zones to return test data
|
||||
self._zones = []
|
||||
self.manager.list_zones = lambda: self._zones
|
||||
|
||||
def test_no_zones_returns_empty(self):
|
||||
events = self.manager.check_position('TEST1', 'aircraft', 51.5, -0.1)
|
||||
assert events == []
|
||||
|
||||
def test_enter_event(self):
|
||||
self._zones = [{
|
||||
'id': 1, 'name': 'London', 'lat': 51.5074, 'lon': -0.1278,
|
||||
'radius_m': 10000, 'alert_on': 'enter_exit',
|
||||
}]
|
||||
# First position inside zone
|
||||
events = self.manager.check_position('AC1', 'aircraft', 51.5074, -0.1278)
|
||||
assert len(events) == 1
|
||||
assert events[0]['type'] == 'geofence_enter'
|
||||
assert events[0]['zone_name'] == 'London'
|
||||
|
||||
def test_no_duplicate_enter(self):
|
||||
self._zones = [{
|
||||
'id': 1, 'name': 'London', 'lat': 51.5074, 'lon': -0.1278,
|
||||
'radius_m': 10000, 'alert_on': 'enter_exit',
|
||||
}]
|
||||
# First enter
|
||||
self.manager.check_position('AC1', 'aircraft', 51.5074, -0.1278)
|
||||
# Second check still inside - should not fire enter again
|
||||
events = self.manager.check_position('AC1', 'aircraft', 51.508, -0.128)
|
||||
assert len(events) == 0
|
||||
|
||||
def test_exit_event(self):
|
||||
self._zones = [{
|
||||
'id': 1, 'name': 'London', 'lat': 51.5074, 'lon': -0.1278,
|
||||
'radius_m': 1000, 'alert_on': 'enter_exit',
|
||||
}]
|
||||
# Enter
|
||||
self.manager.check_position('AC1', 'aircraft', 51.5074, -0.1278)
|
||||
# Exit (far away)
|
||||
events = self.manager.check_position('AC1', 'aircraft', 52.0, 0.0)
|
||||
assert len(events) == 1
|
||||
assert events[0]['type'] == 'geofence_exit'
|
||||
|
||||
def test_enter_only_mode(self):
|
||||
self._zones = [{
|
||||
'id': 1, 'name': 'London', 'lat': 51.5074, 'lon': -0.1278,
|
||||
'radius_m': 1000, 'alert_on': 'enter',
|
||||
}]
|
||||
# Enter
|
||||
events = self.manager.check_position('AC1', 'aircraft', 51.5074, -0.1278)
|
||||
assert len(events) == 1
|
||||
assert events[0]['type'] == 'geofence_enter'
|
||||
# Exit should not fire
|
||||
events = self.manager.check_position('AC1', 'aircraft', 52.0, 0.0)
|
||||
assert len(events) == 0
|
||||
|
||||
def test_metadata_included_in_event(self):
|
||||
self._zones = [{
|
||||
'id': 1, 'name': 'Zone', 'lat': 0.0, 'lon': 0.0,
|
||||
'radius_m': 100000, 'alert_on': 'enter_exit',
|
||||
}]
|
||||
events = self.manager.check_position(
|
||||
'AC1', 'aircraft', 0.0, 0.0,
|
||||
metadata={'callsign': 'TEST01', 'altitude': 35000}
|
||||
)
|
||||
assert events[0]['callsign'] == 'TEST01'
|
||||
assert events[0]['altitude'] == 35000
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Cross-mode analytics: activity tracking, summaries, and emergency squawk detection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
import app as app_module
|
||||
|
||||
|
||||
class ModeActivityTracker:
|
||||
"""Track device counts per mode in time-bucketed ring buffer for sparklines."""
|
||||
|
||||
def __init__(self, max_buckets: int = 60, bucket_interval: float = 5.0):
|
||||
self._max_buckets = max_buckets
|
||||
self._bucket_interval = bucket_interval
|
||||
self._history: dict[str, deque] = {}
|
||||
self._last_record_time = 0.0
|
||||
|
||||
def record(self) -> None:
|
||||
"""Snapshot current counts for all modes."""
|
||||
now = time.time()
|
||||
if now - self._last_record_time < self._bucket_interval:
|
||||
return
|
||||
self._last_record_time = now
|
||||
|
||||
counts = _get_mode_counts()
|
||||
for mode, count in counts.items():
|
||||
if mode not in self._history:
|
||||
self._history[mode] = deque(maxlen=self._max_buckets)
|
||||
self._history[mode].append(count)
|
||||
|
||||
def get_sparkline(self, mode: str) -> list[int]:
|
||||
"""Return sparkline array for a mode."""
|
||||
self.record()
|
||||
return list(self._history.get(mode, []))
|
||||
|
||||
def get_all_sparklines(self) -> dict[str, list[int]]:
|
||||
"""Return sparkline arrays for all tracked modes."""
|
||||
self.record()
|
||||
return {mode: list(values) for mode, values in self._history.items()}
|
||||
|
||||
|
||||
# Singleton
|
||||
_tracker: ModeActivityTracker | None = None
|
||||
|
||||
|
||||
def get_activity_tracker() -> ModeActivityTracker:
|
||||
global _tracker
|
||||
if _tracker is None:
|
||||
_tracker = ModeActivityTracker()
|
||||
return _tracker
|
||||
|
||||
|
||||
def _safe_len(attr_name: str) -> int:
|
||||
"""Safely get len() of an app_module attribute."""
|
||||
try:
|
||||
return len(getattr(app_module, attr_name))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _safe_route_attr(module_path: str, attr_name: str, default: int = 0) -> int:
|
||||
"""Safely read a module-level counter from a route file."""
|
||||
try:
|
||||
import importlib
|
||||
mod = importlib.import_module(module_path)
|
||||
return int(getattr(mod, attr_name, default))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _get_mode_counts() -> dict[str, int]:
|
||||
"""Read current entity counts from all available data sources."""
|
||||
counts: dict[str, int] = {}
|
||||
|
||||
# ADS-B aircraft (DataStore)
|
||||
counts['adsb'] = _safe_len('adsb_aircraft')
|
||||
|
||||
# AIS vessels (DataStore)
|
||||
counts['ais'] = _safe_len('ais_vessels')
|
||||
|
||||
# WiFi: prefer v2 scanner, fall back to legacy DataStore
|
||||
wifi_count = 0
|
||||
try:
|
||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
||||
if wifi_scanner is not None:
|
||||
wifi_count = len(wifi_scanner.access_points)
|
||||
except Exception:
|
||||
pass
|
||||
if wifi_count == 0:
|
||||
wifi_count = _safe_len('wifi_networks')
|
||||
counts['wifi'] = wifi_count
|
||||
|
||||
# Bluetooth: prefer v2 scanner, fall back to legacy DataStore
|
||||
bt_count = 0
|
||||
try:
|
||||
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
||||
if bt_scanner is not None:
|
||||
bt_count = len(bt_scanner.get_devices())
|
||||
except Exception:
|
||||
pass
|
||||
if bt_count == 0:
|
||||
bt_count = _safe_len('bt_devices')
|
||||
counts['bluetooth'] = bt_count
|
||||
|
||||
# DSC messages (DataStore)
|
||||
counts['dsc'] = _safe_len('dsc_messages')
|
||||
|
||||
# ACARS message count (route-level counter)
|
||||
counts['acars'] = _safe_route_attr('routes.acars', 'acars_message_count')
|
||||
|
||||
# VDL2 message count (route-level counter)
|
||||
counts['vdl2'] = _safe_route_attr('routes.vdl2', 'vdl2_message_count')
|
||||
|
||||
# APRS stations (route-level dict)
|
||||
try:
|
||||
import routes.aprs as aprs_mod
|
||||
counts['aprs'] = len(getattr(aprs_mod, 'aprs_stations', {}))
|
||||
except Exception:
|
||||
counts['aprs'] = 0
|
||||
|
||||
# Meshtastic recent messages (route-level list)
|
||||
try:
|
||||
import routes.meshtastic as mesh_route
|
||||
counts['meshtastic'] = len(getattr(mesh_route, '_recent_messages', []))
|
||||
except Exception:
|
||||
counts['meshtastic'] = 0
|
||||
|
||||
return counts
|
||||
|
||||
|
||||
def get_cross_mode_summary() -> dict[str, Any]:
|
||||
"""Return counts dict for all available data sources."""
|
||||
counts = _get_mode_counts()
|
||||
wifi_clients_count = 0
|
||||
try:
|
||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
||||
if wifi_scanner is not None:
|
||||
wifi_clients_count = len(wifi_scanner.clients)
|
||||
except Exception:
|
||||
pass
|
||||
if wifi_clients_count == 0:
|
||||
wifi_clients_count = _safe_len('wifi_clients')
|
||||
counts['wifi_clients'] = wifi_clients_count
|
||||
return counts
|
||||
|
||||
|
||||
def get_mode_health() -> dict[str, dict]:
|
||||
"""Check process refs and SDR status for each mode."""
|
||||
health: dict[str, dict] = {}
|
||||
|
||||
process_map = {
|
||||
'pager': 'current_process',
|
||||
'sensor': 'sensor_process',
|
||||
'adsb': 'adsb_process',
|
||||
'ais': 'ais_process',
|
||||
'acars': 'acars_process',
|
||||
'vdl2': 'vdl2_process',
|
||||
'aprs': 'aprs_process',
|
||||
'wifi': 'wifi_process',
|
||||
'bluetooth': 'bt_process',
|
||||
'dsc': 'dsc_process',
|
||||
'rtlamr': 'rtlamr_process',
|
||||
'dmr': 'dmr_process',
|
||||
}
|
||||
|
||||
for mode, attr in process_map.items():
|
||||
proc = getattr(app_module, attr, None)
|
||||
running = proc is not None and (proc.poll() is None if proc else False)
|
||||
health[mode] = {'running': running}
|
||||
|
||||
# Override WiFi/BT health with v2 scanner status if available
|
||||
try:
|
||||
from utils.wifi.scanner import _scanner_instance as wifi_scanner
|
||||
if wifi_scanner is not None and wifi_scanner.is_scanning:
|
||||
health['wifi'] = {'running': True}
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from utils.bluetooth.scanner import _scanner_instance as bt_scanner
|
||||
if bt_scanner is not None and bt_scanner.is_scanning:
|
||||
health['bluetooth'] = {'running': True}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Meshtastic: check client connection status
|
||||
try:
|
||||
from utils.meshtastic import get_meshtastic_client
|
||||
client = get_meshtastic_client()
|
||||
health['meshtastic'] = {'running': client._interface is not None}
|
||||
except Exception:
|
||||
health['meshtastic'] = {'running': False}
|
||||
|
||||
try:
|
||||
sdr_status = app_module.get_sdr_device_status()
|
||||
health['sdr_devices'] = {str(k): v for k, v in sdr_status.items()}
|
||||
except Exception:
|
||||
health['sdr_devices'] = {}
|
||||
|
||||
return health
|
||||
|
||||
|
||||
EMERGENCY_SQUAWKS = {
|
||||
'7700': 'General Emergency',
|
||||
'7600': 'Comms Failure',
|
||||
'7500': 'Hijack',
|
||||
}
|
||||
|
||||
|
||||
def get_emergency_squawks() -> list[dict]:
|
||||
"""Iterate adsb_aircraft DataStore for emergency squawk codes."""
|
||||
emergencies: list[dict] = []
|
||||
try:
|
||||
for icao, aircraft in app_module.adsb_aircraft.items():
|
||||
sq = str(aircraft.get('squawk', '')).strip()
|
||||
if sq in EMERGENCY_SQUAWKS:
|
||||
emergencies.append({
|
||||
'icao': icao,
|
||||
'callsign': aircraft.get('callsign', ''),
|
||||
'squawk': sq,
|
||||
'meaning': EMERGENCY_SQUAWKS[sq],
|
||||
'altitude': aircraft.get('altitude'),
|
||||
'lat': aircraft.get('lat'),
|
||||
'lon': aircraft.get('lon'),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return emergencies
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Match ACARS/VDL2 messages to ADS-B aircraft by callsign."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
|
||||
class FlightCorrelator:
|
||||
"""Correlate ACARS and VDL2 messages with ADS-B aircraft."""
|
||||
|
||||
def __init__(self, max_messages: int = 1000):
|
||||
self._acars_messages: deque[dict] = deque(maxlen=max_messages)
|
||||
self._vdl2_messages: deque[dict] = deque(maxlen=max_messages)
|
||||
|
||||
def add_acars_message(self, msg: dict) -> None:
|
||||
self._acars_messages.append({
|
||||
**msg,
|
||||
'_corr_time': time.time(),
|
||||
})
|
||||
|
||||
def add_vdl2_message(self, msg: dict) -> None:
|
||||
self._vdl2_messages.append({
|
||||
**msg,
|
||||
'_corr_time': time.time(),
|
||||
})
|
||||
|
||||
def get_messages_for_aircraft(
|
||||
self, icao: str | None = None, callsign: str | None = None
|
||||
) -> dict[str, list[dict]]:
|
||||
"""Match ACARS/VDL2 messages by callsign, flight, or registration fields."""
|
||||
if not icao and not callsign:
|
||||
return {'acars': [], 'vdl2': []}
|
||||
|
||||
search_terms: set[str] = set()
|
||||
if callsign:
|
||||
search_terms.add(callsign.strip().upper())
|
||||
if icao:
|
||||
search_terms.add(icao.strip().upper())
|
||||
|
||||
acars = []
|
||||
for msg in self._acars_messages:
|
||||
if self._msg_matches(msg, search_terms):
|
||||
acars.append(self._clean_msg(msg))
|
||||
|
||||
vdl2 = []
|
||||
for msg in self._vdl2_messages:
|
||||
if self._msg_matches(msg, search_terms):
|
||||
vdl2.append(self._clean_msg(msg))
|
||||
|
||||
return {'acars': acars, 'vdl2': vdl2}
|
||||
|
||||
@staticmethod
|
||||
def _msg_matches(msg: dict, terms: set[str]) -> bool:
|
||||
"""Check if any identifying field in msg matches the search terms."""
|
||||
for field in ('flight', 'tail', 'reg', 'callsign', 'icao', 'addr'):
|
||||
val = msg.get(field)
|
||||
if val and str(val).strip().upper() in terms:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _clean_msg(msg: dict) -> dict:
|
||||
"""Return message without internal correlation fields."""
|
||||
return {k: v for k, v in msg.items() if not k.startswith('_corr_')}
|
||||
|
||||
@property
|
||||
def acars_count(self) -> int:
|
||||
return len(self._acars_messages)
|
||||
|
||||
@property
|
||||
def vdl2_count(self) -> int:
|
||||
return len(self._vdl2_messages)
|
||||
|
||||
|
||||
# Singleton
|
||||
_correlator: FlightCorrelator | None = None
|
||||
|
||||
|
||||
def get_flight_correlator() -> FlightCorrelator:
|
||||
global _correlator
|
||||
if _correlator is None:
|
||||
_correlator = FlightCorrelator()
|
||||
return _correlator
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Geofence zones with haversine distance, enter/exit detection, and SQLite persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from utils.database import get_db
|
||||
|
||||
|
||||
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Return distance in meters between two lat/lon points."""
|
||||
R = 6_371_000 # Earth radius in meters
|
||||
phi1 = math.radians(lat1)
|
||||
phi2 = math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlam = math.radians(lon2 - lon1)
|
||||
|
||||
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
|
||||
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
|
||||
def _ensure_table() -> None:
|
||||
"""Create geofence_zones table if it doesn't exist."""
|
||||
with get_db() as conn:
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS geofence_zones (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL,
|
||||
radius_m REAL NOT NULL,
|
||||
alert_on TEXT DEFAULT 'enter_exit',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
|
||||
class GeofenceManager:
|
||||
"""Manages geofence zones with enter/exit detection."""
|
||||
|
||||
def __init__(self):
|
||||
self._inside: dict[str, set[int]] = {} # entity_id -> set of zone_ids inside
|
||||
_ensure_table()
|
||||
|
||||
def list_zones(self) -> list[dict]:
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
'SELECT id, name, lat, lon, radius_m, alert_on, created_at FROM geofence_zones ORDER BY id'
|
||||
)
|
||||
return [dict(row) for row in cursor]
|
||||
|
||||
def add_zone(self, name: str, lat: float, lon: float, radius_m: float,
|
||||
alert_on: str = 'enter_exit') -> int:
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute(
|
||||
'INSERT INTO geofence_zones (name, lat, lon, radius_m, alert_on) VALUES (?, ?, ?, ?, ?)',
|
||||
(name, lat, lon, radius_m, alert_on),
|
||||
)
|
||||
return cursor.lastrowid
|
||||
|
||||
def delete_zone(self, zone_id: int) -> bool:
|
||||
with get_db() as conn:
|
||||
cursor = conn.execute('DELETE FROM geofence_zones WHERE id = ?', (zone_id,))
|
||||
# Clean up inside tracking
|
||||
for entity_zones in self._inside.values():
|
||||
entity_zones.discard(zone_id)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def check_position(self, entity_id: str, entity_type: str,
|
||||
lat: float, lon: float,
|
||||
metadata: dict[str, Any] | None = None) -> list[dict]:
|
||||
"""Check entity position against all zones. Returns list of events."""
|
||||
zones = self.list_zones()
|
||||
if not zones:
|
||||
return []
|
||||
|
||||
events: list[dict] = []
|
||||
prev_inside = self._inside.get(entity_id, set())
|
||||
curr_inside: set[int] = set()
|
||||
|
||||
for zone in zones:
|
||||
dist = haversine_distance(lat, lon, zone['lat'], zone['lon'])
|
||||
zid = zone['id']
|
||||
if dist <= zone['radius_m']:
|
||||
curr_inside.add(zid)
|
||||
|
||||
if zid not in prev_inside and zone['alert_on'] in ('enter', 'enter_exit'):
|
||||
events.append({
|
||||
'type': 'geofence_enter',
|
||||
'zone_id': zid,
|
||||
'zone_name': zone['name'],
|
||||
'entity_id': entity_id,
|
||||
'entity_type': entity_type,
|
||||
'distance_m': round(dist, 1),
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
**(metadata or {}),
|
||||
})
|
||||
else:
|
||||
if zid in prev_inside and zone['alert_on'] in ('exit', 'enter_exit'):
|
||||
events.append({
|
||||
'type': 'geofence_exit',
|
||||
'zone_id': zid,
|
||||
'zone_name': zone['name'],
|
||||
'entity_id': entity_id,
|
||||
'entity_type': entity_type,
|
||||
'distance_m': round(dist, 1),
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
**(metadata or {}),
|
||||
})
|
||||
|
||||
self._inside[entity_id] = curr_inside
|
||||
return events
|
||||
|
||||
|
||||
# Singleton
|
||||
_manager: GeofenceManager | None = None
|
||||
|
||||
|
||||
def get_geofence_manager() -> GeofenceManager:
|
||||
global _manager
|
||||
if _manager is None:
|
||||
_manager = GeofenceManager()
|
||||
return _manager
|
||||
@@ -306,6 +306,9 @@ class MeshtasticClient:
|
||||
self._range_test_running: bool = False
|
||||
self._range_test_results: list[dict] = []
|
||||
|
||||
# Topology tracking: node_id -> {neighbors, hop_count, msg_count, last_seen}
|
||||
self._topology: dict[str, dict] = {}
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._running
|
||||
@@ -326,6 +329,35 @@ class MeshtasticClient:
|
||||
"""Set callback for received messages."""
|
||||
self._callback = callback
|
||||
|
||||
def record_message_route(self, from_node: str, to_node: str, hops: int | None = None) -> None:
|
||||
"""Record a message route for topology tracking."""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
for node_id in (from_node, to_node):
|
||||
if node_id not in self._topology:
|
||||
self._topology[node_id] = {
|
||||
'neighbors': set(),
|
||||
'hop_count': hops,
|
||||
'msg_count': 0,
|
||||
'last_seen': now,
|
||||
}
|
||||
entry = self._topology[node_id]
|
||||
entry['msg_count'] += 1
|
||||
entry['last_seen'] = now
|
||||
self._topology[from_node]['neighbors'].add(to_node)
|
||||
self._topology[to_node]['neighbors'].add(from_node)
|
||||
|
||||
def get_topology(self) -> dict:
|
||||
"""Return topology dict with serializable sets."""
|
||||
result = {}
|
||||
for node_id, data in self._topology.items():
|
||||
result[node_id] = {
|
||||
'neighbors': list(data.get('neighbors', set())),
|
||||
'hop_count': data.get('hop_count'),
|
||||
'msg_count': data.get('msg_count', 0),
|
||||
'last_seen': data.get('last_seen'),
|
||||
}
|
||||
return result
|
||||
|
||||
def connect(self, device: str | None = None, connection_type: str = 'serial',
|
||||
hostname: str | None = None) -> bool:
|
||||
"""
|
||||
@@ -463,6 +495,14 @@ class MeshtasticClient:
|
||||
# Track node from packet (always, even for filtered messages)
|
||||
self._track_node_from_packet(packet, decoded, portnum)
|
||||
|
||||
# Record topology route
|
||||
if from_num and to_num:
|
||||
self.record_message_route(
|
||||
self._format_node_id(from_num),
|
||||
self._format_node_id(to_num),
|
||||
packet.get('hopLimit'),
|
||||
)
|
||||
|
||||
# Parse traceroute responses
|
||||
if portnum == 'TRACEROUTE_APP':
|
||||
self._handle_traceroute_response(packet, decoded)
|
||||
|
||||
@@ -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
@@ -156,7 +156,9 @@ class WeatherSatDecoder:
|
||||
self._process: subprocess.Popen | None = None
|
||||
self._running = False
|
||||
self._lock = threading.Lock()
|
||||
self._pty_lock = threading.Lock()
|
||||
self._images_lock = threading.Lock()
|
||||
self._stop_event = threading.Event()
|
||||
self._callback: Callable[[CaptureProgress], None] | None = None
|
||||
self._output_dir = Path(output_dir) if output_dir else Path('data/weather_sat')
|
||||
self._images: list[WeatherSatImage] = []
|
||||
@@ -212,6 +214,16 @@ class WeatherSatDecoder:
|
||||
)
|
||||
return None
|
||||
|
||||
def _close_pty(self) -> None:
|
||||
"""Close the PTY master fd in a thread-safe manner."""
|
||||
with self._pty_lock:
|
||||
if self._pty_master_fd is not None:
|
||||
try:
|
||||
os.close(self._pty_master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
self._pty_master_fd = None
|
||||
|
||||
def set_callback(self, callback: Callable[[CaptureProgress], None]) -> None:
|
||||
"""Set callback for capture progress updates."""
|
||||
self._callback = callback
|
||||
@@ -292,6 +304,7 @@ class WeatherSatDecoder:
|
||||
self._current_mode = sat_info['mode']
|
||||
self._capture_start_time = time.time()
|
||||
self._capture_phase = 'decoding'
|
||||
self._stop_event.clear()
|
||||
|
||||
try:
|
||||
self._running = True
|
||||
@@ -372,6 +385,7 @@ class WeatherSatDecoder:
|
||||
self._device_index = device_index
|
||||
self._capture_start_time = time.time()
|
||||
self._capture_phase = 'tuning'
|
||||
self._stop_event.clear()
|
||||
|
||||
try:
|
||||
self._running = True
|
||||
@@ -781,13 +795,11 @@ class WeatherSatDecoder:
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading SatDump output: {e}")
|
||||
finally:
|
||||
# Close PTY master fd
|
||||
if self._pty_master_fd is not None:
|
||||
try:
|
||||
os.close(self._pty_master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
self._pty_master_fd = None
|
||||
# Close PTY master fd (thread-safe)
|
||||
self._close_pty()
|
||||
|
||||
# Signal watcher thread to do final scan and exit
|
||||
self._stop_event.set()
|
||||
|
||||
# Process ended — release resources
|
||||
was_running = self._running
|
||||
@@ -795,17 +807,38 @@ class WeatherSatDecoder:
|
||||
elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0
|
||||
|
||||
if was_running:
|
||||
self._capture_phase = 'complete'
|
||||
self._emit_progress(CaptureProgress(
|
||||
status='complete',
|
||||
satellite=self._current_satellite,
|
||||
frequency=self._current_frequency,
|
||||
mode=self._current_mode,
|
||||
message=f"Capture complete ({elapsed}s)",
|
||||
elapsed_seconds=elapsed,
|
||||
log_type='info',
|
||||
capture_phase='complete',
|
||||
))
|
||||
# Collect exit status (returncode is only set after poll/wait)
|
||||
if self._process and self._process.returncode is None:
|
||||
try:
|
||||
self._process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._process.kill()
|
||||
self._process.wait()
|
||||
retcode = self._process.returncode if self._process else None
|
||||
if retcode and retcode != 0:
|
||||
self._capture_phase = 'error'
|
||||
self._emit_progress(CaptureProgress(
|
||||
status='error',
|
||||
satellite=self._current_satellite,
|
||||
frequency=self._current_frequency,
|
||||
mode=self._current_mode,
|
||||
message=f"SatDump crashed (exit code {retcode}). Check SatDump installation and SDR device.",
|
||||
elapsed_seconds=elapsed,
|
||||
log_type='error',
|
||||
capture_phase='error',
|
||||
))
|
||||
else:
|
||||
self._capture_phase = 'complete'
|
||||
self._emit_progress(CaptureProgress(
|
||||
status='complete',
|
||||
satellite=self._current_satellite,
|
||||
frequency=self._current_frequency,
|
||||
mode=self._current_mode,
|
||||
message=f"Capture complete ({elapsed}s)",
|
||||
elapsed_seconds=elapsed,
|
||||
log_type='info',
|
||||
capture_phase='complete',
|
||||
))
|
||||
|
||||
# Notify route layer to release SDR device
|
||||
if self._on_complete_callback:
|
||||
@@ -822,63 +855,79 @@ class WeatherSatDecoder:
|
||||
known_files: set[str] = set()
|
||||
|
||||
while self._running:
|
||||
time.sleep(2)
|
||||
self._scan_output_dir(known_files)
|
||||
# Use stop_event for faster wakeup on process exit
|
||||
if self._stop_event.wait(timeout=2):
|
||||
break
|
||||
|
||||
try:
|
||||
# Recursively scan for image files
|
||||
for ext in ('*.png', '*.jpg', '*.jpeg'):
|
||||
for filepath in self._capture_output_dir.rglob(ext):
|
||||
file_key = str(filepath)
|
||||
if file_key in known_files:
|
||||
# Final scan — SatDump writes images at the end of processing,
|
||||
# often after the process has already exited. Do multiple scans
|
||||
# with a short delay to catch late-written files.
|
||||
for _ in range(3):
|
||||
time.sleep(0.5)
|
||||
self._scan_output_dir(known_files)
|
||||
|
||||
def _scan_output_dir(self, known_files: set[str]) -> None:
|
||||
"""Scan capture output directory for new image files."""
|
||||
if not self._capture_output_dir:
|
||||
return
|
||||
|
||||
try:
|
||||
# Recursively scan for image files
|
||||
for ext in ('*.png', '*.jpg', '*.jpeg'):
|
||||
for filepath in self._capture_output_dir.rglob(ext):
|
||||
file_key = str(filepath)
|
||||
if file_key in known_files:
|
||||
continue
|
||||
|
||||
# Skip tiny files (likely incomplete)
|
||||
try:
|
||||
stat = filepath.stat()
|
||||
if stat.st_size < 1000:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# Skip tiny files (likely incomplete)
|
||||
try:
|
||||
stat = filepath.stat()
|
||||
if stat.st_size < 1000:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
# Determine product type from filename/path
|
||||
product = self._parse_product_name(filepath)
|
||||
|
||||
known_files.add(file_key)
|
||||
# Copy image to main output dir for serving
|
||||
serve_name = f"{self._current_satellite}_{filepath.stem}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
|
||||
serve_path = self._output_dir / serve_name
|
||||
try:
|
||||
shutil.copy2(filepath, serve_path)
|
||||
except OSError:
|
||||
# Copy failed — don't mark as known so it can be retried
|
||||
continue
|
||||
|
||||
# Determine product type from filename/path
|
||||
product = self._parse_product_name(filepath)
|
||||
# Only mark as known after successful copy
|
||||
known_files.add(file_key)
|
||||
|
||||
# Copy image to main output dir for serving
|
||||
serve_name = f"{self._current_satellite}_{filepath.stem}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
|
||||
serve_path = self._output_dir / serve_name
|
||||
try:
|
||||
shutil.copy2(filepath, serve_path)
|
||||
except OSError:
|
||||
serve_path = filepath
|
||||
serve_name = filepath.name
|
||||
image = WeatherSatImage(
|
||||
filename=serve_name,
|
||||
path=serve_path,
|
||||
satellite=self._current_satellite,
|
||||
mode=self._current_mode,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
frequency=self._current_frequency,
|
||||
size_bytes=stat.st_size,
|
||||
product=product,
|
||||
)
|
||||
with self._images_lock:
|
||||
self._images.append(image)
|
||||
|
||||
image = WeatherSatImage(
|
||||
filename=serve_name,
|
||||
path=serve_path,
|
||||
satellite=self._current_satellite,
|
||||
mode=self._current_mode,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
frequency=self._current_frequency,
|
||||
size_bytes=stat.st_size,
|
||||
product=product,
|
||||
)
|
||||
with self._images_lock:
|
||||
self._images.append(image)
|
||||
logger.info(f"New weather satellite image: {serve_name} ({product})")
|
||||
self._emit_progress(CaptureProgress(
|
||||
status='complete',
|
||||
satellite=self._current_satellite,
|
||||
frequency=self._current_frequency,
|
||||
mode=self._current_mode,
|
||||
message=f'Image decoded: {product}',
|
||||
image=image,
|
||||
))
|
||||
|
||||
logger.info(f"New weather satellite image: {serve_name} ({product})")
|
||||
self._emit_progress(CaptureProgress(
|
||||
status='complete',
|
||||
satellite=self._current_satellite,
|
||||
frequency=self._current_frequency,
|
||||
mode=self._current_mode,
|
||||
message=f'Image decoded: {product}',
|
||||
image=image,
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error watching images: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error scanning for images: {e}")
|
||||
|
||||
def _parse_product_name(self, filepath: Path) -> str:
|
||||
"""Parse a human-readable product name from the image filepath."""
|
||||
@@ -916,13 +965,8 @@ class WeatherSatDecoder:
|
||||
"""Stop weather satellite capture."""
|
||||
with self._lock:
|
||||
self._running = False
|
||||
|
||||
if self._pty_master_fd is not None:
|
||||
try:
|
||||
os.close(self._pty_master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
self._pty_master_fd = None
|
||||
self._stop_event.set()
|
||||
self._close_pty()
|
||||
|
||||
if self._process:
|
||||
safe_terminate(self._process)
|
||||
|
||||
Reference in New Issue
Block a user