wave3.1: ADS-B OSINT layers (emergency squawks, rare-type hunter, shadow, radius focus)
All FREE via adsb.lol. New generic pieces:
- serve.py proxy/adsb/<path>: regex-allowlisted (SSRF-safe) adsb.lol v2 proxy
with per-path 120s cache + upstream call-spacing (adsb.lol 429s on bursts;
reservation slots space call *starts* without serializing network time),
12s fail-fast timeout, stale-on-error
- js/layers/adsb-layer.js: generic {ac:[...]} billboard factory (parallel
allSettled fetch, live/scrub/visibility gating, optional client-side filter)
- js/adsb-registry.js: emergency (sqk 7700/7600/7500, default-on, alerts red),
rare-type hunter (filters the shared mil feed by ICAO type — 1 cached call,
no per-type rate-limit), shadow (LADD+PIA, purple/pink)
- js/layers/radius.js: camera-driven 'all traffic within 250nm of view'
- verified live: all four render, worst-case simultaneous-enable ok, 0 errors
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
eb3d3cca96
commit
60ceb4a261
69
js/adsb-registry.js
Normal file
69
js/adsb-registry.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
// ADS-B OSINT layer specs (Wave 3.1), rendered by createAdsbLayer. All feeds are
|
||||||
|
// FREE via adsb.lol (proxy/adsb/<path>) — 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`,
|
||||||
|
},
|
||||||
|
];
|
||||||
117
js/layers/adsb-layer.js
Normal file
117
js/layers/adsb-layer.js
Normal file
@ -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/<path>), 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 };
|
||||||
|
}
|
||||||
106
js/layers/radius.js
Normal file
106
js/layers/radius.js
Normal file
@ -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 };
|
||||||
|
}
|
||||||
13
js/main.js
13
js/main.js
@ -20,6 +20,8 @@ import * as lib from './lib.js';
|
|||||||
import * as ui from './ui.js';
|
import * as ui from './ui.js';
|
||||||
import { REGISTRY } from './registry.js';
|
import { REGISTRY } from './registry.js';
|
||||||
import { createGeoJsonLayer } from './layers/geojson-layer.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;
|
const Cesium = window.Cesium;
|
||||||
|
|
||||||
@ -178,6 +180,7 @@ const LAYER_MODULES = [
|
|||||||
'./layers/ships.js',
|
'./layers/ships.js',
|
||||||
'./layers/aircraft.js',
|
'./layers/aircraft.js',
|
||||||
'./layers/military.js',
|
'./layers/military.js',
|
||||||
|
'./layers/radius.js',
|
||||||
];
|
];
|
||||||
const activeLayers = [];
|
const activeLayers = [];
|
||||||
|
|
||||||
@ -210,6 +213,16 @@ async function loadLayers() {
|
|||||||
console.error(`[godsigh] registry layer ${spec.id} threw during init:`, err);
|
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) ----
|
// ---- live/scrub gating (throttled ~1/s) ----
|
||||||
|
|||||||
1
js/ui.js
1
js/ui.js
@ -10,6 +10,7 @@ const COLLAPSED_BY_DEFAULT = new Set(['Regional — Australia']);
|
|||||||
const CATEGORY_BY_ID = {
|
const CATEGORY_BY_ID = {
|
||||||
satellites: 'Space', 'sat-paths': 'Space', launches: 'Space',
|
satellites: 'Space', 'sat-paths': 'Space', launches: 'Space',
|
||||||
aircraft: 'Air', military: 'Air', 'aircraft-mil': 'Air',
|
aircraft: 'Air', military: 'Air', 'aircraft-mil': 'Air',
|
||||||
|
emergency: 'Air', 'rare-types': 'Air', shadow: 'Air', radius: 'Air',
|
||||||
ships: 'Sea',
|
ships: 'Sea',
|
||||||
quakes: 'Earth & hazards', fires: 'Earth & hazards', gdacs: 'Earth & hazards', nws: 'Earth & hazards',
|
quakes: 'Earth & hazards', fires: 'Earth & hazards', gdacs: 'Earth & hazards', nws: 'Earth & hazards',
|
||||||
infra: 'Context', events: 'Context',
|
infra: 'Context', events: 'Context',
|
||||||
|
|||||||
60
serve.py
60
serve.py
@ -13,6 +13,7 @@ import re
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@ -60,6 +61,28 @@ ADSBLOL_MIL_URL = "https://api.adsb.lol/v2/mil"
|
|||||||
ADSBLOL_CACHE_SEC = 60
|
ADSBLOL_CACHE_SEC = 60
|
||||||
_adsblol_cache = {"body": None, "ts": 0.0}
|
_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.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) ------------
|
# ---- ADS-B Exchange military feed (paid RapidAPI, ~10k req/month) ------------
|
||||||
# proxy/adsbx-mil injects the RapidAPI key server-side (never in the browser) and
|
# 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
|
# 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("?")
|
name, _, query = self.path[len("/proxy/"):].partition("?")
|
||||||
if name == "mil":
|
if name == "mil":
|
||||||
return self._serve_mil()
|
return self._serve_mil()
|
||||||
|
if name.startswith("adsb/"):
|
||||||
|
return self._serve_adsb(name[len("adsb/"):])
|
||||||
if name == "adsbx-mil":
|
if name == "adsbx-mil":
|
||||||
return self._serve_adsbx_mil()
|
return self._serve_adsbx_mil()
|
||||||
base = UPSTREAMS.get(name)
|
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(200, "application/json", _adsblol_cache["body"], {"X-Godsigh-Cache": "stale"})
|
||||||
return self._send_json(502, {"error": str(e)})
|
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):
|
def _serve_adsbx_mil(self):
|
||||||
host, key = load_adsbx_creds()
|
host, key = load_adsbx_creds()
|
||||||
if not host or not key:
|
if not host or not key:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user