#!/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/.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")