feat: Military (ADS-B Exchange) layer — real unfiltered military aircraft

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 <noreply@anthropic.com>
This commit is contained in:
jing 2026-07-13 23:07:40 +10:00
parent 1e5a241f05
commit 0bbdb30719
4 changed files with 196 additions and 0 deletions

View File

@ -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',

138
js/layers/military.js Normal file
View File

@ -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 };
}

View File

@ -172,6 +172,7 @@ const LAYER_MODULES = [
'./layers/fires.js',
'./layers/ships.js',
'./layers/aircraft.js',
'./layers/military.js',
];
const activeLayers = [];

View File

@ -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)