GODSIGH/record.py
jing 767884146d wave2 phase 4: historical record & replay (true time-travel)
Scrubbing off-live now REPLAYS actual recorded traffic instead of just hiding
aircraft (orbits were already computable in the past; live traffic was not).

- record.py (new, dev-only daemon): every 180s fetches through the local proxy
  (so the shared cache dedupes quota — never hits OpenSky directly), reduces to
  airborne essentials, zlib-compresses into SQLite data/history.db (WAL), keeps
  72h, prunes + checkpoints hourly. Wired into John's ~/.jobs heartbeat.
- serve.py: GET history/aircraft?t=<epoch_s> returns the nearest snapshot within
  ±10 min (read-only per-request sqlite connection — WAL means never locks the
  recorder), else a JSON 404.
- aircraft.js: onClockTick drives replay when not-live. Build loop factored into
  renderPlanes() shared by live + replay. Replayed aircraft show amber ("replay
  · <time> · N aircraft"); 404 → "no history for this time" + hide (this is the
  prod-without-recorder path too). Requests coalesce so a scrub landing mid-fetch
  still resolves to its final snapshot; debounced on requested-time distance.
- data/ gitignored (recorder db is local-only, never committed or deployed).

Verified: history endpoint hit/404/400; frontend replay across -1h/-2h/-3h maps
to distinct snapshots, 404 transitions, live-resume; recorder daemon writes
heartbeat + snapshots.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:30:17 +10:00

131 lines
4.5 KiB
Python

#!/usr/bin/env python3
"""GODSIGH history recorder — dev-side daemon (NOT shipped to prod).
Snapshots live aircraft every 180 s into a local SQLite db so the timeline can
replay ACTUAL past traffic instead of just hiding aircraft when scrubbed off
now. Fetches through the local dev proxy (http://127.0.0.1:8137/proxy/opensky)
so the shared OpenSky cache dedupes quota with the live app — it never calls
upstream directly. If the dev server is down it simply skips the cycle.
Prod has no recorder; the app's history endpoint 404s gracefully there, so the
aircraft layer just falls back to hide-when-scrubbed. Wire this into John's
heartbeat convention: status at ~/.jobs/godsigh-recorder.status.
Run it alongside the dev server: python3 record.py
"""
import json
import sqlite3
import sys
import time
import urllib.request
import zlib
from datetime import datetime
from pathlib import Path
PROXY_URL = "http://127.0.0.1:8137/proxy/opensky"
DB_PATH = Path(__file__).resolve().parent / "data" / "history.db"
SNAPSHOT_SEC = 180 # one snapshot every 3 min
RETENTION_SEC = 72 * 3600 # keep 72 h (~70 MB steady-state)
PRUNE_EVERY_SEC = 3600 # prune + checkpoint hourly
JOB = "godsigh-recorder"
def heartbeat(count, note=""):
# John's convention: overwrite ~/.jobs/<job>.status every iteration so
# "is the recorder alive?" is one `cat` away.
try:
d = Path.home() / ".jobs"
d.mkdir(exist_ok=True)
(d / f"{JOB}.status").write_text(
f"{datetime.now():%Y-%m-%d %H:%M:%S} | {count} snapshots | {note}\n")
except OSError:
pass
def connect():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(str(DB_PATH))
con.execute("PRAGMA journal_mode=WAL")
con.execute("CREATE TABLE IF NOT EXISTS snapshots (ts INTEGER PRIMARY KEY, kind TEXT, body BLOB)")
con.commit()
return con
def reduce_states(data):
# Reduce OpenSky /states/all to airborne essentials:
# [icao24, callsign, lon, lat, baro_alt, track, vel]
planes = []
for s in data.get("states") or []:
if not s:
continue
lon, lat, on_ground = s[5], s[6], s[8]
if lon is None or lat is None or on_ground:
continue
planes.append([s[0], (s[1] or "").strip(), lon, lat, s[7], s[10], s[9]])
return planes
def record_once(con):
req = urllib.request.Request(PROXY_URL, headers={"User-Agent": "godsigh-recorder/0.1"})
with urllib.request.urlopen(req, timeout=45) as r:
data = json.loads(r.read())
t = int(data.get("time") or time.time())
snap = {"t": t, "planes": reduce_states(data)}
body = zlib.compress(json.dumps(snap, separators=(",", ":")).encode())
# INSERT OR REPLACE: if a stale cache hands us the same upstream time twice
# (quota exhausted), we keep one row per timestamp instead of erroring.
con.execute("INSERT OR REPLACE INTO snapshots (ts, kind, body) VALUES (?, ?, ?)",
(t, "aircraft", body))
con.commit()
return t, len(snap["planes"])
def prune(con):
cutoff = int(time.time()) - RETENTION_SEC
con.execute("DELETE FROM snapshots WHERE ts < ?", (cutoff,))
con.commit()
con.execute("PRAGMA wal_checkpoint(TRUNCATE)")
def count_snaps(con):
return con.execute("SELECT COUNT(*) FROM snapshots").fetchone()[0]
def main():
con = connect()
try:
prune(con)
except sqlite3.Error as e:
print(f"[recorder] initial prune failed: {e}", file=sys.stderr)
last_prune = time.time()
n = count_snaps(con)
heartbeat(n, "starting")
print(f"[recorder] db={DB_PATH} snapshots={n} — every {SNAPSHOT_SEC}s via {PROXY_URL}")
while True:
try:
t, planes = record_once(con)
n = count_snaps(con)
note = f"planes={planes} t={t}"
print(f"[recorder] snapshot ok — {note} (retained {n})")
heartbeat(n, note)
except Exception as e:
# Dev server down or upstream hiccup — skip this cycle, retry next
# tick. Never fetch OpenSky directly (that would defeat the cache).
print(f"[recorder] snapshot skipped: {e}", file=sys.stderr)
heartbeat(n, f"skip: {e}")
now = time.time()
if now - last_prune > PRUNE_EVERY_SEC:
try:
prune(con)
last_prune = now
except sqlite3.Error as e:
print(f"[recorder] prune failed: {e}", file=sys.stderr)
time.sleep(SNAPSHOT_SEC)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n[recorder] stopped")