diff --git a/js/adsb-registry.js b/js/adsb-registry.js new file mode 100644 index 0000000..1801fa4 --- /dev/null +++ b/js/adsb-registry.js @@ -0,0 +1,69 @@ +// ADS-B OSINT layer specs (Wave 3.1), rendered by createAdsbLayer. All feeds are +// FREE via adsb.lol (proxy/adsb/) — no key, works forever. Emergency is on +// by default (usually empty = cheap, dramatic when not); the hunters are off. + +// ICAO type designator → readable name (rare-type hunter). +const TYPE_NAMES = { + U2: 'U-2 Dragon Lady (spyplane)', + GHWK: 'RQ-4 Global Hawk (drone)', + E3TF: 'E-3 Sentry (AWACS)', + E3CF: 'E-3 Sentry (AWACS)', + E6: 'E-6 Mercury (TACAMO)', + E4: 'E-4B Nightwatch ("doomsday")', + RC135: 'RC-135 Rivet Joint (recon)', + B52: 'B-52 Stratofortress', + P8: 'P-8 Poseidon (sub-hunter)', +}; +const HUNT_TYPES = Object.keys(TYPE_NAMES); + +const SQUAWK_MEANING = { 7500: 'HIJACK', 7600: 'RADIO FAIL', 7700: 'EMERGENCY' }; +const SQUAWK_COLOR = { 7500: '#ff2d95', 7600: '#ff9f40', 7700: '#ff453a' }; + +const ftRow = (id) => [ + ['Type', id.t || '—'], + ['Reg', id.reg || '—'], + ['Altitude', `${Math.round(id.altFt || 0)} ft`], + ['Speed', `${Math.round(id.gs || 0)} kt`], +]; + +export const ADSB_LAYERS = [ + { + id: 'emergency', name: 'Emergency squawks', category: 'Air', defaultOn: true, + pollMs: 60000, alertOnHits: true, + endpoints: ['sqk/7700', 'sqk/7600', 'sqk/7500'], + color: (a) => SQUAWK_COLOR[a.squawk] || '#ff453a', + scale: 1.15, + pickTitle: (id) => `⚠ ${SQUAWK_MEANING[id.squawk] || 'SQUAWK'} ${id.flight || id.hex}`, + describe: (id) => [['Status', SQUAWK_MEANING[id.squawk] || id.squawk || '—'], ...ftRow(id)], + status: (n) => `${n} squawking emergency ⚠`, + emptyStatus: () => 'clear — no emergencies', + }, + { + id: 'rare-types', name: 'Rare-type hunter', category: 'Air', defaultOn: false, + pollMs: 120000, + // These are all military types, so filter the shared /v2/mil feed (one cached + // call the military layer already warms) instead of 8 rate-limited /type calls. + endpoints: ['mil'], + filter: (a) => HUNT_TYPES.includes(a.t), + color: () => '#35e0ff', + scale: 0.95, + pickTitle: (id) => `✈ ${TYPE_NAMES[id.t] || id.t || 'aircraft'}`, + describe: (id) => [['Aircraft', TYPE_NAMES[id.t] || id.t || '—'], ['Callsign', id.flight || '—'], + ['Reg', id.reg || '—'], ['Altitude', `${Math.round(id.altFt || 0)} ft`], ['Speed', `${Math.round(id.gs || 0)} kt`]], + status: (n) => `${n} rare-type aircraft up`, + emptyStatus: () => 'none of the tracked types airborne', + }, + { + id: 'shadow', name: 'Blocked / shadow aircraft', category: 'Air', defaultOn: false, + pollMs: 120000, + endpoints: ['ladd', 'pia'], + color: (a) => (a._src === 'pia' ? '#ff7bd5' : '#b57bff'), + scale: 0.8, + pickTitle: (id) => `👤 ${id.reg || id.flight || id.hex}`, + describe: (id) => [ + ['Program', id.src === 'pia' ? 'PIA (privacy address)' : 'LADD (FAA limited-display)'], + ...ftRow(id), + ], + status: (n) => `${n} blocked/shadow aircraft`, + }, +]; diff --git a/js/layers/adsb-layer.js b/js/layers/adsb-layer.js new file mode 100644 index 0000000..cdbd5a4 --- /dev/null +++ b/js/layers/adsb-layer.js @@ -0,0 +1,117 @@ +// Generic adsb.lol query-layer factory (free OSINT feeds). Powers the emergency +// squawk, rare-type hunter, and shadow-aircraft layers — each is a spec that +// names one or more adsb.lol v2 endpoints (proxy/adsb/), merged by hex and +// drawn as a BillboardCollection primitive with a click overlay. Live-only +// (paused when scrubbed off-now or the tab is hidden), like the aircraft layer. +// +// spec: { id, name, category, defaultOn, pollMs, +// endpoints: ['sqk/7700', 'type/U2', ...], // fetched sequentially (anti-burst) +// color(a) -> hex, scale(a)|number, label(a) -> string|null, +// describe(a) -> [[k,v],...], status(n) -> string, emptyStatus? -> string } + +export function createAdsbLayer(ctx, spec) { + const { viewer, Cesium, lib, ui } = ctx; + const bc = viewer.scene.primitives.add(new Cesium.BillboardCollection()); + const glyph = lib.aircraftGlyph(); + const pollMs = spec.pollMs || 90000; + + let enabled = spec.defaultOn !== false; + let isLive = true; + let inFlight = false; + let timer = null; + let lastOk = true; + + bc.show = enabled; + ui.addLayer(spec.id, spec.name, enabled, (on) => { + enabled = on; + bc.show = on && isLive; + if (on && isLive) kick(); + }, spec.category); + ui.setStatus(spec.id, 'loading…', 'warn'); + + 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; } + // On a total failure (e.g. adsb.lol rate-limited the whole batch), retry + // soon instead of waiting the full interval. + if (canPoll()) schedule(lastOk ? pollMs : 9000); + } + + const altFeet = (a) => (typeof a.alt_baro === 'number' ? a.alt_baro : 0); + + async function poll() { + // Fetch endpoints in PARALLEL and merge by hex. The proxy already spaces the + // upstream calls (rate-limit politeness), so parallel is safe here — and it + // means one slow/hung endpoint (adsb.lol can be flaky) never stalls the rest. + const byHex = new Map(); + let anyOk = false; + const results = await Promise.allSettled( + spec.endpoints.map((ep) => fetch(`proxy/adsb/${ep}`).then((r) => { + if (!r.ok) throw new Error(String(r.status)); + return r.json().then((d) => ({ ep, d })); + })) + ); + for (const r of results) { + if (r.status !== 'fulfilled') continue; + anyOk = true; + const { ep, d } = r.value; + for (const a of (Array.isArray(d.ac) ? d.ac : [])) { + if (a.lat == null || a.lon == null || byHex.has(a.hex)) continue; + a._src = ep; + byHex.set(a.hex, a); + } + } + lastOk = anyOk; + if (!anyOk) { ui.setStatus(spec.id, 'busy — retrying shortly', 'warn'); return; } + + // Optional client-side filter (e.g. rare-types filters the shared mil feed + // by ICAO type — one cached call, no per-type rate-limit risk). + let list = [...byHex.values()]; + if (spec.filter) list = list.filter(spec.filter); + + bc.removeAll(); + for (const a of list) { + bc.add({ + position: Cesium.Cartesian3.fromDegrees(a.lon, a.lat, altFeet(a) * 0.3048), + image: glyph, + color: lib.cz(spec.color(a)), + rotation: lib.headingToRotation(a.track), + scale: typeof spec.scale === 'function' ? spec.scale(a) : (spec.scale || 0.8), + id: { + layer: spec.id, hex: a.hex, flight: (a.flight || '').trim(), + t: a.t, reg: a.r, altFt: altFeet(a), gs: a.gs, track: a.track, + squawk: a.squawk, src: a._src, + }, + }); + } + const n = list.length; + const txt = n === 0 && spec.emptyStatus ? spec.emptyStatus() : spec.status(n); + ui.setStatus(spec.id, txt, n > 0 && spec.alertOnHits ? 'err' : 'ok'); + } + + function onClockTick(currentTime, live) { + isLive = live; + if (!isLive) { bc.show = false; return; } + bc.show = enabled; + if (enabled) kick(); + } + + function handlePick(picked) { + if (picked?.id?.layer !== spec.id) return false; + ui.showPickOverlay(spec.pickTitle ? spec.pickTitle(picked.id) : (picked.id.flight || picked.id.hex || '—'), + spec.describe(picked.id)); + return true; + } + function clearPick() { ui.hidePickOverlay(); } + + document.addEventListener('visibilitychange', () => { if (!document.hidden) kick(); }); + kick(); + + return { id: spec.id, onClockTick, handlePick, clearPick }; +} diff --git a/js/layers/radius.js b/js/layers/radius.js new file mode 100644 index 0000000..37c8d7d --- /dev/null +++ b/js/layers/radius.js @@ -0,0 +1,106 @@ +// Radius focus (Wave 3.1) — all traffic within 250 nm of where you're looking, +// via adsb.lol's free lat/lon/dist query (proxy/adsb/lat/.../lon/.../dist/250). +// Camera-driven: refetches when you stop panning/zooming, plus a slow refresh +// for freshness. Most useful zoomed into a region. Live-only; off by default. + +export default function create(ctx) { + const { viewer, Cesium, lib, ui } = ctx; + const bc = viewer.scene.primitives.add(new Cesium.BillboardCollection()); + const glyph = lib.aircraftGlyph(); + const DIST_NM = 250; // adsb.lol free radius cap + + let enabled = false; + let isLive = true; + let inFlight = false; + let debounce = null; + let poller = null; + let lastKey = null; + + bc.show = false; + ui.addLayer('radius', `Traffic near view (${DIST_NM}nm)`, false, (on) => { + enabled = on; + bc.show = on && isLive; + if (on && isLive) { refresh(); ensurePoller(); } + else { clearInterval(poller); poller = null; ui.setStatus('radius', on ? 'locating…' : '', on ? 'warn' : 'ok'); } + }); + + viewer.camera.percentageChanged = 0.25; // fire camera.changed a bit more eagerly + viewer.camera.changed.addEventListener(() => { + if (!enabled || !isLive) return; + if (debounce) clearTimeout(debounce); + debounce = setTimeout(refresh, 1800); // refetch after panning stops + }); + + function ensurePoller() { + if (poller) return; + poller = setInterval(() => { if (enabled && isLive && !document.hidden) refresh(); }, 60000); + } + + // Center = camera nadir point, rounded (also improves proxy cache hits). + function center() { + const c = viewer.camera.positionCartographic; + return [ + +Cesium.Math.toDegrees(c.latitude).toFixed(2), + +Cesium.Math.toDegrees(c.longitude).toFixed(2), + ]; + } + + async function refresh() { + if (!enabled || !isLive || document.hidden || inFlight) return; + const [lat, lon] = center(); + if (!isFinite(lat) || !isFinite(lon)) return; + const key = `${lat}/${lon}`; + lastKey = key; + inFlight = true; + try { + const res = await fetch(`proxy/adsb/lat/${lat}/lon/${lon}/dist/${DIST_NM}`); + if (key !== lastKey) return; // camera moved again mid-fetch — stale + if (!res.ok) { ui.setStatus('radius', `HTTP ${res.status}`, 'warn'); return; } + const data = await res.json(); + const list = Array.isArray(data.ac) ? data.ac : []; + bc.removeAll(); + let n = 0; + for (const a of list) { + if (a.lat == null || a.lon == null) continue; + const altFt = typeof a.alt_baro === 'number' ? a.alt_baro : 0; + bc.add({ + position: Cesium.Cartesian3.fromDegrees(a.lon, a.lat, altFt * 0.3048), + image: glyph, + color: lib.cz('#7bff9d'), + rotation: lib.headingToRotation(a.track), + scale: 0.7, + id: { layer: 'radius', hex: a.hex, flight: (a.flight || '').trim(), t: a.t, + reg: a.r, altFt, gs: a.gs, track: a.track }, + }); + n++; + } + ui.setStatus('radius', `${n} within ${DIST_NM}nm of view`, 'ok'); + } catch { + ui.setStatus('radius', 'network error', 'warn'); + } finally { + inFlight = false; + } + } + + function onClockTick(currentTime, live) { + isLive = live; + if (!isLive) { bc.show = false; return; } + bc.show = enabled; + if (enabled) { refresh(); ensurePoller(); } + } + + function handlePick(picked) { + if (picked?.id?.layer !== 'radius') return false; + const id = picked.id; + ui.showPickOverlay('✈ ' + (id.flight || id.hex || '—'), [ + ['Type', id.t || '—'], ['Reg', id.reg || '—'], + ['Altitude', `${Math.round(id.altFt || 0)} ft`], ['Speed', `${Math.round(id.gs || 0)} kt`], + ]); + return true; + } + function clearPick() { ui.hidePickOverlay(); } + + document.addEventListener('visibilitychange', () => { if (!document.hidden && enabled && isLive) refresh(); }); + + return { id: 'radius', onClockTick, handlePick, clearPick }; +} diff --git a/js/main.js b/js/main.js index 7f7845f..dc265be 100644 --- a/js/main.js +++ b/js/main.js @@ -20,6 +20,8 @@ import * as lib from './lib.js'; import * as ui from './ui.js'; import { REGISTRY } from './registry.js'; import { createGeoJsonLayer } from './layers/geojson-layer.js'; +import { ADSB_LAYERS } from './adsb-registry.js'; +import { createAdsbLayer } from './layers/adsb-layer.js'; const Cesium = window.Cesium; @@ -178,6 +180,7 @@ const LAYER_MODULES = [ './layers/ships.js', './layers/aircraft.js', './layers/military.js', + './layers/radius.js', ]; const activeLayers = []; @@ -210,6 +213,16 @@ async function loadLayers() { console.error(`[godsigh] registry layer ${spec.id} threw during init:`, err); } } + + // ADS-B OSINT layers (Wave 3.1): emergency squawks, rare-type hunter, shadow. + for (const spec of ADSB_LAYERS) { + try { + const layer = createAdsbLayer(ctx, spec); + if (layer) activeLayers.push(layer); + } catch (err) { + console.error(`[godsigh] adsb layer ${spec.id} threw during init:`, err); + } + } } // ---- live/scrub gating (throttled ~1/s) ---- diff --git a/js/ui.js b/js/ui.js index a609f47..6f54605 100644 --- a/js/ui.js +++ b/js/ui.js @@ -10,6 +10,7 @@ const COLLAPSED_BY_DEFAULT = new Set(['Regional — Australia']); const CATEGORY_BY_ID = { satellites: 'Space', 'sat-paths': 'Space', launches: 'Space', aircraft: 'Air', military: 'Air', 'aircraft-mil': 'Air', + emergency: 'Air', 'rare-types': 'Air', shadow: 'Air', radius: 'Air', ships: 'Sea', quakes: 'Earth & hazards', fires: 'Earth & hazards', gdacs: 'Earth & hazards', nws: 'Earth & hazards', infra: 'Context', events: 'Context', diff --git a/serve.py b/serve.py index e1bee53..b8f6b12 100644 --- a/serve.py +++ b/serve.py @@ -13,6 +13,7 @@ import re import sqlite3 import sys import tempfile +import threading import time import urllib.error import urllib.parse @@ -60,6 +61,28 @@ 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/) --------------------- +# 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.0 # 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 + # ---- 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 @@ -176,6 +199,8 @@ class Handler(SimpleHTTPRequestHandler): 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 == "adsbx-mil": return self._serve_adsbx_mil() base = UPSTREAMS.get(name) @@ -299,6 +324,41 @@ class Handler(SimpleHTTPRequestHandler): 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"}) + 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_adsbx_mil(self): host, key = load_adsbx_creds() if not host or not key: