mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Add ADS-B history persistence and reporting UI
This commit is contained in:
@@ -33,6 +33,9 @@ htmlcov/
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Local Postgres data
|
||||
pgdata/
|
||||
|
||||
# Captured files (don't include in image)
|
||||
*.cap
|
||||
*.pcap
|
||||
|
||||
19
.gitignore
vendored
19
.gitignore
vendored
@@ -10,9 +10,17 @@ venv/
|
||||
ENV/
|
||||
uv.lock
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
pager_messages.log
|
||||
# Logs
|
||||
*.log
|
||||
pager_messages.log
|
||||
|
||||
# Local data
|
||||
downloads/
|
||||
pgdata/
|
||||
|
||||
# Local data
|
||||
downloads/
|
||||
pgdata/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
@@ -34,7 +42,4 @@ build/
|
||||
uv.lock
|
||||
*.db
|
||||
*.sqlite3
|
||||
intercept.db
|
||||
|
||||
# Agent Files
|
||||
.agent
|
||||
intercept.db
|
||||
|
||||
15
config.py
15
config.py
@@ -126,9 +126,18 @@ AIRODUMP_HEADER_LINES = _get_env_int('AIRODUMP_HEADER_LINES', 2)
|
||||
BT_SCAN_TIMEOUT = _get_env_int('BT_SCAN_TIMEOUT', 10)
|
||||
BT_UPDATE_INTERVAL = _get_env_float('BT_UPDATE_INTERVAL', 2.0)
|
||||
|
||||
# ADS-B settings
|
||||
ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
|
||||
ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0)
|
||||
# ADS-B settings
|
||||
ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
|
||||
ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0)
|
||||
ADSB_HISTORY_ENABLED = _get_env_bool('ADSB_HISTORY_ENABLED', False)
|
||||
ADSB_DB_HOST = _get_env('ADSB_DB_HOST', 'localhost')
|
||||
ADSB_DB_PORT = _get_env_int('ADSB_DB_PORT', 5432)
|
||||
ADSB_DB_NAME = _get_env('ADSB_DB_NAME', 'intercept_adsb')
|
||||
ADSB_DB_USER = _get_env('ADSB_DB_USER', 'intercept')
|
||||
ADSB_DB_PASSWORD = _get_env('ADSB_DB_PASSWORD', 'intercept')
|
||||
ADSB_HISTORY_BATCH_SIZE = _get_env_int('ADSB_HISTORY_BATCH_SIZE', 500)
|
||||
ADSB_HISTORY_FLUSH_INTERVAL = _get_env_float('ADSB_HISTORY_FLUSH_INTERVAL', 1.0)
|
||||
ADSB_HISTORY_QUEUE_SIZE = _get_env_int('ADSB_HISTORY_QUEUE_SIZE', 50000)
|
||||
|
||||
# Satellite settings
|
||||
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
|
||||
|
||||
@@ -5,6 +5,8 @@ services:
|
||||
intercept:
|
||||
build: .
|
||||
container_name: intercept
|
||||
depends_on:
|
||||
- adsb_db
|
||||
ports:
|
||||
- "5050:5050"
|
||||
# Privileged mode required for USB SDR device access
|
||||
@@ -22,6 +24,12 @@ services:
|
||||
- INTERCEPT_HOST=0.0.0.0
|
||||
- INTERCEPT_PORT=5050
|
||||
- INTERCEPT_LOG_LEVEL=INFO
|
||||
- INTERCEPT_ADSB_HISTORY_ENABLED=true
|
||||
- INTERCEPT_ADSB_DB_HOST=adsb_db
|
||||
- INTERCEPT_ADSB_DB_PORT=5432
|
||||
- INTERCEPT_ADSB_DB_NAME=intercept_adsb
|
||||
- INTERCEPT_ADSB_DB_USER=intercept
|
||||
- INTERCEPT_ADSB_DB_PASSWORD=intercept
|
||||
# Network mode for WiFi scanning (requires host network)
|
||||
# network_mode: host
|
||||
restart: unless-stopped
|
||||
@@ -32,6 +40,23 @@ services:
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
adsb_db:
|
||||
image: postgres:16-alpine
|
||||
container_name: intercept-adsb-db
|
||||
environment:
|
||||
- POSTGRES_DB=intercept_adsb
|
||||
- POSTGRES_USER=intercept
|
||||
- POSTGRES_PASSWORD=intercept
|
||||
volumes:
|
||||
# Move this path to the USB drive later for larger retention
|
||||
- ./pgdata:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U intercept -d intercept_adsb"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Optional: Add volume for persistent SQLite database
|
||||
# volumes:
|
||||
# intercept-data:
|
||||
|
||||
684
routes/adsb.py
684
routes/adsb.py
@@ -2,27 +2,39 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, render_template
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import adsb_logger as logger
|
||||
from utils.validation import (
|
||||
validate_device_index, validate_gain,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, render_template
|
||||
from flask import make_response
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
import app as app_module
|
||||
from config import (
|
||||
ADSB_DB_HOST,
|
||||
ADSB_DB_NAME,
|
||||
ADSB_DB_PASSWORD,
|
||||
ADSB_DB_PORT,
|
||||
ADSB_DB_USER,
|
||||
ADSB_HISTORY_ENABLED,
|
||||
)
|
||||
from utils.logging import adsb_logger as logger
|
||||
from utils.validation import (
|
||||
validate_device_index, validate_gain,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
from utils.constants import (
|
||||
ADSB_SBS_PORT,
|
||||
ADSB_TERMINATE_TIMEOUT,
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
@@ -33,9 +45,10 @@ from utils.constants import (
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
SOCKET_CONNECT_TIMEOUT,
|
||||
ADSB_UPDATE_INTERVAL,
|
||||
DUMP1090_START_WAIT,
|
||||
)
|
||||
from utils import aircraft_db
|
||||
DUMP1090_START_WAIT,
|
||||
)
|
||||
from utils import aircraft_db
|
||||
from utils.adsb_history import adsb_history_writer, adsb_snapshot_writer, _ensure_adsb_schema
|
||||
|
||||
adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb')
|
||||
|
||||
@@ -56,7 +69,7 @@ _looked_up_icaos: set[str] = set()
|
||||
aircraft_db.load_database()
|
||||
|
||||
# Common installation paths for dump1090 (when not in PATH)
|
||||
DUMP1090_PATHS = [
|
||||
DUMP1090_PATHS = [
|
||||
# Homebrew on Apple Silicon (M1/M2/M3)
|
||||
'/opt/homebrew/bin/dump1090',
|
||||
'/opt/homebrew/bin/dump1090-fa',
|
||||
@@ -69,8 +82,202 @@ DUMP1090_PATHS = [
|
||||
'/usr/bin/dump1090',
|
||||
'/usr/bin/dump1090-fa',
|
||||
'/usr/bin/dump1090-mutability',
|
||||
]
|
||||
|
||||
]
|
||||
|
||||
|
||||
def _get_part(parts: list[str], index: int) -> str | None:
|
||||
if len(parts) <= index:
|
||||
return None
|
||||
value = parts[index].strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _parse_sbs_timestamp(date_str: str | None, time_str: str | None) -> datetime | None:
|
||||
if not date_str or not time_str:
|
||||
return None
|
||||
combined = f"{date_str} {time_str}"
|
||||
for fmt in ("%Y/%m/%d %H:%M:%S.%f", "%Y/%m/%d %H:%M:%S"):
|
||||
try:
|
||||
parsed = datetime.strptime(combined, fmt)
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _parse_int(value: str | None) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(float(value))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_float(value: str | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _build_history_record(
|
||||
parts: list[str],
|
||||
msg_type: str,
|
||||
icao: str,
|
||||
msg_time: datetime | None,
|
||||
logged_time: datetime | None,
|
||||
service_addr: str,
|
||||
raw_line: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
'received_at': datetime.now(timezone.utc),
|
||||
'msg_time': msg_time,
|
||||
'logged_time': logged_time,
|
||||
'icao': icao,
|
||||
'msg_type': _parse_int(msg_type),
|
||||
'callsign': _get_part(parts, 10),
|
||||
'altitude': _parse_int(_get_part(parts, 11)),
|
||||
'speed': _parse_int(_get_part(parts, 12)),
|
||||
'heading': _parse_int(_get_part(parts, 13)),
|
||||
'vertical_rate': _parse_int(_get_part(parts, 16)),
|
||||
'lat': _parse_float(_get_part(parts, 14)),
|
||||
'lon': _parse_float(_get_part(parts, 15)),
|
||||
'squawk': _get_part(parts, 17),
|
||||
'session_id': _get_part(parts, 2),
|
||||
'aircraft_id': _get_part(parts, 3),
|
||||
'flight_id': _get_part(parts, 5),
|
||||
'raw_line': raw_line,
|
||||
'source_host': service_addr,
|
||||
}
|
||||
|
||||
|
||||
_history_schema_checked = False
|
||||
|
||||
|
||||
def _get_history_connection():
|
||||
return psycopg2.connect(
|
||||
host=ADSB_DB_HOST,
|
||||
port=ADSB_DB_PORT,
|
||||
dbname=ADSB_DB_NAME,
|
||||
user=ADSB_DB_USER,
|
||||
password=ADSB_DB_PASSWORD,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_history_schema() -> None:
|
||||
global _history_schema_checked
|
||||
if _history_schema_checked:
|
||||
return
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
_ensure_adsb_schema(conn)
|
||||
_history_schema_checked = True
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B schema check failed: %s", exc)
|
||||
|
||||
|
||||
def _parse_int_param(value: str | None, default: int, min_value: int | None = None, max_value: int | None = None) -> int:
|
||||
try:
|
||||
parsed = int(value) if value is not None else default
|
||||
except (ValueError, TypeError):
|
||||
parsed = default
|
||||
if min_value is not None:
|
||||
parsed = max(min_value, parsed)
|
||||
if max_value is not None:
|
||||
parsed = min(max_value, parsed)
|
||||
return parsed
|
||||
|
||||
|
||||
def _get_active_session() -> dict[str, Any] | None:
|
||||
if not ADSB_HISTORY_ENABLED:
|
||||
return None
|
||||
_ensure_history_schema()
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM adsb_sessions
|
||||
WHERE ended_at IS NULL
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
return cur.fetchone()
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B session lookup failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _record_session_start(
|
||||
*,
|
||||
device_index: int | None,
|
||||
sdr_type: str | None,
|
||||
remote_host: str | None,
|
||||
remote_port: int | None,
|
||||
start_source: str | None,
|
||||
started_by: str | None,
|
||||
) -> dict[str, Any] | None:
|
||||
if not ADSB_HISTORY_ENABLED:
|
||||
return None
|
||||
_ensure_history_schema()
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO adsb_sessions (
|
||||
device_index,
|
||||
sdr_type,
|
||||
remote_host,
|
||||
remote_port,
|
||||
start_source,
|
||||
started_by
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
device_index,
|
||||
sdr_type,
|
||||
remote_host,
|
||||
remote_port,
|
||||
start_source,
|
||||
started_by,
|
||||
),
|
||||
)
|
||||
return cur.fetchone()
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B session start record failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def _record_session_stop(*, stop_source: str | None, stopped_by: str | None) -> dict[str, Any] | None:
|
||||
if not ADSB_HISTORY_ENABLED:
|
||||
return None
|
||||
_ensure_history_schema()
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE adsb_sessions
|
||||
SET ended_at = NOW(),
|
||||
stop_source = COALESCE(%s, stop_source),
|
||||
stopped_by = COALESCE(%s, stopped_by)
|
||||
WHERE ended_at IS NULL
|
||||
RETURNING *
|
||||
""",
|
||||
(stop_source, stopped_by),
|
||||
)
|
||||
return cur.fetchone()
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B session stop record failed: %s", exc)
|
||||
return None
|
||||
|
||||
def find_dump1090():
|
||||
"""Find dump1090 binary, checking PATH and common locations."""
|
||||
@@ -100,12 +307,15 @@ def check_dump1090_service():
|
||||
return None
|
||||
|
||||
|
||||
def parse_sbs_stream(service_addr):
|
||||
"""Parse SBS format data from dump1090 SBS port."""
|
||||
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received, _sbs_error_logged
|
||||
|
||||
host, port = service_addr.split(':')
|
||||
port = int(port)
|
||||
def parse_sbs_stream(service_addr):
|
||||
"""Parse SBS format data from dump1090 SBS port."""
|
||||
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received, _sbs_error_logged
|
||||
|
||||
adsb_history_writer.start()
|
||||
adsb_snapshot_writer.start()
|
||||
|
||||
host, port = service_addr.split(':')
|
||||
port = int(port)
|
||||
|
||||
logger.info(f"SBS stream parser started, connecting to {host}:{port}")
|
||||
adsb_connected = False
|
||||
@@ -147,18 +357,31 @@ def parse_sbs_stream(service_addr):
|
||||
if adsb_lines_received <= 3:
|
||||
logger.info(f"SBS line {adsb_lines_received}: {line[:100]}")
|
||||
|
||||
parts = line.split(',')
|
||||
if len(parts) < 11 or parts[0] != 'MSG':
|
||||
if adsb_lines_received <= 5:
|
||||
logger.debug(f"Skipping non-MSG line: {line[:50]}")
|
||||
continue
|
||||
|
||||
msg_type = parts[1]
|
||||
icao = parts[4].upper()
|
||||
if not icao:
|
||||
continue
|
||||
|
||||
aircraft = app_module.adsb_aircraft.get(icao) or {'icao': icao}
|
||||
parts = line.split(',')
|
||||
if len(parts) < 11 or parts[0] != 'MSG':
|
||||
if adsb_lines_received <= 5:
|
||||
logger.debug(f"Skipping non-MSG line: {line[:50]}")
|
||||
continue
|
||||
|
||||
msg_type = parts[1]
|
||||
icao = parts[4].upper()
|
||||
if not icao:
|
||||
continue
|
||||
|
||||
msg_time = _parse_sbs_timestamp(_get_part(parts, 6), _get_part(parts, 7))
|
||||
logged_time = _parse_sbs_timestamp(_get_part(parts, 8), _get_part(parts, 9))
|
||||
history_record = _build_history_record(
|
||||
parts=parts,
|
||||
msg_type=msg_type,
|
||||
icao=icao,
|
||||
msg_time=msg_time,
|
||||
logged_time=logged_time,
|
||||
service_addr=service_addr,
|
||||
raw_line=line,
|
||||
)
|
||||
adsb_history_writer.enqueue(history_record)
|
||||
|
||||
aircraft = app_module.adsb_aircraft.get(icao) or {'icao': icao}
|
||||
|
||||
# Look up aircraft type from database (once per ICAO)
|
||||
if icao not in _looked_up_icaos:
|
||||
@@ -229,12 +452,30 @@ def parse_sbs_stream(service_addr):
|
||||
|
||||
now = time.time()
|
||||
if now - last_update >= ADSB_UPDATE_INTERVAL:
|
||||
for update_icao in pending_updates:
|
||||
if update_icao in app_module.adsb_aircraft:
|
||||
app_module.adsb_queue.put({
|
||||
'type': 'aircraft',
|
||||
**app_module.adsb_aircraft[update_icao]
|
||||
})
|
||||
for update_icao in pending_updates:
|
||||
if update_icao in app_module.adsb_aircraft:
|
||||
snapshot = app_module.adsb_aircraft[update_icao]
|
||||
app_module.adsb_queue.put({
|
||||
'type': 'aircraft',
|
||||
**snapshot
|
||||
})
|
||||
adsb_snapshot_writer.enqueue({
|
||||
'captured_at': datetime.now(timezone.utc),
|
||||
'icao': update_icao,
|
||||
'callsign': snapshot.get('callsign'),
|
||||
'registration': snapshot.get('registration'),
|
||||
'type_code': snapshot.get('type_code'),
|
||||
'type_desc': snapshot.get('type_desc'),
|
||||
'altitude': snapshot.get('altitude'),
|
||||
'speed': snapshot.get('speed'),
|
||||
'heading': snapshot.get('heading'),
|
||||
'vertical_rate': snapshot.get('vertical_rate'),
|
||||
'lat': snapshot.get('lat'),
|
||||
'lon': snapshot.get('lon'),
|
||||
'squawk': snapshot.get('squawk'),
|
||||
'source_host': service_addr,
|
||||
'snapshot': snapshot,
|
||||
})
|
||||
pending_updates.clear()
|
||||
last_update = now
|
||||
|
||||
@@ -282,18 +523,18 @@ def check_adsb_tools():
|
||||
})
|
||||
|
||||
|
||||
@adsb_bp.route('/status')
|
||||
def adsb_status():
|
||||
"""Get ADS-B tracking status for debugging."""
|
||||
@adsb_bp.route('/status')
|
||||
def adsb_status():
|
||||
"""Get ADS-B tracking status for debugging."""
|
||||
# Check if dump1090 process is still running
|
||||
dump1090_running = False
|
||||
if app_module.adsb_process:
|
||||
dump1090_running = app_module.adsb_process.poll() is None
|
||||
|
||||
return jsonify({
|
||||
'tracking_active': adsb_using_service,
|
||||
'active_device': adsb_active_device,
|
||||
'connected_to_sbs': adsb_connected,
|
||||
return jsonify({
|
||||
'tracking_active': adsb_using_service,
|
||||
'active_device': adsb_active_device,
|
||||
'connected_to_sbs': adsb_connected,
|
||||
'messages_received': adsb_messages_received,
|
||||
'bytes_received': adsb_bytes_received,
|
||||
'lines_received': adsb_lines_received,
|
||||
@@ -303,25 +544,50 @@ def adsb_status():
|
||||
'queue_size': app_module.adsb_queue.qsize(),
|
||||
'dump1090_path': find_dump1090(),
|
||||
'dump1090_running': dump1090_running,
|
||||
'port_30003_open': check_dump1090_service() is not None
|
||||
})
|
||||
'port_30003_open': check_dump1090_service() is not None
|
||||
})
|
||||
|
||||
|
||||
@adsb_bp.route('/session')
|
||||
def adsb_session():
|
||||
"""Get ADS-B session status and uptime."""
|
||||
session = _get_active_session()
|
||||
uptime_seconds = None
|
||||
if session and session.get('started_at'):
|
||||
started_at = session['started_at']
|
||||
if isinstance(started_at, datetime):
|
||||
uptime_seconds = int((datetime.now(timezone.utc) - started_at).total_seconds())
|
||||
return jsonify({
|
||||
'tracking_active': adsb_using_service,
|
||||
'connected_to_sbs': adsb_connected,
|
||||
'active_device': adsb_active_device,
|
||||
'session': session,
|
||||
'uptime_seconds': uptime_seconds,
|
||||
})
|
||||
|
||||
|
||||
@adsb_bp.route('/start', methods=['POST'])
|
||||
def start_adsb():
|
||||
"""Start ADS-B tracking."""
|
||||
global adsb_using_service, adsb_active_device
|
||||
|
||||
with app_module.adsb_lock:
|
||||
if adsb_using_service:
|
||||
return jsonify({'status': 'already_running', 'message': 'ADS-B tracking already active'}), 409
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
gain = int(validate_gain(data.get('gain', '40')))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
def start_adsb():
|
||||
"""Start ADS-B tracking."""
|
||||
global adsb_using_service, adsb_active_device
|
||||
|
||||
with app_module.adsb_lock:
|
||||
if adsb_using_service:
|
||||
session = _get_active_session()
|
||||
return jsonify({
|
||||
'status': 'already_running',
|
||||
'message': 'ADS-B tracking already active',
|
||||
'session': session
|
||||
}), 409
|
||||
|
||||
data = request.json or {}
|
||||
start_source = data.get('source')
|
||||
started_by = request.remote_addr
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
gain = int(validate_gain(data.get('gain', '40')))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
@@ -337,21 +603,45 @@ def start_adsb():
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
remote_addr = f"{remote_sbs_host}:{remote_sbs_port}"
|
||||
logger.info(f"Connecting to remote dump1090 SBS at {remote_addr}")
|
||||
adsb_using_service = True
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=(remote_addr,), daemon=True)
|
||||
thread.start()
|
||||
return jsonify({'status': 'started', 'message': f'Connected to remote dump1090 at {remote_addr}'})
|
||||
remote_addr = f"{remote_sbs_host}:{remote_sbs_port}"
|
||||
logger.info(f"Connecting to remote dump1090 SBS at {remote_addr}")
|
||||
adsb_using_service = True
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=(remote_addr,), daemon=True)
|
||||
thread.start()
|
||||
session = _record_session_start(
|
||||
device_index=device,
|
||||
sdr_type='remote',
|
||||
remote_host=remote_sbs_host,
|
||||
remote_port=remote_sbs_port,
|
||||
start_source=start_source,
|
||||
started_by=started_by,
|
||||
)
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'message': f'Connected to remote dump1090 at {remote_addr}',
|
||||
'session': session
|
||||
})
|
||||
|
||||
# Check if dump1090 is already running externally (e.g., user started it manually)
|
||||
existing_service = check_dump1090_service()
|
||||
if existing_service:
|
||||
logger.info(f"Found existing dump1090 service at {existing_service}")
|
||||
adsb_using_service = True
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=(existing_service,), daemon=True)
|
||||
thread.start()
|
||||
return jsonify({'status': 'started', 'message': 'Connected to existing dump1090 service'})
|
||||
if existing_service:
|
||||
logger.info(f"Found existing dump1090 service at {existing_service}")
|
||||
adsb_using_service = True
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=(existing_service,), daemon=True)
|
||||
thread.start()
|
||||
session = _record_session_start(
|
||||
device_index=device,
|
||||
sdr_type='external',
|
||||
remote_host='localhost',
|
||||
remote_port=ADSB_SBS_PORT,
|
||||
start_source=start_source,
|
||||
started_by=started_by,
|
||||
)
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'message': 'Connected to existing dump1090 service',
|
||||
'session': session
|
||||
})
|
||||
|
||||
# Get SDR type from request
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
@@ -402,10 +692,10 @@ def start_adsb():
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
cmd[0] = dump1090_path
|
||||
|
||||
try:
|
||||
logger.info(f"Starting dump1090 with device index {device}: {' '.join(cmd)}")
|
||||
app_module.adsb_process = subprocess.Popen(
|
||||
cmd,
|
||||
try:
|
||||
logger.info(f"Starting dump1090 with device index {device}: {' '.join(cmd)}")
|
||||
app_module.adsb_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True # Create new process group for clean shutdown
|
||||
@@ -432,24 +722,40 @@ def start_adsb():
|
||||
error_msg += f' Error: {stderr_output[:200]}'
|
||||
return jsonify({'status': 'error', 'message': error_msg})
|
||||
|
||||
adsb_using_service = True
|
||||
adsb_active_device = device # Track which device is being used
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
|
||||
thread.start()
|
||||
|
||||
return jsonify({'status': 'started', 'message': 'ADS-B tracking started', 'device': device})
|
||||
adsb_using_service = True
|
||||
adsb_active_device = device # Track which device is being used
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
|
||||
thread.start()
|
||||
|
||||
session = _record_session_start(
|
||||
device_index=device,
|
||||
sdr_type=sdr_type.value,
|
||||
remote_host=None,
|
||||
remote_port=None,
|
||||
start_source=start_source,
|
||||
started_by=started_by,
|
||||
)
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'message': 'ADS-B tracking started',
|
||||
'device': device,
|
||||
'session': session
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@adsb_bp.route('/stop', methods=['POST'])
|
||||
def stop_adsb():
|
||||
"""Stop ADS-B tracking."""
|
||||
global adsb_using_service, adsb_active_device
|
||||
|
||||
with app_module.adsb_lock:
|
||||
if app_module.adsb_process:
|
||||
try:
|
||||
def stop_adsb():
|
||||
"""Stop ADS-B tracking."""
|
||||
global adsb_using_service, adsb_active_device
|
||||
data = request.json or {}
|
||||
stop_source = data.get('source')
|
||||
stopped_by = request.remote_addr
|
||||
|
||||
with app_module.adsb_lock:
|
||||
if app_module.adsb_process:
|
||||
try:
|
||||
# Kill the entire process group to ensure all child processes are terminated
|
||||
pgid = os.getpgid(app_module.adsb_process.pid)
|
||||
os.killpg(pgid, 15) # SIGTERM
|
||||
@@ -463,12 +769,13 @@ def stop_adsb():
|
||||
pass
|
||||
app_module.adsb_process = None
|
||||
logger.info("ADS-B process stopped")
|
||||
adsb_using_service = False
|
||||
adsb_active_device = None
|
||||
|
||||
app_module.adsb_aircraft.clear()
|
||||
_looked_up_icaos.clear()
|
||||
return jsonify({'status': 'stopped'})
|
||||
adsb_using_service = False
|
||||
adsb_active_device = None
|
||||
|
||||
app_module.adsb_aircraft.clear()
|
||||
_looked_up_icaos.clear()
|
||||
session = _record_session_stop(stop_source=stop_source, stopped_by=stopped_by)
|
||||
return jsonify({'status': 'stopped', 'session': session})
|
||||
|
||||
|
||||
@adsb_bp.route('/stream')
|
||||
@@ -494,10 +801,165 @@ def stream_adsb():
|
||||
return response
|
||||
|
||||
|
||||
@adsb_bp.route('/dashboard')
|
||||
def adsb_dashboard():
|
||||
"""Popout ADS-B dashboard."""
|
||||
return render_template('adsb_dashboard.html')
|
||||
@adsb_bp.route('/dashboard')
|
||||
def adsb_dashboard():
|
||||
"""Popout ADS-B dashboard."""
|
||||
return render_template('adsb_dashboard.html')
|
||||
|
||||
|
||||
@adsb_bp.route('/history')
|
||||
def adsb_history():
|
||||
"""ADS-B history reporting dashboard."""
|
||||
resp = make_response(render_template('adsb_history.html', history_enabled=ADSB_HISTORY_ENABLED))
|
||||
resp.headers['Cache-Control'] = 'no-store'
|
||||
return resp
|
||||
|
||||
|
||||
@adsb_bp.route('/history/summary')
|
||||
def adsb_history_summary():
|
||||
"""Summary stats for ADS-B history window."""
|
||||
if not ADSB_HISTORY_ENABLED:
|
||||
return jsonify({'error': 'ADS-B history is disabled'}), 503
|
||||
_ensure_history_schema()
|
||||
|
||||
since_minutes = _parse_int_param(request.args.get('since_minutes'), 60, 1, 10080)
|
||||
window = f'{since_minutes} minutes'
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM adsb_messages WHERE received_at >= NOW() - INTERVAL %s) AS message_count,
|
||||
(SELECT COUNT(*) FROM adsb_snapshots WHERE captured_at >= NOW() - INTERVAL %s) AS snapshot_count,
|
||||
(SELECT COUNT(DISTINCT icao) FROM adsb_snapshots WHERE captured_at >= NOW() - INTERVAL %s) AS aircraft_count,
|
||||
(SELECT MIN(captured_at) FROM adsb_snapshots WHERE captured_at >= NOW() - INTERVAL %s) AS first_seen,
|
||||
(SELECT MAX(captured_at) FROM adsb_snapshots WHERE captured_at >= NOW() - INTERVAL %s) AS last_seen
|
||||
"""
|
||||
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(sql, (window, window, window, window, window))
|
||||
row = cur.fetchone() or {}
|
||||
return jsonify(row)
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B history summary failed: %s", exc)
|
||||
return jsonify({'error': 'History database unavailable'}), 503
|
||||
|
||||
|
||||
@adsb_bp.route('/history/aircraft')
|
||||
def adsb_history_aircraft():
|
||||
"""List latest aircraft snapshots for a time window."""
|
||||
if not ADSB_HISTORY_ENABLED:
|
||||
return jsonify({'error': 'ADS-B history is disabled'}), 503
|
||||
_ensure_history_schema()
|
||||
|
||||
since_minutes = _parse_int_param(request.args.get('since_minutes'), 60, 1, 10080)
|
||||
limit = _parse_int_param(request.args.get('limit'), 200, 1, 2000)
|
||||
search = (request.args.get('search') or '').strip()
|
||||
window = f'{since_minutes} minutes'
|
||||
pattern = f'%{search}%'
|
||||
|
||||
sql = """
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT DISTINCT ON (icao)
|
||||
icao,
|
||||
callsign,
|
||||
registration,
|
||||
type_code,
|
||||
type_desc,
|
||||
altitude,
|
||||
speed,
|
||||
heading,
|
||||
vertical_rate,
|
||||
lat,
|
||||
lon,
|
||||
squawk,
|
||||
captured_at AS last_seen
|
||||
FROM adsb_snapshots
|
||||
WHERE captured_at >= NOW() - INTERVAL %s
|
||||
AND (%s = '' OR icao ILIKE %s OR callsign ILIKE %s OR registration ILIKE %s)
|
||||
ORDER BY icao, captured_at DESC
|
||||
) latest
|
||||
ORDER BY last_seen DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(sql, (window, search, pattern, pattern, pattern, limit))
|
||||
rows = cur.fetchall()
|
||||
return jsonify({'aircraft': rows, 'count': len(rows)})
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B history aircraft query failed: %s", exc)
|
||||
return jsonify({'error': 'History database unavailable'}), 503
|
||||
|
||||
|
||||
@adsb_bp.route('/history/timeline')
|
||||
def adsb_history_timeline():
|
||||
"""Timeline snapshots for a specific aircraft."""
|
||||
if not ADSB_HISTORY_ENABLED:
|
||||
return jsonify({'error': 'ADS-B history is disabled'}), 503
|
||||
_ensure_history_schema()
|
||||
|
||||
icao = (request.args.get('icao') or '').strip().upper()
|
||||
if not icao:
|
||||
return jsonify({'error': 'icao is required'}), 400
|
||||
|
||||
since_minutes = _parse_int_param(request.args.get('since_minutes'), 60, 1, 10080)
|
||||
limit = _parse_int_param(request.args.get('limit'), 2000, 1, 20000)
|
||||
window = f'{since_minutes} minutes'
|
||||
|
||||
sql = """
|
||||
SELECT captured_at, altitude, speed, heading, vertical_rate, lat, lon, squawk
|
||||
FROM adsb_snapshots
|
||||
WHERE icao = %s
|
||||
AND captured_at >= NOW() - INTERVAL %s
|
||||
ORDER BY captured_at ASC
|
||||
LIMIT %s
|
||||
"""
|
||||
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(sql, (icao, window, limit))
|
||||
rows = cur.fetchall()
|
||||
return jsonify({'icao': icao, 'timeline': rows, 'count': len(rows)})
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B history timeline query failed: %s", exc)
|
||||
return jsonify({'error': 'History database unavailable'}), 503
|
||||
|
||||
|
||||
@adsb_bp.route('/history/messages')
|
||||
def adsb_history_messages():
|
||||
"""Raw message history for a specific aircraft."""
|
||||
if not ADSB_HISTORY_ENABLED:
|
||||
return jsonify({'error': 'ADS-B history is disabled'}), 503
|
||||
_ensure_history_schema()
|
||||
|
||||
icao = (request.args.get('icao') or '').strip().upper()
|
||||
since_minutes = _parse_int_param(request.args.get('since_minutes'), 30, 1, 10080)
|
||||
limit = _parse_int_param(request.args.get('limit'), 200, 1, 2000)
|
||||
window = f'{since_minutes} minutes'
|
||||
|
||||
sql = """
|
||||
SELECT received_at, msg_type, callsign, altitude, speed, heading, vertical_rate, lat, lon, squawk
|
||||
FROM adsb_messages
|
||||
WHERE received_at >= NOW() - INTERVAL %s
|
||||
AND (%s = '' OR icao = %s)
|
||||
ORDER BY received_at DESC
|
||||
LIMIT %s
|
||||
"""
|
||||
|
||||
try:
|
||||
with _get_history_connection() as conn:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(sql, (window, icao, icao, limit))
|
||||
rows = cur.fetchall()
|
||||
return jsonify({'icao': icao, 'messages': rows, 'count': len(rows)})
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B history message query failed: %s", exc)
|
||||
return jsonify({'error': 'History database unavailable'}), 503
|
||||
|
||||
|
||||
# ============================================
|
||||
|
||||
615
static/css/adsb_history.css
Normal file
615
static/css/adsb_history.css
Normal file
@@ -0,0 +1,615 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-dark: #0a0c10;
|
||||
--bg-panel: #0f1218;
|
||||
--bg-card: #141a24;
|
||||
--border-color: #1f2937;
|
||||
--border-glow: rgba(74, 158, 255, 0.6);
|
||||
--text-primary: #e8eaed;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-dim: #4b5563;
|
||||
--accent-cyan: #4a9eff;
|
||||
--accent-green: #22c55e;
|
||||
--accent-amber: #d4a853;
|
||||
--grid-line: rgba(74, 158, 255, 0.08);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.radar-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.scanline {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
animation: scan 6s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
0% { top: -4px; }
|
||||
100% { top: 100vh; }
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 12px 20px;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.logo span {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
margin-left: 10px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: none;
|
||||
font-size: 11px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--accent-cyan);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.history-shell {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 16px 18px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.summary-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.session-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
background: linear-gradient(120deg, rgba(15, 18, 24, 0.95), rgba(20, 26, 36, 0.95));
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
box-shadow: 0 0 18px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.session-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim);
|
||||
box-shadow: 0 0 12px rgba(75, 85, 99, 0.6);
|
||||
}
|
||||
|
||||
.status-dot.active {
|
||||
background: var(--accent-green);
|
||||
box-shadow: 0 0 14px rgba(34, 197, 94, 0.8);
|
||||
}
|
||||
|
||||
.session-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
|
||||
.session-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.session-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#sessionNotice {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.session-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.session-controls select {
|
||||
background: var(--bg-dark);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.primary-btn.stop {
|
||||
background: var(--accent-amber);
|
||||
color: #0a0c10;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
box-shadow: 0 0 14px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.3px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
align-items: flex-end;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
|
||||
.control-group input,
|
||||
.control-group select {
|
||||
background: var(--bg-dark);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: var(--accent-cyan);
|
||||
border: none;
|
||||
color: #0a0c10;
|
||||
font-weight: 600;
|
||||
padding: 10px 18px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.primary-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 14px rgba(74, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--accent-amber);
|
||||
color: var(--accent-amber);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 1fr) minmax(320px, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 12px;
|
||||
letter-spacing: 1.6px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.panel-meta {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 12px 14px;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.aircraft-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.aircraft-table th,
|
||||
.aircraft-table td {
|
||||
padding: 8px 6px;
|
||||
border-bottom: 1px solid rgba(31, 41, 55, 0.6);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.aircraft-table th {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.aircraft-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.aircraft-row:hover {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.empty-row td,
|
||||
.empty-row {
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
padding: 18px 10px;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
height: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
#altitudeChart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#speedChart,
|
||||
#headingChart,
|
||||
#verticalChart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.timeline-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.timeline-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(31, 41, 55, 0.6);
|
||||
border-radius: 6px;
|
||||
background: rgba(15, 18, 24, 0.6);
|
||||
}
|
||||
|
||||
.squawk-list {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(5, 8, 15, 0.65);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.modal-backdrop.open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
padding: 18px;
|
||||
width: min(820px, 92vw);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
border: 1px solid rgba(74, 158, 255, 0.4);
|
||||
color: var(--accent-cyan);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.2fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.modal-photo {
|
||||
background: var(--bg-card);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.photo-fallback {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-details {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px 18px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(20, 26, 36, 0.6);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(31, 41, 55, 0.6);
|
||||
}
|
||||
|
||||
.detail-row span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.detail-row strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.control-group input,
|
||||
.control-group select {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.session-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal-details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -74,9 +74,12 @@
|
||||
<button type="button" class="strip-btn" onclick="lookupSelectedFlight()" title="Lookup selected aircraft on FlightAware" id="flightLookupBtn" disabled>
|
||||
🔗 Lookup
|
||||
</button>
|
||||
<button type="button" class="strip-btn primary" onclick="generateReport()" title="Generate Session Report">
|
||||
📊 Report
|
||||
</button>
|
||||
<button type="button" class="strip-btn primary" onclick="generateReport()" title="Generate Session Report">
|
||||
📊 Report
|
||||
</button>
|
||||
<a class="strip-btn" href="/adsb/history" title="Open History Reporting">
|
||||
📚 History
|
||||
</a>
|
||||
<div class="strip-divider"></div>
|
||||
<div class="strip-status">
|
||||
<div class="status-dot inactive" id="trackingDot"></div>
|
||||
@@ -1959,11 +1962,14 @@ ACARS: ${r.statistics.acarsMessages} messages`;
|
||||
setInterval(cleanupOldAircraft, 10000);
|
||||
checkAdsbTools();
|
||||
checkAircraftDatabase();
|
||||
checkDvbDriverConflict();
|
||||
|
||||
// Auto-connect to gpsd if available
|
||||
autoConnectGps();
|
||||
});
|
||||
checkDvbDriverConflict();
|
||||
|
||||
// Auto-connect to gpsd if available
|
||||
autoConnectGps();
|
||||
|
||||
// Sync tracking state if ADS-B already running
|
||||
syncTrackingStatus();
|
||||
});
|
||||
|
||||
// Track which device is being used for ADS-B tracking
|
||||
let adsbActiveDevice = null;
|
||||
@@ -2362,8 +2368,8 @@ sudo make install</code>
|
||||
return { host, port };
|
||||
}
|
||||
|
||||
async function toggleTracking() {
|
||||
const btn = document.getElementById('startBtn');
|
||||
async function toggleTracking() {
|
||||
const btn = document.getElementById('startBtn');
|
||||
|
||||
if (!isTracking) {
|
||||
// Check for remote dump1090 config
|
||||
@@ -2428,12 +2434,52 @@ sudo make install</code>
|
||||
document.getElementById('trackingDot').classList.add('inactive');
|
||||
document.getElementById('trackingStatus').textContent = 'STANDBY';
|
||||
// Re-enable ADS-B device selector
|
||||
document.getElementById('adsbDeviceSelect').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEventStream() {
|
||||
if (eventSource) eventSource.close();
|
||||
document.getElementById('adsbDeviceSelect').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncTrackingStatus() {
|
||||
try {
|
||||
const response = await fetch('/adsb/session');
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!data.tracking_active) {
|
||||
return;
|
||||
}
|
||||
isTracking = true;
|
||||
startEventStream();
|
||||
drawRangeRings();
|
||||
const startBtn = document.getElementById('startBtn');
|
||||
startBtn.textContent = 'STOP';
|
||||
startBtn.classList.add('active');
|
||||
document.getElementById('trackingDot').classList.remove('inactive');
|
||||
document.getElementById('trackingStatus').textContent = 'TRACKING';
|
||||
document.getElementById('adsbDeviceSelect').disabled = true;
|
||||
|
||||
const session = data.session || {};
|
||||
const startTime = session.started_at ? Date.parse(session.started_at) : null;
|
||||
if (startTime) {
|
||||
stats.sessionStart = startTime;
|
||||
}
|
||||
startSessionTimer();
|
||||
|
||||
const sessionDevice = session.device_index;
|
||||
if (sessionDevice !== null && sessionDevice !== undefined) {
|
||||
adsbActiveDevice = sessionDevice;
|
||||
const adsbSelect = document.getElementById('adsbDeviceSelect');
|
||||
if (adsbSelect) {
|
||||
adsbSelect.value = sessionDevice;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to sync ADS-B tracking status', err);
|
||||
}
|
||||
}
|
||||
|
||||
function startEventStream() {
|
||||
if (eventSource) eventSource.close();
|
||||
|
||||
console.log('Starting ADS-B event stream...');
|
||||
eventSource = new EventSource('/adsb/stream');
|
||||
|
||||
762
templates/adsb_history.html
Normal file
762
templates/adsb_history.html
Normal file
@@ -0,0 +1,762 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ADS-B History // INTERCEPT</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_history.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="radar-bg"></div>
|
||||
<div class="scanline"></div>
|
||||
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
ADS-B HISTORY
|
||||
<span>// INTERCEPT REPORTING</span>
|
||||
</div>
|
||||
<div class="status-bar">
|
||||
<a href="/adsb/dashboard" class="back-link">Live Radar</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="history-shell">
|
||||
<section class="summary-strip">
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">Messages</div>
|
||||
<div class="summary-value" id="summaryMessages">--</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">Snapshots</div>
|
||||
<div class="summary-value" id="summarySnapshots">--</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">Aircraft</div>
|
||||
<div class="summary-value" id="summaryAircraft">--</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">First Seen</div>
|
||||
<div class="summary-value" id="summaryFirstSeen">--</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">Last Seen</div>
|
||||
<div class="summary-value" id="summaryLastSeen">--</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="session-strip">
|
||||
<div class="session-status">
|
||||
<div class="status-dot" id="sessionStatusDot"></div>
|
||||
<div>
|
||||
<div class="session-label">Tracking</div>
|
||||
<div class="session-value" id="sessionStatusText">--</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-metric">
|
||||
<div class="session-label">Uptime</div>
|
||||
<div class="session-value mono" id="sessionUptime">--:--:--</div>
|
||||
</div>
|
||||
<div class="session-metric">
|
||||
<div class="session-label">Started</div>
|
||||
<div class="session-value" id="sessionStartedAt">--</div>
|
||||
</div>
|
||||
<div class="session-metric">
|
||||
<div class="session-label">Status</div>
|
||||
<div class="session-value" id="sessionNotice">Ready</div>
|
||||
</div>
|
||||
<div class="session-controls">
|
||||
<select id="sessionDeviceSelect"></select>
|
||||
<button class="primary-btn" id="sessionToggleBtn" type="button" onclick="toggleSession()">Start Tracking</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="controls">
|
||||
<div class="control-group">
|
||||
<label for="windowSelect">Window</label>
|
||||
<select id="windowSelect">
|
||||
<option value="15">15 minutes</option>
|
||||
<option value="60" selected>1 hour</option>
|
||||
<option value="360">6 hours</option>
|
||||
<option value="1440">24 hours</option>
|
||||
<option value="10080">7 days</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label for="searchInput">Search</label>
|
||||
<input type="text" id="searchInput" placeholder="ICAO / callsign / registration">
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label for="limitSelect">Limit</label>
|
||||
<select id="limitSelect">
|
||||
<option value="100">100</option>
|
||||
<option value="200" selected>200</option>
|
||||
<option value="500">500</option>
|
||||
<option value="1000">1000</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="primary-btn" id="refreshBtn">Refresh</button>
|
||||
<div class="status-pill" id="historyStatus">
|
||||
{% if history_enabled %}
|
||||
HISTORY ONLINE
|
||||
{% else %}
|
||||
HISTORY DISABLED
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="content-grid">
|
||||
<div class="panel aircraft-panel">
|
||||
<div class="panel-header">
|
||||
<span>RECENT AIRCRAFT</span>
|
||||
<span class="panel-meta" id="aircraftCount">0</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table class="aircraft-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ICAO</th>
|
||||
<th>Callsign</th>
|
||||
<th>Alt</th>
|
||||
<th>Speed</th>
|
||||
<th>Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="aircraftTableBody">
|
||||
<tr class="empty-row">
|
||||
<td colspan="5">No aircraft in this window</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel detail-panel">
|
||||
<div class="panel-header">
|
||||
<span>DETAIL TIMELINE</span>
|
||||
<span class="panel-meta" id="detailIcao">--</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="detail-card">
|
||||
<div class="detail-title" id="detailTitle">Select an aircraft</div>
|
||||
<div class="detail-meta" id="detailMeta">---</div>
|
||||
</div>
|
||||
<div class="chart-grid">
|
||||
<div class="chart-card">
|
||||
<div class="chart-title">Altitude (ft)</div>
|
||||
<canvas id="altitudeChart"></canvas>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-title">Speed (kt)</div>
|
||||
<canvas id="speedChart"></canvas>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-title">Heading (deg)</div>
|
||||
<canvas id="headingChart"></canvas>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-title">Vertical Rate (fpm)</div>
|
||||
<canvas id="verticalChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline-list" id="timelineList">
|
||||
<div class="empty-row">No timeline data</div>
|
||||
</div>
|
||||
<div class="squawk-list" id="squawkList">
|
||||
<div class="empty-row">No squawk changes</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="modal-backdrop" id="aircraftModalBackdrop" aria-hidden="true">
|
||||
<div class="modal-card" role="dialog" aria-modal="true">
|
||||
<button class="modal-close" id="aircraftModalClose" aria-label="Close">×</button>
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<div class="modal-title" id="modalTitle">Aircraft</div>
|
||||
<div class="modal-subtitle" id="modalSubtitle">--</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="nav-btn" id="modalPrev">◀</button>
|
||||
<button class="nav-btn" id="modalNext">▶</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-photo">
|
||||
<img id="modalPhoto" alt="Aircraft photo">
|
||||
<div class="photo-fallback" id="modalPhotoFallback">No photo</div>
|
||||
</div>
|
||||
<div class="modal-details">
|
||||
<div class="detail-row">
|
||||
<span>ICAO</span>
|
||||
<strong id="modalIcao">--</strong>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>Callsign</span>
|
||||
<strong id="modalCallsign">--</strong>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>Registration</span>
|
||||
<strong id="modalRegistration">--</strong>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>Type</span>
|
||||
<strong id="modalType">--</strong>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>Altitude</span>
|
||||
<strong id="modalAltitude">--</strong>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>Speed</span>
|
||||
<strong id="modalSpeed">--</strong>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>Heading</span>
|
||||
<strong id="modalHeading">--</strong>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>Vertical Rate</span>
|
||||
<strong id="modalVerticalRate">--</strong>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>Squawk</span>
|
||||
<strong id="modalSquawk">--</strong>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>Position</span>
|
||||
<strong id="modalPosition">--</strong>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>Last Seen</span>
|
||||
<strong id="modalLastSeen">--</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const historyEnabled = {{ 'true' if history_enabled else 'false' }};
|
||||
|
||||
const summaryMessages = document.getElementById('summaryMessages');
|
||||
const summarySnapshots = document.getElementById('summarySnapshots');
|
||||
const summaryAircraft = document.getElementById('summaryAircraft');
|
||||
const summaryFirstSeen = document.getElementById('summaryFirstSeen');
|
||||
const summaryLastSeen = document.getElementById('summaryLastSeen');
|
||||
const aircraftTableBody = document.getElementById('aircraftTableBody');
|
||||
const aircraftCount = document.getElementById('aircraftCount');
|
||||
const detailIcao = document.getElementById('detailIcao');
|
||||
const detailTitle = document.getElementById('detailTitle');
|
||||
const detailMeta = document.getElementById('detailMeta');
|
||||
const timelineList = document.getElementById('timelineList');
|
||||
const squawkList = document.getElementById('squawkList');
|
||||
const altitudeChart = document.getElementById('altitudeChart');
|
||||
const speedChart = document.getElementById('speedChart');
|
||||
const headingChart = document.getElementById('headingChart');
|
||||
const verticalChart = document.getElementById('verticalChart');
|
||||
const refreshBtn = document.getElementById('refreshBtn');
|
||||
const sessionStatusDot = document.getElementById('sessionStatusDot');
|
||||
const sessionStatusText = document.getElementById('sessionStatusText');
|
||||
const sessionUptime = document.getElementById('sessionUptime');
|
||||
const sessionStartedAt = document.getElementById('sessionStartedAt');
|
||||
const sessionNotice = document.getElementById('sessionNotice');
|
||||
const sessionDeviceSelect = document.getElementById('sessionDeviceSelect');
|
||||
const sessionToggleBtn = document.getElementById('sessionToggleBtn');
|
||||
|
||||
const windowSelect = document.getElementById('windowSelect');
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const limitSelect = document.getElementById('limitSelect');
|
||||
|
||||
let selectedIcao = '';
|
||||
let sessionStartAt = null;
|
||||
let sessionTimer = null;
|
||||
let recentAircraft = [];
|
||||
let selectedIndex = -1;
|
||||
const photoCache = new Map();
|
||||
const modalBackdrop = document.getElementById('aircraftModalBackdrop');
|
||||
const modalClose = document.getElementById('aircraftModalClose');
|
||||
const modalPrev = document.getElementById('modalPrev');
|
||||
const modalNext = document.getElementById('modalNext');
|
||||
|
||||
const modalTitle = document.getElementById('modalTitle');
|
||||
const modalSubtitle = document.getElementById('modalSubtitle');
|
||||
const modalPhoto = document.getElementById('modalPhoto');
|
||||
const modalPhotoFallback = document.getElementById('modalPhotoFallback');
|
||||
const modalIcao = document.getElementById('modalIcao');
|
||||
const modalCallsign = document.getElementById('modalCallsign');
|
||||
const modalRegistration = document.getElementById('modalRegistration');
|
||||
const modalType = document.getElementById('modalType');
|
||||
const modalAltitude = document.getElementById('modalAltitude');
|
||||
const modalSpeed = document.getElementById('modalSpeed');
|
||||
const modalHeading = document.getElementById('modalHeading');
|
||||
const modalVerticalRate = document.getElementById('modalVerticalRate');
|
||||
const modalSquawk = document.getElementById('modalSquawk');
|
||||
const modalPosition = document.getElementById('modalPosition');
|
||||
const modalLastSeen = document.getElementById('modalLastSeen');
|
||||
|
||||
function formatNumber(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return '--';
|
||||
}
|
||||
return Number(value).toLocaleString();
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '--';
|
||||
}
|
||||
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '--';
|
||||
}
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function valueOrDash(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '--';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatUptime(seconds) {
|
||||
if (seconds === null || seconds === undefined) {
|
||||
return '--:--:--';
|
||||
}
|
||||
const hrs = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
const sinceMinutes = windowSelect.value;
|
||||
const resp = await fetch(`/adsb/history/summary?since_minutes=${sinceMinutes}`);
|
||||
if (!resp.ok) {
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
summaryMessages.textContent = formatNumber(data.message_count);
|
||||
summarySnapshots.textContent = formatNumber(data.snapshot_count);
|
||||
summaryAircraft.textContent = formatNumber(data.aircraft_count);
|
||||
summaryFirstSeen.textContent = formatTime(data.first_seen);
|
||||
summaryLastSeen.textContent = formatTime(data.last_seen);
|
||||
}
|
||||
|
||||
async function loadAircraft() {
|
||||
const sinceMinutes = windowSelect.value;
|
||||
const limit = limitSelect.value;
|
||||
const search = encodeURIComponent(searchInput.value.trim());
|
||||
const resp = await fetch(`/adsb/history/aircraft?since_minutes=${sinceMinutes}&limit=${limit}&search=${search}`);
|
||||
if (!resp.ok) {
|
||||
aircraftTableBody.innerHTML = '<tr class="empty-row"><td colspan="5">History database unavailable</td></tr>';
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const rows = data.aircraft || [];
|
||||
recentAircraft = rows;
|
||||
aircraftCount.textContent = rows.length;
|
||||
if (!rows.length) {
|
||||
aircraftTableBody.innerHTML = '<tr class="empty-row"><td colspan="5">No aircraft in this window</td></tr>';
|
||||
return;
|
||||
}
|
||||
aircraftTableBody.innerHTML = rows.map(row => `
|
||||
<tr class="aircraft-row" data-icao="${row.icao}">
|
||||
<td class="mono">${row.icao}</td>
|
||||
<td>${valueOrDash(row.callsign)}</td>
|
||||
<td>${valueOrDash(row.altitude)}</td>
|
||||
<td>${valueOrDash(row.speed)}</td>
|
||||
<td>${formatTime(row.last_seen)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
document.querySelectorAll('.aircraft-row').forEach((row, index) => {
|
||||
row.addEventListener('click', () => {
|
||||
selectAircraft(row.dataset.icao);
|
||||
openModal(index);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function selectAircraft(icao) {
|
||||
selectedIcao = icao;
|
||||
detailIcao.textContent = icao || '--';
|
||||
if (!icao) {
|
||||
detailTitle.textContent = 'Select an aircraft';
|
||||
detailMeta.textContent = '---';
|
||||
timelineList.innerHTML = '<div class="empty-row">No timeline data</div>';
|
||||
squawkList.innerHTML = '<div class="empty-row">No squawk changes</div>';
|
||||
drawMetricChart(altitudeChart, [], 'altitude', 'Altitude', 'ft');
|
||||
drawMetricChart(speedChart, [], 'speed', 'Speed', 'kt');
|
||||
drawMetricChart(headingChart, [], 'heading', 'Heading', 'deg');
|
||||
drawMetricChart(verticalChart, [], 'vertical_rate', 'Vertical Rate', 'fpm');
|
||||
return;
|
||||
}
|
||||
await loadTimeline(icao);
|
||||
}
|
||||
|
||||
async function loadTimeline(icao) {
|
||||
const sinceMinutes = windowSelect.value;
|
||||
const resp = await fetch(`/adsb/history/timeline?icao=${icao}&since_minutes=${sinceMinutes}`);
|
||||
if (!resp.ok) {
|
||||
timelineList.innerHTML = '<div class="empty-row">Timeline unavailable</div>';
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const timeline = data.timeline || [];
|
||||
if (!timeline.length) {
|
||||
timelineList.innerHTML = '<div class="empty-row">No timeline data</div>';
|
||||
squawkList.innerHTML = '<div class="empty-row">No squawk changes</div>';
|
||||
drawMetricChart(altitudeChart, [], 'altitude', 'Altitude', 'ft');
|
||||
drawMetricChart(speedChart, [], 'speed', 'Speed', 'kt');
|
||||
drawMetricChart(headingChart, [], 'heading', 'Heading', 'deg');
|
||||
drawMetricChart(verticalChart, [], 'vertical_rate', 'Vertical Rate', 'fpm');
|
||||
return;
|
||||
}
|
||||
const latest = timeline[timeline.length - 1];
|
||||
detailTitle.textContent = `${icao} Timeline`;
|
||||
detailMeta.textContent = `Alt ${valueOrDash(latest.altitude)} ft | Spd ${valueOrDash(latest.speed)} kt | Head ${valueOrDash(latest.heading)}`;
|
||||
timelineList.innerHTML = timeline.slice(-30).reverse().map(point => `
|
||||
<div class="timeline-row">
|
||||
<span>${formatTime(point.captured_at)}</span>
|
||||
<span>Alt ${valueOrDash(point.altitude)} ft</span>
|
||||
<span>Spd ${valueOrDash(point.speed)} kt</span>
|
||||
<span>Hdg ${valueOrDash(point.heading)}</span>
|
||||
<span>V/S ${valueOrDash(point.vertical_rate)} fpm</span>
|
||||
</div>
|
||||
`).join('');
|
||||
updateSquawkChanges(timeline);
|
||||
drawMetricChart(altitudeChart, timeline, 'altitude', 'Altitude', 'ft');
|
||||
drawMetricChart(speedChart, timeline, 'speed', 'Speed', 'kt');
|
||||
drawMetricChart(headingChart, timeline, 'heading', 'Heading', 'deg');
|
||||
drawMetricChart(verticalChart, timeline, 'vertical_rate', 'Vertical Rate', 'fpm');
|
||||
}
|
||||
|
||||
function drawMetricChart(canvas, points, field, label, unit) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const width = canvas.clientWidth;
|
||||
const height = canvas.clientHeight;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
if (!points.length) {
|
||||
ctx.fillStyle = 'rgba(156, 163, 175, 0.6)';
|
||||
ctx.font = '12px "JetBrains Mono", monospace';
|
||||
ctx.fillText(`No ${label.toLowerCase()} data`, 12, height / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
const series = points.map(p => p[field]).filter(v => v !== null && v !== undefined);
|
||||
if (!series.length) {
|
||||
ctx.fillStyle = 'rgba(156, 163, 175, 0.6)';
|
||||
ctx.font = '12px "JetBrains Mono", monospace';
|
||||
ctx.fillText(`No ${label.toLowerCase()} data`, 12, height / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
const minVal = Math.min(...series);
|
||||
const maxVal = Math.max(...series);
|
||||
const range = maxVal - minVal || 1;
|
||||
const padding = 20;
|
||||
|
||||
ctx.strokeStyle = 'rgba(74, 158, 255, 0.4)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding, padding);
|
||||
ctx.lineTo(padding, height - padding);
|
||||
ctx.lineTo(width - padding, height - padding);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = 'rgba(74, 158, 255, 0.9)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
let started = false;
|
||||
const span = Math.max(1, points.length - 1);
|
||||
points.forEach((point, index) => {
|
||||
if (point[field] === null || point[field] === undefined) {
|
||||
return;
|
||||
}
|
||||
const x = padding + (index / span) * (width - padding * 2);
|
||||
const y = height - padding - ((point[field] - minVal) / range) * (height - padding * 2);
|
||||
if (!started) {
|
||||
ctx.moveTo(x, y);
|
||||
started = true;
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
});
|
||||
if (started) {
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.fillStyle = 'rgba(226, 232, 240, 0.8)';
|
||||
ctx.font = '11px "JetBrains Mono", monospace';
|
||||
ctx.fillText(`${maxVal} ${unit}`, 12, padding);
|
||||
ctx.fillText(`${minVal} ${unit}`, 12, height - padding);
|
||||
}
|
||||
|
||||
function updateSquawkChanges(points) {
|
||||
const changes = [];
|
||||
let lastSquawk = null;
|
||||
points.forEach(point => {
|
||||
if (point.squawk && point.squawk !== lastSquawk) {
|
||||
changes.push({ time: point.captured_at, squawk: point.squawk });
|
||||
lastSquawk = point.squawk;
|
||||
}
|
||||
});
|
||||
if (!changes.length) {
|
||||
squawkList.innerHTML = '<div class="empty-row">No squawk changes</div>';
|
||||
return;
|
||||
}
|
||||
squawkList.innerHTML = changes.slice(-10).reverse().map(change => `
|
||||
<div class="timeline-row">
|
||||
<span>${formatTime(change.time)}</span>
|
||||
<span>Squawk ${change.squawk}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function loadSessionDevices() {
|
||||
if (!historyEnabled) {
|
||||
sessionDeviceSelect.innerHTML = '<option value="0">History disabled</option>';
|
||||
sessionDeviceSelect.disabled = true;
|
||||
sessionToggleBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
const resp = await fetch('/devices');
|
||||
if (!resp.ok) {
|
||||
sessionDeviceSelect.innerHTML = '<option value="0">No SDR</option>';
|
||||
return;
|
||||
}
|
||||
const devices = await resp.json();
|
||||
sessionDeviceSelect.innerHTML = '';
|
||||
if (!devices.length) {
|
||||
sessionDeviceSelect.innerHTML = '<option value="0">No SDR</option>';
|
||||
sessionDeviceSelect.disabled = true;
|
||||
return;
|
||||
}
|
||||
devices.forEach((dev, idx) => {
|
||||
const index = dev.index !== undefined ? dev.index : idx;
|
||||
const type = (dev.sdr_type || dev.driver || 'RTL-SDR').toUpperCase();
|
||||
const serial = dev.serial ? ` (${dev.serial.slice(-4)})` : '';
|
||||
const opt = document.createElement('option');
|
||||
opt.value = index;
|
||||
opt.textContent = `${type} #${index}${serial}`;
|
||||
sessionDeviceSelect.appendChild(opt);
|
||||
});
|
||||
sessionDeviceSelect.disabled = false;
|
||||
}
|
||||
|
||||
async function loadSessionStatus() {
|
||||
const resp = await fetch('/adsb/session');
|
||||
if (!resp.ok) {
|
||||
sessionStatusText.textContent = 'UNKNOWN';
|
||||
sessionStatusDot.classList.remove('active');
|
||||
sessionNotice.textContent = 'Session unavailable';
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (data.tracking_active) {
|
||||
sessionStatusText.textContent = 'TRACKING';
|
||||
sessionStatusDot.classList.add('active');
|
||||
sessionToggleBtn.textContent = 'Stop Tracking';
|
||||
sessionToggleBtn.classList.add('stop');
|
||||
sessionNotice.textContent = 'Live';
|
||||
} else {
|
||||
sessionStatusText.textContent = 'STANDBY';
|
||||
sessionStatusDot.classList.remove('active');
|
||||
sessionToggleBtn.textContent = 'Start Tracking';
|
||||
sessionToggleBtn.classList.remove('stop');
|
||||
sessionNotice.textContent = 'Idle';
|
||||
}
|
||||
if (data.session && data.session.started_at) {
|
||||
sessionStartAt = new Date(data.session.started_at);
|
||||
sessionStartedAt.textContent = formatDateTime(data.session.started_at);
|
||||
sessionUptime.textContent = formatUptime(data.uptime_seconds);
|
||||
} else {
|
||||
sessionStartAt = null;
|
||||
sessionStartedAt.textContent = '--';
|
||||
sessionUptime.textContent = '--:--:--';
|
||||
}
|
||||
}
|
||||
|
||||
function startSessionTimer() {
|
||||
if (sessionTimer) {
|
||||
clearInterval(sessionTimer);
|
||||
}
|
||||
sessionTimer = setInterval(() => {
|
||||
if (!sessionStartAt) {
|
||||
sessionUptime.textContent = '--:--:--';
|
||||
return;
|
||||
}
|
||||
const seconds = Math.floor((Date.now() - sessionStartAt.getTime()) / 1000);
|
||||
sessionUptime.textContent = formatUptime(seconds);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function toggleSession() {
|
||||
if (!historyEnabled) {
|
||||
return;
|
||||
}
|
||||
sessionToggleBtn.disabled = true;
|
||||
sessionNotice.textContent = 'Working...';
|
||||
if (sessionStatusText.textContent === 'TRACKING') {
|
||||
const resp = await fetch('/adsb/stop', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ source: 'adsb_history' })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
sessionNotice.textContent = 'Stop failed';
|
||||
}
|
||||
} else {
|
||||
const device = parseInt(sessionDeviceSelect.value, 10) || 0;
|
||||
const resp = await fetch('/adsb/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ device, source: 'adsb_history' })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
sessionNotice.textContent = 'Start failed';
|
||||
}
|
||||
}
|
||||
await loadSessionStatus();
|
||||
sessionToggleBtn.disabled = false;
|
||||
}
|
||||
|
||||
async function openModal(index) {
|
||||
if (index < 0 || index >= recentAircraft.length) {
|
||||
return;
|
||||
}
|
||||
selectedIndex = index;
|
||||
const ac = recentAircraft[index];
|
||||
modalTitle.textContent = ac.callsign || ac.icao || 'Aircraft';
|
||||
modalSubtitle.textContent = `${valueOrDash(ac.registration)} • ${valueOrDash(ac.type_code)}`;
|
||||
modalIcao.textContent = valueOrDash(ac.icao);
|
||||
modalCallsign.textContent = valueOrDash(ac.callsign);
|
||||
modalRegistration.textContent = valueOrDash(ac.registration);
|
||||
modalType.textContent = [ac.type_desc, ac.type_code].filter(Boolean).join(' • ') || '--';
|
||||
modalAltitude.textContent = ac.altitude ? `${ac.altitude} ft` : '--';
|
||||
modalSpeed.textContent = ac.speed ? `${ac.speed} kt` : '--';
|
||||
modalHeading.textContent = ac.heading !== null && ac.heading !== undefined ? `${ac.heading}°` : '--';
|
||||
modalVerticalRate.textContent = ac.vertical_rate ? `${ac.vertical_rate} fpm` : '--';
|
||||
modalSquawk.textContent = valueOrDash(ac.squawk);
|
||||
if (ac.lat !== null && ac.lat !== undefined && ac.lon !== null && ac.lon !== undefined) {
|
||||
modalPosition.textContent = `${ac.lat.toFixed(4)}, ${ac.lon.toFixed(4)}`;
|
||||
} else {
|
||||
modalPosition.textContent = '--';
|
||||
}
|
||||
modalLastSeen.textContent = formatDateTime(ac.last_seen);
|
||||
|
||||
await loadPhoto(ac.registration);
|
||||
modalBackdrop.classList.add('open');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalBackdrop.classList.remove('open');
|
||||
}
|
||||
|
||||
async function loadPhoto(registration) {
|
||||
if (!registration) {
|
||||
modalPhoto.style.display = 'none';
|
||||
modalPhotoFallback.style.display = 'flex';
|
||||
return;
|
||||
}
|
||||
if (photoCache.has(registration)) {
|
||||
const url = photoCache.get(registration);
|
||||
if (url) {
|
||||
modalPhoto.src = url;
|
||||
modalPhoto.style.display = 'block';
|
||||
modalPhotoFallback.style.display = 'none';
|
||||
} else {
|
||||
modalPhoto.style.display = 'none';
|
||||
modalPhotoFallback.style.display = 'flex';
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`/adsb/aircraft-photo/${encodeURIComponent(registration)}`);
|
||||
const data = await resp.json();
|
||||
const url = data && data.thumbnail;
|
||||
photoCache.set(registration, url || null);
|
||||
if (url) {
|
||||
modalPhoto.src = url;
|
||||
modalPhoto.onerror = () => {
|
||||
modalPhoto.style.display = 'none';
|
||||
modalPhotoFallback.style.display = 'flex';
|
||||
};
|
||||
modalPhoto.style.display = 'block';
|
||||
modalPhotoFallback.style.display = 'none';
|
||||
} else {
|
||||
modalPhoto.style.display = 'none';
|
||||
modalPhotoFallback.style.display = 'flex';
|
||||
}
|
||||
} catch (err) {
|
||||
modalPhoto.style.display = 'none';
|
||||
modalPhotoFallback.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
function moveModal(offset) {
|
||||
const nextIndex = selectedIndex + offset;
|
||||
if (nextIndex < 0 || nextIndex >= recentAircraft.length) {
|
||||
return;
|
||||
}
|
||||
openModal(nextIndex);
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
if (!historyEnabled) {
|
||||
return;
|
||||
}
|
||||
await Promise.all([loadSummary(), loadAircraft(), loadSessionStatus()]);
|
||||
if (selectedIcao) {
|
||||
await loadTimeline(selectedIcao);
|
||||
}
|
||||
}
|
||||
|
||||
refreshBtn.addEventListener('click', refreshAll);
|
||||
windowSelect.addEventListener('change', refreshAll);
|
||||
limitSelect.addEventListener('change', refreshAll);
|
||||
searchInput.addEventListener('input', () => {
|
||||
clearTimeout(searchInput._debounce);
|
||||
searchInput._debounce = setTimeout(refreshAll, 350);
|
||||
});
|
||||
|
||||
refreshAll();
|
||||
loadSessionDevices();
|
||||
startSessionTimer();
|
||||
modalClose.addEventListener('click', closeModal);
|
||||
modalBackdrop.addEventListener('click', (event) => {
|
||||
if (event.target === modalBackdrop) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
modalPrev.addEventListener('click', () => moveModal(-1));
|
||||
modalNext.addEventListener('click', () => moveModal(1));
|
||||
window.addEventListener('resize', () => {
|
||||
if (selectedIcao) {
|
||||
loadTimeline(selectedIcao);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
397
utils/adsb_history.py
Normal file
397
utils/adsb_history.py
Normal file
@@ -0,0 +1,397 @@
|
||||
"""ADS-B history persistence to PostgreSQL."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterable
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extras import execute_values, Json
|
||||
|
||||
from config import (
|
||||
ADSB_DB_HOST,
|
||||
ADSB_DB_NAME,
|
||||
ADSB_DB_PASSWORD,
|
||||
ADSB_DB_PORT,
|
||||
ADSB_DB_USER,
|
||||
ADSB_HISTORY_BATCH_SIZE,
|
||||
ADSB_HISTORY_ENABLED,
|
||||
ADSB_HISTORY_FLUSH_INTERVAL,
|
||||
ADSB_HISTORY_QUEUE_SIZE,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('intercept.adsb_history')
|
||||
|
||||
|
||||
_MESSAGE_FIELDS = (
|
||||
'received_at',
|
||||
'msg_time',
|
||||
'logged_time',
|
||||
'icao',
|
||||
'msg_type',
|
||||
'callsign',
|
||||
'altitude',
|
||||
'speed',
|
||||
'heading',
|
||||
'vertical_rate',
|
||||
'lat',
|
||||
'lon',
|
||||
'squawk',
|
||||
'session_id',
|
||||
'aircraft_id',
|
||||
'flight_id',
|
||||
'raw_line',
|
||||
'source_host',
|
||||
)
|
||||
|
||||
_MESSAGE_INSERT_SQL = f"""
|
||||
INSERT INTO adsb_messages ({', '.join(_MESSAGE_FIELDS)})
|
||||
VALUES %s
|
||||
"""
|
||||
|
||||
_SNAPSHOT_FIELDS = (
|
||||
'captured_at',
|
||||
'icao',
|
||||
'callsign',
|
||||
'registration',
|
||||
'type_code',
|
||||
'type_desc',
|
||||
'altitude',
|
||||
'speed',
|
||||
'heading',
|
||||
'vertical_rate',
|
||||
'lat',
|
||||
'lon',
|
||||
'squawk',
|
||||
'source_host',
|
||||
'snapshot',
|
||||
)
|
||||
|
||||
_SNAPSHOT_INSERT_SQL = f"""
|
||||
INSERT INTO adsb_snapshots ({', '.join(_SNAPSHOT_FIELDS)})
|
||||
VALUES %s
|
||||
"""
|
||||
|
||||
def _ensure_adsb_schema(conn: psycopg2.extensions.connection) -> None:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS adsb_messages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
msg_time TIMESTAMPTZ,
|
||||
logged_time TIMESTAMPTZ,
|
||||
icao TEXT NOT NULL,
|
||||
msg_type SMALLINT,
|
||||
callsign TEXT,
|
||||
altitude INTEGER,
|
||||
speed INTEGER,
|
||||
heading INTEGER,
|
||||
vertical_rate INTEGER,
|
||||
lat DOUBLE PRECISION,
|
||||
lon DOUBLE PRECISION,
|
||||
squawk TEXT,
|
||||
session_id TEXT,
|
||||
aircraft_id TEXT,
|
||||
flight_id TEXT,
|
||||
raw_line TEXT,
|
||||
source_host TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_adsb_messages_icao_time
|
||||
ON adsb_messages (icao, received_at)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_adsb_messages_received_at
|
||||
ON adsb_messages (received_at)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_adsb_messages_msg_time
|
||||
ON adsb_messages (msg_time)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS adsb_snapshots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
icao TEXT NOT NULL,
|
||||
callsign TEXT,
|
||||
registration TEXT,
|
||||
type_code TEXT,
|
||||
type_desc TEXT,
|
||||
altitude INTEGER,
|
||||
speed INTEGER,
|
||||
heading INTEGER,
|
||||
vertical_rate INTEGER,
|
||||
lat DOUBLE PRECISION,
|
||||
lon DOUBLE PRECISION,
|
||||
squawk TEXT,
|
||||
source_host TEXT,
|
||||
snapshot JSONB
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_adsb_snapshots_icao_time
|
||||
ON adsb_snapshots (icao, captured_at)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_adsb_snapshots_captured_at
|
||||
ON adsb_snapshots (captured_at)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS adsb_sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ended_at TIMESTAMPTZ,
|
||||
device_index INTEGER,
|
||||
sdr_type TEXT,
|
||||
remote_host TEXT,
|
||||
remote_port INTEGER,
|
||||
start_source TEXT,
|
||||
stop_source TEXT,
|
||||
started_by TEXT,
|
||||
stopped_by TEXT,
|
||||
notes TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_adsb_sessions_started_at
|
||||
ON adsb_sessions (started_at)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_adsb_sessions_active
|
||||
ON adsb_sessions (ended_at)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _make_dsn() -> str:
|
||||
return (
|
||||
f"host={ADSB_DB_HOST} port={ADSB_DB_PORT} dbname={ADSB_DB_NAME} "
|
||||
f"user={ADSB_DB_USER} password={ADSB_DB_PASSWORD}"
|
||||
)
|
||||
|
||||
|
||||
class AdsbHistoryWriter:
|
||||
"""Background writer for ADS-B history records."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.enabled = ADSB_HISTORY_ENABLED
|
||||
self._queue: queue.Queue[dict] = queue.Queue(maxsize=ADSB_HISTORY_QUEUE_SIZE)
|
||||
self._thread: threading.Thread | None = None
|
||||
self._stop_event = threading.Event()
|
||||
self._conn: psycopg2.extensions.connection | None = None
|
||||
self._dropped = 0
|
||||
|
||||
def start(self) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._thread = threading.Thread(target=self._run, name='adsb-history-writer', daemon=True)
|
||||
self._thread.start()
|
||||
logger.info("ADS-B history writer started")
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
|
||||
def enqueue(self, record: dict) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
if 'received_at' not in record or record['received_at'] is None:
|
||||
record['received_at'] = datetime.now(timezone.utc)
|
||||
try:
|
||||
self._queue.put_nowait(record)
|
||||
except queue.Full:
|
||||
self._dropped += 1
|
||||
if self._dropped % 1000 == 0:
|
||||
logger.warning("ADS-B history queue full, dropped %d records", self._dropped)
|
||||
|
||||
def _run(self) -> None:
|
||||
batch: list[dict] = []
|
||||
last_flush = time.time()
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
timeout = max(0.0, ADSB_HISTORY_FLUSH_INTERVAL - (time.time() - last_flush))
|
||||
try:
|
||||
item = self._queue.get(timeout=timeout)
|
||||
batch.append(item)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
now = time.time()
|
||||
if batch and (len(batch) >= ADSB_HISTORY_BATCH_SIZE or now - last_flush >= ADSB_HISTORY_FLUSH_INTERVAL):
|
||||
if self._flush(batch):
|
||||
batch.clear()
|
||||
last_flush = now
|
||||
|
||||
def _ensure_connection(self) -> psycopg2.extensions.connection | None:
|
||||
if self._conn:
|
||||
return self._conn
|
||||
try:
|
||||
self._conn = psycopg2.connect(_make_dsn())
|
||||
self._conn.autocommit = False
|
||||
self._ensure_schema(self._conn)
|
||||
return self._conn
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B history DB connection failed: %s", exc)
|
||||
self._conn = None
|
||||
return None
|
||||
|
||||
def _ensure_schema(self, conn: psycopg2.extensions.connection) -> None:
|
||||
_ensure_adsb_schema(conn)
|
||||
|
||||
def _flush(self, batch: Iterable[dict]) -> bool:
|
||||
conn = self._ensure_connection()
|
||||
if not conn:
|
||||
time.sleep(2.0)
|
||||
return False
|
||||
|
||||
values = []
|
||||
for record in batch:
|
||||
values.append(tuple(record.get(field) for field in _MESSAGE_FIELDS))
|
||||
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
execute_values(cur, _MESSAGE_INSERT_SQL, values)
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B history insert failed: %s", exc)
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
self._conn = None
|
||||
time.sleep(2.0)
|
||||
return False
|
||||
|
||||
|
||||
adsb_history_writer = AdsbHistoryWriter()
|
||||
|
||||
|
||||
class AdsbSnapshotWriter:
|
||||
"""Background writer for ADS-B snapshot records."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.enabled = ADSB_HISTORY_ENABLED
|
||||
self._queue: queue.Queue[dict] = queue.Queue(maxsize=ADSB_HISTORY_QUEUE_SIZE)
|
||||
self._thread: threading.Thread | None = None
|
||||
self._stop_event = threading.Event()
|
||||
self._conn: psycopg2.extensions.connection | None = None
|
||||
self._dropped = 0
|
||||
|
||||
def start(self) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._thread = threading.Thread(target=self._run, name='adsb-snapshot-writer', daemon=True)
|
||||
self._thread.start()
|
||||
logger.info("ADS-B snapshot writer started")
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
|
||||
def enqueue(self, record: dict) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
if 'captured_at' not in record or record['captured_at'] is None:
|
||||
record['captured_at'] = datetime.now(timezone.utc)
|
||||
try:
|
||||
self._queue.put_nowait(record)
|
||||
except queue.Full:
|
||||
self._dropped += 1
|
||||
if self._dropped % 1000 == 0:
|
||||
logger.warning("ADS-B snapshot queue full, dropped %d records", self._dropped)
|
||||
|
||||
def _run(self) -> None:
|
||||
batch: list[dict] = []
|
||||
last_flush = time.time()
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
timeout = max(0.0, ADSB_HISTORY_FLUSH_INTERVAL - (time.time() - last_flush))
|
||||
try:
|
||||
item = self._queue.get(timeout=timeout)
|
||||
batch.append(item)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
now = time.time()
|
||||
if batch and (len(batch) >= ADSB_HISTORY_BATCH_SIZE or now - last_flush >= ADSB_HISTORY_FLUSH_INTERVAL):
|
||||
if self._flush(batch):
|
||||
batch.clear()
|
||||
last_flush = now
|
||||
|
||||
def _ensure_connection(self) -> psycopg2.extensions.connection | None:
|
||||
if self._conn:
|
||||
return self._conn
|
||||
try:
|
||||
self._conn = psycopg2.connect(_make_dsn())
|
||||
self._conn.autocommit = False
|
||||
self._ensure_schema(self._conn)
|
||||
return self._conn
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B snapshot DB connection failed: %s", exc)
|
||||
self._conn = None
|
||||
return None
|
||||
|
||||
def _ensure_schema(self, conn: psycopg2.extensions.connection) -> None:
|
||||
_ensure_adsb_schema(conn)
|
||||
|
||||
def _flush(self, batch: Iterable[dict]) -> bool:
|
||||
conn = self._ensure_connection()
|
||||
if not conn:
|
||||
time.sleep(2.0)
|
||||
return False
|
||||
|
||||
values = []
|
||||
for record in batch:
|
||||
row = []
|
||||
for field in _SNAPSHOT_FIELDS:
|
||||
value = record.get(field)
|
||||
if field == 'snapshot' and value is not None:
|
||||
value = Json(value)
|
||||
row.append(value)
|
||||
values.append(tuple(row))
|
||||
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
execute_values(cur, _SNAPSHOT_INSERT_SQL, values)
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("ADS-B snapshot insert failed: %s", exc)
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
self._conn = None
|
||||
time.sleep(2.0)
|
||||
return False
|
||||
|
||||
|
||||
adsb_snapshot_writer = AdsbSnapshotWriter()
|
||||
Reference in New Issue
Block a user