feat: add adsb.lol as primary military aircraft source

OpenSky anonymous access returns 429 consistently. Added adsb.lol
free community API (/v2/mil endpoint) as primary source with OpenSky
as fallback.

- _fetch_adsblol_military: dedicated military endpoint, no filtering needed
- _fetch_opensky_military: original OpenSky logic as fallback
- _icao_to_country: derives country from ICAO hex prefix (US, UK, FR, etc.)
- adsblol rate limit: 5s between calls (community API, be polite)
- Cache TTL increased to 300s for both sources
- bbox filtering applied client-side for adsb.lol data

Result: 127 military aircraft from multiple nations now visible on map.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Marc Shade
2026-02-23 21:23:11 -05:00
parent 6c2159d06d
commit 2d46b064dd
2 changed files with 134 additions and 31 deletions
+1
View File
@@ -31,6 +31,7 @@ _SOURCE_RATE_LIMITS: dict[str, float] = {
"cloudflare-radar": 3.0, # 20 req/min
"reddit": 1.5, # ~60 req/min (be conservative)
"nasa-firms": 2.0, # API key: ~1000 req/day
"adsblol": 5.0, # community API — be very polite
"polymarket": 1.0, # be polite
"faa": 1.0, # govt API
"usgs": 1.0, # generous but be polite
+133 -31
View File
@@ -19,6 +19,7 @@ logger = logging.getLogger("world-intel-mcp.sources.military")
# ---------------------------------------------------------------------------
_OPENSKY_STATES_URL = "https://opensky-network.org/api/states/all"
_ADSBLOL_MIL_URL = "https://api.adsb.lol/v2/mil"
_HEXDB_BASE_URL = "https://hexdb.io/api/v1/aircraft"
# ICAO hex prefix ranges known to be allocated to military operators.
@@ -40,6 +41,39 @@ MILITARY_CALLSIGN_PREFIXES = [
"SPAR", "VALOR", "BLADE",
]
# ICAO hex prefix → country mapping for military aircraft.
_ICAO_COUNTRY: list[tuple[str, str]] = [
("AE", "United States"), ("A8", "United States"), ("A9", "United States"),
("AA", "United States"), ("AB", "United States"), ("AC", "United States"),
("AD", "United States"),
("43C", "United Kingdom"), ("43D", "United Kingdom"),
("43E", "United Kingdom"), ("43F", "United Kingdom"),
("3F", "Germany"), ("3A8", "France"), ("3A9", "France"),
("3AA", "France"), ("3AB", "France"),
("500", "Israel"), ("501", "Israel"), ("502", "Israel"),
("70", "Pakistan"), ("C0", "Canada"),
("34", "Italy"), ("3C", "Germany"),
("E4", "Brazil"), ("71", "Turkey"),
("50", "Israel"), ("48", "Netherlands"),
("44", "Austria"), ("45", "Belgium"),
("46", "Bulgaria"), ("49", "Denmark"),
("4A", "Finland"), ("39", "France"),
("4B", "Greece"), ("4D", "Hungary"),
("4C", "Ireland"), ("30", "Italy"),
("73", "Japan"), ("78", "China"),
("7C", "Australia"), ("C8", "Australia"),
]
def _icao_to_country(icao24: str) -> str:
"""Derive country from ICAO24 hex address prefix."""
upper = icao24.upper()
# Try longest prefixes first for specificity
for prefix, country in sorted(_ICAO_COUNTRY, key=lambda x: -len(x[0])):
if upper.startswith(prefix):
return country
return ""
# Theater bounding boxes for global military posture assessment.
THEATERS = {
"european": {"bbox": "35,-25,72,45", "desc": "NATO/Russia theater"},
@@ -110,19 +144,45 @@ def _extract_aircraft(state: list) -> dict:
# Public API
# ---------------------------------------------------------------------------
async def fetch_military_flights(
fetcher: Fetcher,
bbox: str | None = None,
) -> dict:
"""Fetch current military aircraft positions from the OpenSky Network.
async def _fetch_adsblol_military(fetcher: Fetcher) -> list[dict] | None:
"""Fetch military aircraft from adsb.lol free API (pre-filtered)."""
data = await fetcher.get_json(
url=_ADSBLOL_MIL_URL,
source="adsblol",
cache_key="military:adsblol:mil",
cache_ttl=300,
)
if data is None:
return None
Args:
fetcher: Shared HTTP fetcher with caching and circuit breaking.
bbox: Optional bounding box as "lamin,lomin,lamax,lomax".
aircraft: list[dict] = []
for ac in data.get("ac") or []:
lat = ac.get("lat")
lon = ac.get("lon")
if lat is None or lon is None:
continue
icao24 = ac.get("hex", "")
aircraft.append({
"icao24": icao24,
"callsign": (ac.get("flight") or "").strip() or None,
"origin_country": _icao_to_country(icao24),
"latitude": lat,
"longitude": lon,
"altitude_m": ac.get("alt_baro"),
"velocity_ms": ac.get("gs"),
"heading": ac.get("track"),
"on_ground": ac.get("alt_baro") == "ground",
"squawk": ac.get("squawk"),
"aircraft_type": ac.get("t"),
"registration": ac.get("r"),
})
return aircraft
Returns:
Dict with aircraft list, count, filter description, source, and timestamp.
"""
async def _fetch_opensky_military(
fetcher: Fetcher, bbox: str | None = None,
) -> list[dict] | None:
"""Fetch military aircraft from OpenSky (requires filtering)."""
params: dict[str, str] = {}
if bbox is not None:
parts = bbox.split(",")
@@ -139,39 +199,81 @@ async def fetch_military_flights(
url=_OPENSKY_STATES_URL,
source="opensky",
cache_key=f"military:flights:{cache_label}",
cache_ttl=120,
cache_ttl=300,
headers=headers,
params=params if params else None,
)
if data is None:
logger.warning("OpenSky API returned no data (bbox=%s)", cache_label)
return None
states = data.get("states") or []
aircraft: list[dict] = []
for state in states:
if not state or len(state) < 17:
continue
icao24 = state[0] or ""
callsign = state[1] or ""
if _is_military_icao(icao24) or _is_military_callsign(callsign):
aircraft.append(_extract_aircraft(state))
return aircraft
async def fetch_military_flights(
fetcher: Fetcher,
bbox: str | None = None,
) -> dict:
"""Fetch current military aircraft positions.
Tries adsb.lol (free, pre-filtered military endpoint) first,
falls back to OpenSky Network if unavailable.
Args:
fetcher: Shared HTTP fetcher with caching and circuit breaking.
bbox: Optional bounding box as "lamin,lomin,lamax,lomax" (OpenSky only).
Returns:
Dict with aircraft list, count, filter description, source, and timestamp.
"""
# Primary: adsb.lol (free, dedicated military endpoint, no filtering needed)
aircraft = await _fetch_adsblol_military(fetcher)
if aircraft is not None and len(aircraft) > 0:
# Apply bbox filter client-side if requested
if bbox is not None:
parts = bbox.split(",")
if len(parts) == 4:
la_min, lo_min, la_max, lo_max = map(float, parts)
aircraft = [
a for a in aircraft
if a["latitude"] is not None and a["longitude"] is not None
and la_min <= a["latitude"] <= la_max
and lo_min <= a["longitude"] <= lo_max
]
return {
"aircraft": [],
"count": 0,
"aircraft": aircraft,
"count": len(aircraft),
"military_filter": "adsblol_mil_endpoint",
"source": "adsb.lol",
"timestamp": _utc_now_iso(),
}
# Fallback: OpenSky Network
logger.debug("adsb.lol unavailable, trying OpenSky")
aircraft = await _fetch_opensky_military(fetcher, bbox)
if aircraft is not None:
return {
"aircraft": aircraft,
"count": len(aircraft),
"military_filter": "icao_prefix+callsign",
"source": "opensky",
"timestamp": _utc_now_iso(),
}
states = data.get("states") or []
military_aircraft: list[dict] = []
for state in states:
if not state or len(state) < 17:
continue
icao24 = state[0] or ""
callsign = state[1] or ""
if _is_military_icao(icao24) or _is_military_callsign(callsign):
military_aircraft.append(_extract_aircraft(state))
return {
"aircraft": military_aircraft,
"count": len(military_aircraft),
"aircraft": [],
"count": 0,
"military_filter": "icao_prefix+callsign",
"source": "opensky",
"source": "none",
"timestamp": _utc_now_iso(),
}