From 456404a2663d527f55f2ca7e8b630bfaf8eccdd2 Mon Sep 17 00:00:00 2001 From: James Smith Date: Tue, 7 Jul 2026 13:04:23 +0100 Subject: [PATCH] feat: ADS-B historical playback map (closes #208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New GET /adsb/history/playback endpoint returns time-bucketed snapshots (one position per aircraft per N-second bucket) via a SQL window function - Playback tab added to adsb_history.html: Leaflet map with animated aircraft markers, play/pause, 1x–20x speed, scrubber, and time display - Configurable window (15 min – 24 h) and bucket step (10 s – 5 min) - Requires the history Docker profile or INTERCEPT_ADSB_HISTORY_ENABLED=true - Bump version to 2.32.0 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 7 + config.py | 9 +- pyproject.toml | 2 +- routes/adsb.py | 81 +++++++++++ static/css/adsb_history.css | 135 ++++++++++++++++++ templates/adsb_history.html | 272 ++++++++++++++++++++++++++++++++++++ 6 files changed, 504 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6815e3..fb578db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to iNTERCEPT will be documented in this file. +## [2.32.0] - 2026-07-07 + +### Added +- **ADS-B historical playback** — new Playback tab on the ADS-B history page. Loads time-bucketed snapshots from the Postgres history database and animates aircraft positions on a Leaflet map. Controls: configurable time window (15 min – 24 h), bucket step (10 s – 5 min), play/pause, speed multiplier (1×–20×), and a time scrubber. Requires the `history` Docker profile or `INTERCEPT_ADSB_HISTORY_ENABLED=true`. Backed by new `GET /adsb/history/playback` endpoint. + +--- + ## [2.31.0] - 2026-07-07 ### Added diff --git a/config.py b/config.py index db6cd33..6e72400 100644 --- a/config.py +++ b/config.py @@ -7,10 +7,17 @@ import os import sys # Application version -VERSION = "2.31.0" +VERSION = "2.32.0" # Changelog - latest release notes (shown on welcome screen) CHANGELOG = [ + { + "version": "2.32.0", + "date": "July 2026", + "highlights": [ + "Feat: ADS-B historical playback — replay past aircraft positions on an interactive map with play/pause, speed control, and time scrubber (requires history profile)", + ], + }, { "version": "2.31.0", "date": "July 2026", diff --git a/pyproject.toml b/pyproject.toml index a120663..5626082 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "intercept" -version = "2.31.0" +version = "2.32.0" description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth" readme = "README.md" requires-python = ">=3.9" diff --git a/routes/adsb.py b/routes/adsb.py index 64c3123..9d66b1e 100644 --- a/routes/adsb.py +++ b/routes/adsb.py @@ -1664,6 +1664,87 @@ def adsb_history_export(): return response +@adsb_bp.route("/history/playback") +def adsb_history_playback(): + """Return time-bucketed snapshots for map playback. + + Query params: + since_minutes: window size in minutes (default 60, max 1440) + bucket_seconds: bucket width in seconds (default 30, min 10, max 300) + + Returns one position per aircraft per bucket (latest within bucket), + structured as a list of {t, aircraft:[]} frames ready for animation. + """ + if not ADSB_HISTORY_ENABLED or not PSYCOPG2_AVAILABLE: + return api_error("ADS-B history is disabled", 503) + _ensure_history_schema() + + since_minutes = _parse_int_param(request.args.get("since_minutes"), 60, 1, 1440) + bucket_seconds = _parse_int_param(request.args.get("bucket_seconds"), 30, 10, 300) + window = f"{since_minutes} minutes" + + sql = """ + WITH bucketed AS ( + SELECT + (EXTRACT(EPOCH FROM captured_at)::bigint / %(bkt)s) * %(bkt)s AS bucket_epoch, + icao, + callsign, + lat, + lon, + altitude, + heading, + speed, + ROW_NUMBER() OVER ( + PARTITION BY (EXTRACT(EPOCH FROM captured_at)::bigint / %(bkt)s), icao + ORDER BY captured_at DESC + ) AS rn + FROM adsb_snapshots + WHERE captured_at >= NOW() - INTERVAL %(window)s + AND lat IS NOT NULL + AND lon IS NOT NULL + ) + SELECT bucket_epoch, icao, callsign, lat, lon, altitude, heading, speed + FROM bucketed + WHERE rn = 1 + ORDER BY bucket_epoch ASC, icao ASC + """ + + try: + with _get_history_connection() as conn, conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(sql, {"bkt": bucket_seconds, "window": window}) + rows = cur.fetchall() + + # Group rows into frames keyed by bucket timestamp + frames: dict[int, list] = {} + for row in rows: + epoch = int(row["bucket_epoch"]) + frames.setdefault(epoch, []).append( + { + "icao": row["icao"], + "callsign": row["callsign"] or "", + "lat": float(row["lat"]), + "lon": float(row["lon"]), + "alt": row["altitude"], + "hdg": row["heading"], + "spd": row["speed"], + } + ) + + buckets = [{"t": epoch, "aircraft": aircraft} for epoch, aircraft in sorted(frames.items())] + + return jsonify( + { + "buckets": buckets, + "bucket_seconds": bucket_seconds, + "since_minutes": since_minutes, + "count": len(buckets), + } + ) + except Exception as exc: + logger.warning("ADS-B history playback query failed: %s", exc) + return api_error("History database unavailable", 503) + + @adsb_bp.route("/history/prune", methods=["POST"]) def adsb_history_prune(): """Delete ADS-B history for a selected time range or entire dataset.""" diff --git a/static/css/adsb_history.css b/static/css/adsb_history.css index dd51101..eaaf817 100644 --- a/static/css/adsb_history.css +++ b/static/css/adsb_history.css @@ -746,3 +746,138 @@ html[data-ui-tier="lean"] { --border-glow: transparent; --grid-line: transparent; } + +/* ── View toggle ───────────────────────────────────────────────────────── */ +.view-toggle { + display: flex; + gap: 2px; + border: 1px solid var(--border-color); + border-radius: 4px; + overflow: hidden; +} + +.view-btn { + padding: 6px 14px; + background: transparent; + border: none; + color: var(--text-secondary); + font-size: 11px; + font-family: var(--font-mono); + letter-spacing: 0.05em; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.view-btn.active, +.view-btn:hover { + background: var(--accent-cyan); + color: #000; +} + +/* ── Playback section ──────────────────────────────────────────────────── */ +.playback-section { + display: flex; + flex-direction: column; + gap: 0; + height: calc(100vh - 320px); + min-height: 420px; + border: 1px solid var(--border-color); + border-radius: 6px; + overflow: hidden; + margin: 0 var(--page-gutter, 16px); +} + +.playback-map { + flex: 1; + min-height: 300px; + background: var(--bg-card); +} + +.playback-controls { + padding: 10px 14px; + background: var(--bg-card); + border-top: 1px solid var(--border-color); + display: flex; + flex-direction: column; + gap: 8px; +} + +.playback-ctrl-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.playback-scrubber-row { + display: flex; + align-items: center; + gap: 10px; +} + +.playback-scrubber-row input[type="range"] { + flex: 1; + accent-color: var(--accent-cyan); + cursor: pointer; +} + +.playback-scrubber-row input[type="range"]:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.playback-time { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); + white-space: nowrap; + min-width: 140px; +} + +.playback-time:last-child { text-align: right; } + +.playback-current-time { + font-family: var(--font-mono); + font-size: 13px; + color: var(--accent-cyan); + text-align: center; + letter-spacing: 0.05em; +} + +.playback-counters { + display: flex; + flex-direction: column; + align-items: center; + min-width: 52px; +} + +.playback-label { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-secondary); +} + +.playback-value { + font-family: var(--font-mono); + font-size: 13px; + color: var(--text-primary); +} + +/* Aircraft markers on playback map */ +.pb-aircraft-icon { + background: none; + border: none; +} + +.pb-tooltip { + font-family: var(--font-mono); + font-size: 11px; + color: #fff; + white-space: nowrap; +} + +.status-pill.loading { background: var(--accent-yellow, #f0a500); color: #000; } +.status-pill.active { background: var(--accent-cyan); color: #000; } +.status-pill.warn { background: var(--accent-orange, #e06c00); color: #fff; } +.status-pill.error { background: var(--accent-red, #c0392b); color: #fff; } diff --git a/templates/adsb_history.html b/templates/adsb_history.html index 573b5ea..da81fbd 100644 --- a/templates/adsb_history.html +++ b/templates/adsb_history.html @@ -16,6 +16,9 @@ + + +
@@ -145,6 +148,10 @@ +
+ + +
{% if history_enabled %} HISTORY ONLINE @@ -218,6 +225,62 @@
+ + + + +