Add ADS-B history persistence and reporting UI

This commit is contained in:
James Ward
2026-01-24 15:14:48 -08:00
committed by Smittix
parent 0cccf3c9dd
commit 8b4b440b22
9 changed files with 2461 additions and 137 deletions

View File

@@ -33,6 +33,9 @@ htmlcov/
# Logs
*.log
# Local Postgres data
pgdata/
# Captured files (don't include in image)
*.cap
*.pcap

19
.gitignore vendored
View File

@@ -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

View File

@@ -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)

View File

@@ -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:

View File

@@ -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
View 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;
}
}

View File

@@ -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
View 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
View 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()