GODSIGH/serve.py
type-two 30b1381a8b feat: Wave 6.2 — swap paywalled Windy for free 511-network road cams
Windy moved its Webcams API behind a paid tier (only the in-site Plugins
API is free), so the windycams layer is gone before ever going live. In
its place: roadcams — official public traffic cameras from keyless 511
APIs (a North-America-standard platform, one adapter covers many regions).
Launching with Alberta (367) + Ontario (946); more regions are one entry
in REGIONS + serve.py CAMS_REGIONS + an nginx location each (several 511s
just need free keys). InfoBox descriptions are CallbackProperties, so the
snapshot cache-buster is computed at click time — always-fresh images with
zero entity rebuilds. Verified: 1,302 cams, live Toronto snapshot in-app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 15:01:19 +10:00

558 lines
26 KiB
Python

#!/usr/bin/env python3
"""GODSIGH dev server: static files + same-origin proxy for feeds that block CORS.
Also implements the **OpenSky shared cache v1** contract (see SPEC2.md §4), which
lets GODSIGH and its sibling project godstrument poll OpenSky from the same IP
without racing each other into the anonymous daily quota. The contract is
mirrored verbatim in godstrument's brief — keep the two in sync.
"""
import base64
import json
import os
import re
import sqlite3
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import zlib
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
UPSTREAMS = {
# OpenSky pins Access-Control-Allow-Origin to its own domain, so the browser
# can't call it directly — we forward it here instead.
"opensky": "https://opensky-network.org/api/states/all",
# Celestrak sends ACAO:* today; proxied too so the app keeps working if that changes.
"celestrak": "https://celestrak.org/NORAD/elements/gp.php",
}
# ---- OpenSky shared cache v1 ------------------------------------------------
# Path: ~/.cache/godverse/opensky-states.json (overridable via OPENSKY_CACHE_FILE).
# Content: the raw, unmodified /states/all JSON body (global, no bbox). Freshness
# = file mtime; readers treat <120 s as fresh. On upstream 429/failure a reader
# may serve a stale cache rather than nothing.
OPENSKY_CACHE_FRESH_SEC = 120
# OpenSky OAuth2 (client-credentials) → 4000 credits/day vs 400 anonymous. The
# clientId/clientSecret live in gitignored openskycredentials.json; a 30-min
# bearer token is fetched on demand and cached. If the file is absent or auth
# fails, we simply fetch anonymously (graceful fallback).
OPENSKY_CREDS = Path(__file__).resolve().parent / "openskycredentials.json"
OPENSKY_TOKEN_URL = ("https://auth.opensky-network.org/auth/realms/"
"opensky-network/protocol/openid-connect/token")
_opensky_token = {"value": None, "exp": 0.0}
# ---- Historical replay (record.py writes this db; dev-only) -----------------
# GET history/aircraft?t=<epoch_s> returns the nearest recorded snapshot within
# ±10 min, or 404. Prod has no recorder → the file is absent → graceful 404.
HISTORY_DB = Path(__file__).resolve().parent / "data" / "history.db"
HISTORY_TOLERANCE_SEC = 600
# ---- Military ADS-B via adsb.lol (FREE, no key, community feed) --------------
# proxy/mil forwards api.adsb.lol/v2/mil (no CORS header, so it needs a same-origin
# proxy) with a short in-memory cache. Free and unmetered — the cache is just
# politeness/coalescing, not a quota guard. Same {ac:[...]} shape as ADS-B
# Exchange, so js/layers/military.js is source-agnostic.
ADSBLOL_MIL_URL = "https://api.adsb.lol/v2/mil"
ADSBLOL_CACHE_SEC = 60
_adsblol_cache = {"body": None, "ts": 0.0}
# ---- Generic adsb.lol query proxy (proxy/adsb/<v2-path>) ---------------------
# Powers the OSINT layers (emergency squawks, rare-type hunter, LADD/PIA shadow
# aircraft, radius focus). The regex is the security allowlist — only these v2
# shapes are forwardable, so it can't be turned into an open proxy/SSRF. Each
# distinct path is cached 60s, and upstream calls are spaced (adsb.lol 429s on
# rapid bursts — the same guard the prod nginx regex-location relies on). The
# prod nginx block mirrors this allowlist; keep the two in sync.
ADSB_PATH_RE = re.compile(
r'^(mil|ladd|pia'
r'|sqk/\d{3,4}'
r'|type/[A-Za-z0-9]{2,5}'
r'|hex/[0-9a-fA-F]{6}'
r'|callsign/[A-Za-z0-9]{1,8}'
r'|reg/[A-Za-z0-9\-]{1,10}'
r'|lat/-?\d{1,3}(\.\d+)?/lon/-?\d{1,3}(\.\d+)?/dist/\d{1,4}(\.\d+)?'
r'|closest/-?\d{1,3}(\.\d+)?/-?\d{1,3}(\.\d+)?/\d{1,4}(\.\d+)?)$')
ADSB_CACHE_SEC = 120
ADSB_MIN_INTERVAL = 1.3 # min seconds between upstream adsb.lol calls (anti-burst; it 429s fast)
_adsb_cache = {} # path -> (body, ts)
_adsb_lock = threading.Lock()
_adsb_next = [0.0] # earliest wall-time the next upstream call may start
# ---- Radio Garden proxy (proxy/rg/<path>) ------------------------------------
# Unofficial API, no CORS header → needs this same-origin hop. Only two shapes
# are forwardable (the allowlist prevents open-proxy/SSRF): the global places
# list (~1.8 MB, changes rarely → 12 h cache) and one place's channel list
# (1 h cache). Audio streams do NOT go through here — the browser's <audio>
# element plays radio.garden/…/channel.mp3 directly (media loads need no CORS).
RG_PATH_RE = re.compile(r'^ara/content/(places|page/[A-Za-z0-9]{4,16}/channels)$')
RG_CACHE_SEC = {"ara/content/places": 43200} # default for others: 3600
_rg_cache = {} # path -> (body, ts)
# ---- Keyed upstreams: NASA FIRMS --------------------------------------------
# Keys live in gitignored files at repo root and are injected SERVER-SIDE only
# (never reach the browser). Files are re-read on every request (they're tiny),
# so dropping in a fixed key needs no server restart. The loader is tolerant:
# full JSON, brace-less "key": "value" fragments, or a raw pasted token all work.
def read_key_file(path):
try:
raw = open(path).read().strip()
except OSError:
return None
for attempt in (raw, "{" + raw + "}"):
try:
d = json.loads(attempt)
if isinstance(d, dict):
for v in d.values():
if isinstance(v, str) and len(v) >= 16:
return v
except Exception:
pass
m = re.findall(r'[A-Za-z0-9_-]{16,}', raw)
return m[0] if m else None
FIRMS_CACHE_SEC = 3600 # FIRMS refreshes ~3-hourly; 1h cache is generous
_firms_cache = [None, 0.0] # (body, ts)
# ---- Road cams (511-network et al.) ------------------------------------------
# Official public traffic-camera APIs with no key. The 511 platform is a
# North-America standard, so one adapter covers many regions; add entries here
# (and a matching prod nginx location) to light up more. JSON needs this proxy
# (no ACAO upstream); the snapshot IMAGES load directly in <img> (no CORS needed).
CAMS_REGIONS = {
"alberta": "https://511.alberta.ca/api/v2/get/cameras",
"ontario": "https://511on.ca/api/v2/get/cameras",
}
CAMS_CACHE_SEC = 1800 # camera LISTS are near-static; images are fetched live
_cams_cache = {} # region -> (body, ts)
# ---- ADS-B Exchange military feed (paid RapidAPI, ~10k req/month) ------------
# proxy/adsbx-mil injects the RapidAPI key server-side (never in the browser) and
# HARD-caches the /v2/mil/ response — the quota guard. The $10 BASIC tier bills
# $0.0015/request OVER 10k/month with NO hard block, so the cache TTL must keep
# us safely under: 10 min → at most ~144 calls/day (~4.4k/month, <half the cap).
ADSBX_CREDS = Path(__file__).resolve().parent / "adsbxcredentials.json"
ADSBX_CACHE_SEC = 600
_adsbx_cache = {"body": None, "ts": 0.0}
def load_adsbx_creds():
try:
d = json.loads(ADSBX_CREDS.read_text())
return d.get("host"), d.get("key")
except (OSError, ValueError):
return None, None
def opensky_cache_file():
override = os.environ.get("OPENSKY_CACHE_FILE")
return Path(override if override else "~/.cache/godverse/opensky-states.json").expanduser()
def opensky_bearer():
# A valid access token (refreshed ~1 min before expiry), or None if creds are
# missing/auth fails → caller fetches anonymously.
try:
d = json.loads(OPENSKY_CREDS.read_text())
cid, csec = d.get("clientId"), d.get("clientSecret")
except (OSError, ValueError):
return None
if not cid or not csec:
return None
now = time.time()
if _opensky_token["value"] and now < _opensky_token["exp"]:
return _opensky_token["value"]
try:
data = urllib.parse.urlencode({
"grant_type": "client_credentials", "client_id": cid, "client_secret": csec,
}).encode()
req = urllib.request.Request(
OPENSKY_TOKEN_URL, data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"})
with urllib.request.urlopen(req, timeout=15) as r:
tok = json.loads(r.read())
_opensky_token["value"] = tok["access_token"]
_opensky_token["exp"] = now + max(60, int(tok.get("expires_in", 1800)) - 60)
return _opensky_token["value"]
except Exception:
return None
def has_bbox(query):
# A global states/all request has no bounding-box params; only those bypass the cache.
q = query.lower()
return any(k in q for k in ("lamin", "lomin", "lamax", "lomax"))
def read_cache_if_fresh(path, max_age):
try:
st = path.stat()
except OSError:
return None
if time.time() - st.st_mtime >= max_age:
return None
try:
return path.read_bytes()
except OSError:
return None
def read_cache_any(path):
try:
return path.read_bytes()
except OSError:
return None
def write_cache(path, body):
# Atomic write: a unique temp file *beside* the target (same filesystem, so
# os.replace is atomic) then os.replace over the target. A unique temp name
# means concurrent writers (GODSIGH + godstrument, or two threads here) never
# corrupt each other's cache or expose a half-written file. Best-effort —
# caching must never break the actual response.
try:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=path.name + ".", suffix=".tmp")
try:
with os.fdopen(fd, "wb") as f:
f.write(body)
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
except OSError:
pass
class Handler(SimpleHTTPRequestHandler):
def end_headers(self):
# Dev server: never let the browser cache our static assets, so edits to
# js/css always take effect on reload (module graphs cache aggressively).
self.send_header("Cache-Control", "no-store")
super().end_headers()
def do_GET(self):
if self.path.startswith("/history/"):
return self._serve_history()
if not self.path.startswith("/proxy/"):
return super().do_GET()
name, _, query = self.path[len("/proxy/"):].partition("?")
if name == "mil":
return self._serve_mil()
if name.startswith("adsb/"):
return self._serve_adsb(name[len("adsb/"):])
if name.startswith("rg/"):
return self._serve_rg(name[len("rg/"):])
if name == "firms":
return self._serve_firms()
if name.startswith("cams/"):
return self._serve_cams(name[len("cams/"):])
if name == "adsbx-mil":
return self._serve_adsbx_mil()
base = UPSTREAMS.get(name)
if not base:
return self.send_error(404, f"unknown upstream {name!r}")
# Shared cache applies only to the OpenSky global states feed.
opensky_global = name == "opensky" and not has_bbox(query)
cache = opensky_cache_file()
# 1) Serve-from-cache: fresh (<120 s) global request costs zero quota.
if opensky_global:
fresh = read_cache_if_fresh(cache, OPENSKY_CACHE_FRESH_SEC)
if fresh is not None:
return self._send(200, "application/json", fresh, {"X-Godsigh-Cache": "hit"})
url = base + ("?" + query if query else "")
passthrough = {}
def grab(hdrs):
for k in ("X-Rate-Limit-Remaining", "X-Rate-Limit-Limit", "X-Expires-After"):
if hdrs.get(k) is not None:
passthrough[k] = hdrs.get(k)
req_headers = {"User-Agent": "godsigh-dev/0.1"}
if name == "opensky":
tok = opensky_bearer() # authenticated → 4000/day; None → anonymous 400/day
if tok:
req_headers["Authorization"] = "Bearer " + tok
try:
req = urllib.request.Request(url, headers=req_headers)
with urllib.request.urlopen(req, timeout=45) as r:
body = r.read()
status, ctype = r.status, r.headers.get("Content-Type", "text/plain")
grab(r.headers)
# 2) Write-through: a healthy global fetch refreshes the shared cache.
if opensky_global and status == 200:
write_cache(cache, body)
passthrough["X-Godsigh-Cache"] = "miss"
except urllib.error.HTTPError as e:
# 3) Stale-on-error: on 429 (quota) or any 5xx (upstream failure), keep
# the client working from the last-known cache. 4xx client errors are
# NOT masked with stale data.
if opensky_global and (e.code == 429 or e.code >= 500):
stale = read_cache_any(cache)
if stale is not None:
return self._send(200, "application/json", stale, {"X-Godsigh-Cache": "stale"})
body = e.read() or str(e).encode()
status, ctype = e.code, "text/plain"
grab(e.headers)
except Exception as e:
# Same stale fallback for connection errors/timeouts.
if opensky_global:
stale = read_cache_any(cache)
if stale is not None:
return self._send(200, "application/json", stale, {"X-Godsigh-Cache": "stale"})
body, status, ctype = str(e).encode(), 502, "text/plain"
self._send(status, ctype, body, passthrough)
def _send(self, status, ctype, body, extra=None):
self.send_response(status)
self.send_header("Content-Type", ctype)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header(
"Access-Control-Expose-Headers",
"X-Rate-Limit-Remaining, X-Rate-Limit-Limit, X-Expires-After, X-Godsigh-Cache",
)
for k, v in (extra or {}).items():
self.send_header(k, v)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
# Dev-only screenshot sink: POST snap?name=<name> with a base64 PNG (or a
# full data:image/png;base64,... URL) body → writes docs/<name>.png. The
# server binds 127.0.0.1 only, and the name is sanitized to [a-z0-9-] so
# the write can never escape docs/. serve.py is never deployed.
if not (self.path == "/snap" or self.path.startswith("/snap?")):
return self.send_error(404, "not found")
_, _, query = self.path.partition("?")
raw_name = (urllib.parse.parse_qs(query).get("name") or ["screenshot"])[0]
name = re.sub(r"[^a-z0-9-]", "", raw_name.lower()) or "screenshot"
length = int(self.headers.get("Content-Length") or 0)
text = self.rfile.read(length).decode("utf-8", "replace") if length else ""
if text.strip().startswith("data:") and "," in text:
text = text.split(",", 1)[1]
try:
png = base64.b64decode(text, validate=False)
except Exception:
return self._send_json(400, {"error": "body must be base64 PNG (optionally a data: URL)"})
if not png:
return self._send_json(400, {"error": "empty image"})
docs = (Path(__file__).resolve().parent / "docs")
docs.mkdir(parents=True, exist_ok=True)
out = (docs / f"{name}.png").resolve()
if out.parent != docs.resolve():
return self._send_json(400, {"error": "invalid name"})
out.write_bytes(png)
return self._send_json(200, {"ok": True, "path": f"docs/{name}.png", "bytes": len(png)})
def _serve_mil(self):
# Free adsb.lol military feed; short in-memory cache (politeness, not quota).
now = time.time()
if _adsblol_cache["body"] is not None and now - _adsblol_cache["ts"] < ADSBLOL_CACHE_SEC:
return self._send(200, "application/json", _adsblol_cache["body"], {"X-Godsigh-Cache": "hit"})
try:
req = urllib.request.Request(ADSBLOL_MIL_URL, headers={"User-Agent": "godsigh/1.0 (partly.party)"})
with urllib.request.urlopen(req, timeout=30) as r:
body = r.read()
_adsblol_cache["body"] = body
_adsblol_cache["ts"] = now
return self._send(200, "application/json", body, {"X-Godsigh-Cache": "miss"})
except urllib.error.HTTPError as e:
if _adsblol_cache["body"] is not None:
return self._send(200, "application/json", _adsblol_cache["body"], {"X-Godsigh-Cache": "stale"})
return self._send_json(e.code, {"error": f"adsb.lol upstream {e.code}"})
except Exception as e:
if _adsblol_cache["body"] is not None:
return self._send(200, "application/json", _adsblol_cache["body"], {"X-Godsigh-Cache": "stale"})
return self._send_json(502, {"error": str(e)})
def _serve_adsb(self, path):
# Generic adsb.lol v2 query proxy, allowlisted by ADSB_PATH_RE.
if not ADSB_PATH_RE.match(path):
return self._send_json(400, {"error": "unsupported adsb path"})
hit = _adsb_cache.get(path)
if hit and time.time() - hit[1] < ADSB_CACHE_SEC:
return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "hit"})
# Reserve a rate-limit slot so call STARTS are spaced ≥ADSB_MIN_INTERVAL,
# but do NOT hold the lock during the fetch — otherwise a big response
# (ladd, ~300 aircraft) would stall every other layer's poll.
with _adsb_lock:
hit = _adsb_cache.get(path) # re-check under lock
if hit and time.time() - hit[1] < ADSB_CACHE_SEC:
return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "hit"})
start_at = max(time.time(), _adsb_next[0])
_adsb_next[0] = start_at + ADSB_MIN_INTERVAL
wait = start_at - time.time()
if wait > 0:
time.sleep(wait)
try:
req = urllib.request.Request(f"https://api.adsb.lol/v2/{path}",
headers={"User-Agent": "godsigh/1.0 (partly.party)"})
with urllib.request.urlopen(req, timeout=12) as r: # fail fast; adsb.lol can hang
body = r.read()
_adsb_cache[path] = (body, time.time())
return self._send(200, "application/json", body, {"X-Godsigh-Cache": "miss"})
except urllib.error.HTTPError as e:
if hit is not None:
return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "stale"})
# adsb.lol rate-limits with 429 or 420 ("Enhance Your Calm"). With no
# cache to fall back on, return a clean empty result (200) rather than
# surfacing the error — the layer just shows nothing until the next
# poll, and the browser console stays quiet.
if e.code in (420, 429):
return self._send(200, "application/json", b'{"ac":[]}', {"X-Godsigh-Cache": "ratelimited"})
return self._send_json(e.code, {"error": f"adsb.lol upstream {e.code}"})
except Exception as e:
if hit is not None:
return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "stale"})
return self._send_json(502, {"error": str(e)})
def _serve_rg(self, path):
# Radio Garden proxy, allowlisted by RG_PATH_RE (places + channel lists).
if not RG_PATH_RE.match(path):
return self._send_json(400, {"error": "unsupported radio garden path"})
hit = _rg_cache.get(path)
ttl = RG_CACHE_SEC.get(path, 3600)
if hit and time.time() - hit[1] < ttl:
return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "hit"})
try:
req = urllib.request.Request(f"https://radio.garden/api/{path}",
headers={"User-Agent": "godsigh/1.0 (partly.party)"})
with urllib.request.urlopen(req, timeout=20) as r:
body = r.read()
_rg_cache[path] = (body, time.time())
return self._send(200, "application/json", body, {"X-Godsigh-Cache": "miss"})
except urllib.error.HTTPError as e:
if hit is not None: # stale-on-error keeps the layer alive
return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "stale"})
return self._send_json(e.code, {"error": f"radio.garden upstream {e.code}"})
except Exception as e:
if hit is not None:
return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "stale"})
return self._send_json(502, {"error": str(e)})
def _serve_firms(self):
# NASA FIRMS: VIIRS S-NPP near-real-time detections, world, last 24 h.
# CSV upstream, key injected server-side, 1 h cache + stale-on-error.
body, ts = _firms_cache
if body is not None and time.time() - ts < FIRMS_CACHE_SEC:
return self._send(200, "text/csv", body, {"X-Godsigh-Cache": "hit"})
key = read_key_file("firmscredentials.json")
if not key:
return self._send_json(503, {"error": "no FIRMS key (firmscredentials.json)"})
try:
url = f"https://firms.modaps.eosdis.nasa.gov/api/area/csv/{key}/VIIRS_SNPP_NRT/world/1"
req = urllib.request.Request(url, headers={"User-Agent": "godsigh/1.0 (partly.party)"})
with urllib.request.urlopen(req, timeout=30) as r:
data = r.read()
_firms_cache[0], _firms_cache[1] = data, time.time()
return self._send(200, "text/csv", data, {"X-Godsigh-Cache": "miss"})
except Exception as e:
if body is not None:
return self._send(200, "text/csv", body, {"X-Godsigh-Cache": "stale"})
code = getattr(e, "code", 502)
return self._send_json(code if isinstance(code, int) else 502, {"error": "FIRMS upstream failed"})
def _serve_cams(self, region):
# Road cams: allowlisted official public feeds (see CAMS_REGIONS).
base = CAMS_REGIONS.get(region)
if not base:
return self._send_json(400, {"error": f"unknown cams region {region!r}"})
hit = _cams_cache.get(region)
if hit and time.time() - hit[1] < CAMS_CACHE_SEC:
return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "hit"})
try:
req = urllib.request.Request(base, headers={"User-Agent": "godsigh/1.0 (partly.party)"})
with urllib.request.urlopen(req, timeout=20) as r:
data = r.read()
_cams_cache[region] = (data, time.time())
return self._send(200, "application/json", data, {"X-Godsigh-Cache": "miss"})
except Exception:
if hit is not None:
return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "stale"})
return self._send_json(502, {"error": f"cams upstream {region} failed"})
def _serve_adsbx_mil(self):
host, key = load_adsbx_creds()
if not host or not key:
# Not configured (e.g. no key file) — the layer degrades gracefully.
return self._send_json(503, {"error": "ADS-B Exchange not configured"})
now = time.time()
if _adsbx_cache["body"] is not None and now - _adsbx_cache["ts"] < ADSBX_CACHE_SEC:
return self._send(200, "application/json", _adsbx_cache["body"], {"X-Godsigh-Cache": "hit"})
try:
req = urllib.request.Request(
f"https://{host}/v2/mil/",
headers={"x-rapidapi-host": host, "x-rapidapi-key": key,
"User-Agent": "godsigh-dev/0.1"},
)
with urllib.request.urlopen(req, timeout=30) as r:
body = r.read()
_adsbx_cache["body"] = body
_adsbx_cache["ts"] = now
return self._send(200, "application/json", body, {"X-Godsigh-Cache": "miss"})
except urllib.error.HTTPError as e:
if _adsbx_cache["body"] is not None:
return self._send(200, "application/json", _adsbx_cache["body"], {"X-Godsigh-Cache": "stale"})
return self._send_json(e.code, {"error": f"adsbx upstream {e.code}"})
except Exception as e:
if _adsbx_cache["body"] is not None:
return self._send(200, "application/json", _adsbx_cache["body"], {"X-Godsigh-Cache": "stale"})
return self._send_json(502, {"error": str(e)})
def _send_json(self, status, obj, extra=None):
self._send(status, "application/json", json.dumps(obj).encode(), extra)
def _serve_history(self):
# /history/aircraft?t=<epoch_s> → nearest snapshot within ±10 min, or 404.
kind, _, query = self.path[len("/history/"):].partition("?")
params = urllib.parse.parse_qs(query)
t_raw = (params.get("t") or [None])[0]
if kind != "aircraft" or t_raw is None:
return self._send_json(400, {"error": "usage: history/aircraft?t=<epoch_s>"})
try:
t = int(float(t_raw))
except (ValueError, OverflowError): # OverflowError: t=inf / 1e999
return self._send_json(400, {"error": "t must be epoch seconds"})
if not HISTORY_DB.exists():
return self._send_json(404, {"error": "no history for this time"})
try:
# Read-only, short-lived connection per request — WAL lets us read
# without ever locking out the recorder's writes.
con = sqlite3.connect(f"file:{HISTORY_DB}?mode=ro", uri=True, timeout=2)
try:
row = con.execute(
"SELECT ts, body FROM snapshots WHERE kind='aircraft' "
"ORDER BY ABS(ts - ?) ASC LIMIT 1", (t,)).fetchone()
finally:
con.close()
except sqlite3.Error as e:
return self._send_json(500, {"error": f"history db error: {e}"})
if not row or abs(row[0] - t) > HISTORY_TOLERANCE_SEC:
return self._send_json(404, {"error": "no history for this time"})
try:
snap = zlib.decompress(row[1]) # already-JSON bytes: {"t":..,"planes":[...]}
except zlib.error:
return self._send_json(500, {"error": "corrupt snapshot"})
return self._send(200, "application/json", snap, {"X-Godsigh-History": "hit"})
def log_message(self, fmt, *args):
sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8137
print(f"GODSIGH serving on http://127.0.0.1:{port}")
ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()