From 0bbdb30719b7d71521f8b556200ce3943132af22 Mon Sep 17 00:00:00 2001 From: jing Date: Mon, 13 Jul 2026 23:07:40 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20Military=20(ADS-B=20Exchange)=20layer?= =?UTF-8?q?=20=E2=80=94=20real=20unfiltered=20military=20aircraft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires John's paid ADS-B Exchange RapidAPI feed ($10/mo, ~10k req/month) into a new layer showing the military traffic OpenSky/FR24 hide (C-17 "Reach" airlift, tankers, the Area 51 "Janet" shuttle, etc.). - serve.py: proxy/adsbx-mil injects the x-rapidapi-key/host headers SERVER-SIDE (key read from gitignored adsbxcredentials.json, never in the browser) and HARD-caches /v2/mil/ for 5 min — the quota guard: <=~288 upstream calls/day (~8.6k/month) no matter how many tabs poll. Stale-on-error; 503 if unconfigured. - js/layers/military.js: BillboardCollection like the civilian layer but a distinct red; live-only (no history feed), 5-min poll matched to the cache. ADSBx altitude is FEET (→metres for Cesium). Emergency squawks (7500/7600/ 7700) highlight brighter+bigger and flip the HUD status to err. Click overlay shows type / tail / altitude / speed / squawk. - config.js: adsbx block + militaryReal/militaryEmerg colours. Verified in dev: proxy miss→hit (5-min cache, key not leaked), 412 military aircraft render over CONUS, click RCH042 → C17 / 99-0062 / 34000ft / 424kt, zero console errors. adsbxcredentials.json stays gitignored + server-side. Co-Authored-By: Claude Opus 4.8 --- js/config.js | 11 ++++ js/layers/military.js | 138 ++++++++++++++++++++++++++++++++++++++++++ js/main.js | 1 + serve.py | 46 ++++++++++++++ 4 files changed, 196 insertions(+) create mode 100644 js/layers/military.js diff --git a/js/config.js b/js/config.js index 63ad929..2904679 100644 --- a/js/config.js +++ b/js/config.js @@ -88,6 +88,15 @@ export const CONFIG = { cap: 500, }, + // Military aircraft — ADS-B Exchange /v2/mil/ (REAL unfiltered military, the + // OSINT prize OpenSky hides). Paid RapidAPI feed; the same-origin proxy injects + // the key server-side AND 5-min-caches it, so poll cadence == cache TTL keeps + // the ~10k/month quota safe (~8.6k/month worst case). + adsbx: { + proxyMil: 'proxy/adsbx-mil', // RELATIVE — works at "/" and "/godsigh/" + pollMs: 300_000, // 5 min + }, + // Optional live AIS. Leave blank to use the demo fleet only (roadmap feature). AISSTREAM_API_KEY: '', @@ -108,6 +117,8 @@ export const CONFIG = { aircraftMid: '#ffe74d', aircraftHigh: '#4dd2ff', aircraftMil: '#ff5964', // military-callsign tint (overrides altitude band) + militaryReal: '#ff453a', // REAL military (ADS-B Exchange feed) + militaryEmerg: '#ff2d95', // military squawking 7500/7600/7700 or emergency // earthquakes (magnitude ramp: amber → red) quakeMin: '#ffcf50', quakeMax: '#ff3b30', diff --git a/js/layers/military.js b/js/layers/military.js new file mode 100644 index 0000000..b6a3cc3 --- /dev/null +++ b/js/layers/military.js @@ -0,0 +1,138 @@ +// Military aircraft (REAL) — ADS-B Exchange /v2/mil/ feed via the paid RapidAPI +// proxy. This is the unfiltered military traffic OpenSky/FR24 hide (C-17 Reach +// flights, tankers, the Area 51 "Janet" shuttle, etc.) — the OSINT prize. +// +// BillboardCollection primitive like the civilian aircraft layer, but a distinct +// red so it never blends in. Live-only (no history feed for this), polled every +// 5 min to match the server-side cache and respect the ~10k/month quota. + +export default function create(ctx) { + const { viewer, CONFIG, lib, ui, Cesium } = ctx; + const A = CONFIG.adsbx; + + const bc = viewer.scene.primitives.add(new Cesium.BillboardCollection()); + const glyph = lib.aircraftGlyph(); // shared triangle, tinted per-billboard + + let enabled = true; + let isLive = true; + let inFlight = false; + let timer = null; + let delay = A.pollMs; + + ui.addLayer('military', 'Military (ADS-B Exchange)', true, (on) => { + enabled = on; + bc.show = on && isLive; + if (on && isLive) kick(); + }); + ui.setStatus('military', 'loading…', 'warn'); + + // Transponder emergency codes → the highlight label. + const EMERG = { '7500': 'HIJACK', '7600': 'RADIO FAIL', '7700': 'EMERGENCY' }; + + function canPoll() { return enabled && isLive && !document.hidden; } + function schedule(ms) { if (timer) clearTimeout(timer); timer = setTimeout(tick, ms); } + function kick() { if (!canPoll() || timer || inFlight) return; schedule(0); } + + async function tick() { + timer = null; + if (!canPoll() || inFlight) return; + inFlight = true; + try { await poll(); } finally { inFlight = false; } + if (canPoll()) schedule(delay); + } + + // ADS-B Exchange altitude is in FEET (and "ground" for on-ground aircraft). + function altFeet(a) { return typeof a.alt_baro === 'number' ? a.alt_baro : 0; } + + async function poll() { + let res; + try { + res = await fetch(A.proxyMil); + } catch (err) { + ui.setStatus('military', 'network error — retrying', 'warn'); + return; + } + if (res.status === 503) { + // Key not configured (e.g. prod before the key is installed) — degrade quietly. + bc.removeAll(); + ui.setStatus('military', 'ADS-B Exchange not configured', 'warn'); + return; + } + if (!res.ok) { + ui.setStatus('military', `HTTP ${res.status} — retrying`, 'warn'); + return; + } + let data; + try { + data = await res.json(); + } catch (err) { + ui.setStatus('military', 'bad response — retrying', 'warn'); + return; + } + + const list = Array.isArray(data && data.ac) ? data.ac : []; + bc.removeAll(); + let count = 0; + let emergencies = 0; + for (const a of list) { + if (a.lat == null || a.lon == null) continue; + const sq = a.squawk; + const isEmerg = !!EMERG[sq] || (a.emergency && a.emergency !== 'none'); + if (isEmerg) emergencies++; + bc.add({ + position: Cesium.Cartesian3.fromDegrees(a.lon, a.lat, altFeet(a) * 0.3048), + image: glyph, + color: lib.cz(isEmerg ? CONFIG.colors.militaryEmerg : CONFIG.colors.militaryReal), + rotation: lib.headingToRotation(a.track), // lib negates — track may be absent (→0) + scale: isEmerg ? 1.05 : 0.82, // bolder than civilian; emergencies bigger still + id: { + layer: 'military', + hex: a.hex, + callsign: (a.flight || '').trim(), + type: a.t, + reg: a.r, + altFt: altFeet(a), + gs: a.gs, + track: a.track, + squawk: sq, + emerg: isEmerg ? (EMERG[sq] || String(a.emergency).toUpperCase()) : null, + }, + }); + count++; + } + + delay = A.pollMs; + const stamp = data.now ? new Date(data.now).toLocaleTimeString() : new Date().toLocaleTimeString(); + const em = emergencies ? ` · ${emergencies}⚠` : ''; + ui.setStatus('military', `${count} military · ${stamp}${em}`, emergencies ? 'err' : 'ok'); + } + + function onClockTick(currentTime, live) { + isLive = live; + if (!isLive) { bc.show = false; return; } // live-only feed; pause when scrubbed + bc.show = enabled; + if (enabled) kick(); + } + + function handlePick(picked) { + if (picked?.id?.layer !== 'military') return false; + const id = picked.id; + const rows = [ + ['Type', id.type || '—'], + ['Reg', id.reg || '—'], + ['Altitude', `${Math.round(id.altFt || 0)} ft`], + ['Speed', `${Math.round(id.gs || 0)} kt`], + ['Squawk', id.squawk || '—'], + ]; + if (id.emerg) rows.unshift(['⚠ Status', id.emerg]); + ui.showPickOverlay('✈ MIL ' + (id.callsign || id.hex || '—'), rows); + return true; + } + + function clearPick() { ui.hidePickOverlay(); } + + document.addEventListener('visibilitychange', () => { if (!document.hidden) kick(); }); + kick(); + + return { id: 'military', onClockTick, handlePick, clearPick }; +} diff --git a/js/main.js b/js/main.js index 00bcb46..8663572 100644 --- a/js/main.js +++ b/js/main.js @@ -172,6 +172,7 @@ const LAYER_MODULES = [ './layers/fires.js', './layers/ships.js', './layers/aircraft.js', + './layers/military.js', ]; const activeLayers = []; diff --git a/serve.py b/serve.py index aa61b6b..fe1b5b0 100644 --- a/serve.py +++ b/serve.py @@ -42,6 +42,22 @@ OPENSKY_CACHE_FRESH_SEC = 120 HISTORY_DB = Path(__file__).resolve().parent / "data" / "history.db" HISTORY_TOLERANCE_SEC = 600 +# ---- 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 for 5 min — the quota guard: even with many +# open tabs, at most ~288 upstream calls/day (~8.6k/month) are spent. +ADSBX_CREDS = Path(__file__).resolve().parent / "adsbxcredentials.json" +ADSBX_CACHE_SEC = 300 +_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") @@ -110,6 +126,8 @@ class Handler(SimpleHTTPRequestHandler): if not self.path.startswith("/proxy/"): return super().do_GET() name, _, query = self.path[len("/proxy/"):].partition("?") + 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}") @@ -205,6 +223,34 @@ class Handler(SimpleHTTPRequestHandler): out.write_bytes(png) return self._send_json(200, {"ok": True, "path": f"docs/{name}.png", "bytes": len(png)}) + 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)