228 lines
8.9 KiB
Python
228 lines
8.9 KiB
Python
"""Air-traffic ingestion — OpenSky Network state vectors over regions of interest.
|
|
|
|
This is *additional analysis input* for the political section, tagged
|
|
`"category": "flight_traffic"` in the same way the financial indicators are. It is
|
|
not a section of its own — synth/prompts/political.md decides whether any of it
|
|
means anything, and is told in as many words not to read ordinary civil aviation
|
|
as military posture.
|
|
|
|
Sourcing, all verified 2026-07-28:
|
|
|
|
OpenSky is the only one of the three obvious ADS-B aggregators with a
|
|
documented free REST API. FlightRadar24's ToS prohibit scraping and its
|
|
programmatic access is a paid product; ADS-B Exchange discontinued its
|
|
freemium RapidAPI tier on 2025-03-01 and now sells access from $10/month.
|
|
Neither is usable here without paying, so neither is offered as a fallback.
|
|
|
|
READ THIS BEFORE ENABLING: OpenSky's Terms of Use license the data for
|
|
non-profit research/education and personal use, and state that using the REST
|
|
API "in any operational capacity — including integration into a live product,
|
|
service, or automated system (even if only internal)" requires a prior written
|
|
agreement. A digest that runs on a timer is arguably exactly that. This is why
|
|
ENABLE_FLIGHT_TRAFFIC_INGEST is false by default: it is a decision for the
|
|
human running the box, not a default we can make for them. Attribution to
|
|
OpenSky is required wherever the data surfaces.
|
|
|
|
Rate limits are a daily credit budget per endpoint. Anonymous (by IP): 400
|
|
credits/day, current state vectors only, 10-second resolution. OAuth2 client
|
|
credentials: 4,000/day, 5-second resolution, up to 1h of history. Active
|
|
feeders: 8,000/day. A /states/all call costs 1 credit for a bounding box of
|
|
<=25 sq degrees, 2 for 25-100, 3 for 100-400, and 4 for a global dump. The
|
|
default region list below is deliberately a handful of bounded boxes rather
|
|
than one global dump: ~12 credits per run, ~48/day at the 4x/day cadence,
|
|
which fits inside even the anonymous budget with room to spare.
|
|
|
|
Basic auth was removed on 2026-03-18; OAuth2 client credentials is the only
|
|
authenticated flow that still works.
|
|
|
|
The military-aircraft signal here is a callsign-prefix heuristic and nothing
|
|
more. It catches transport and tanker traffic flying under published national
|
|
callsigns (REACH, ASCOT, CANFORCE) because those aircraft have no reason to hide.
|
|
It will not catch anything that has switched its transponder off, which is most
|
|
of what would actually matter. Absence of matches is therefore not evidence of
|
|
absence, and the prompt is told so.
|
|
|
|
Configuration is env-only:
|
|
|
|
FLIGHT_REGIONS — semicolon-separated `Name:lamin,lomin,lamax,lomax` boxes in
|
|
decimal degrees. The shipped default is a placeholder list of
|
|
currently-tense regions and needs the user's review; conflict
|
|
zones move and this file does not update itself.
|
|
FLIGHT_MILITARY_CALLSIGN_PREFIXES — comma-separated callsign prefixes counted
|
|
as military.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from datetime import datetime, timezone
|
|
|
|
import requests
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
|
|
STATES_URL = "https://opensky-network.org/api/states/all"
|
|
TOKEN_URL = "https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token"
|
|
|
|
HTTP_TIMEOUT = 45
|
|
|
|
DEFAULT_REGIONS = (
|
|
"Eastern Mediterranean / Levant:30,32,37,37;"
|
|
"Black Sea / Ukraine:44,29,53,41;"
|
|
"Persian Gulf / Strait of Hormuz:24,50,30,60;"
|
|
"Red Sea / Bab el-Mandeb:12,38,20,45;"
|
|
"Taiwan Strait:21,117,27,124;"
|
|
"Baltic / Kaliningrad:53,17,60,28"
|
|
)
|
|
|
|
DEFAULT_MILITARY_PREFIXES = "RCH,CNV,RRR,CFC,GAF,IAM,FAF,BAF,NAF,PLF,HAF,NATO,SVF"
|
|
|
|
# /states/all returns bare arrays, not objects, so the offsets have to be named here.
|
|
_ICAO24 = 0
|
|
_CALLSIGN = 1
|
|
_ORIGIN_COUNTRY = 2
|
|
_LONGITUDE = 5
|
|
_LATITUDE = 6
|
|
_BARO_ALTITUDE = 7
|
|
_ON_GROUND = 8
|
|
_VELOCITY = 9
|
|
_GEO_ALTITUDE = 13
|
|
|
|
|
|
def _csv_list(name, default):
|
|
raw = os.environ.get(name, default)
|
|
return [item.strip() for item in raw.split(",") if item.strip()]
|
|
|
|
|
|
def _parse_regions(raw):
|
|
regions = []
|
|
for chunk in raw.split(";"):
|
|
chunk = chunk.strip()
|
|
if not chunk:
|
|
continue
|
|
try:
|
|
name, box = chunk.rsplit(":", 1)
|
|
lamin, lomin, lamax, lomax = (float(part) for part in box.split(","))
|
|
regions.append(
|
|
{
|
|
"name": name.strip(),
|
|
"lamin": lamin,
|
|
"lomin": lomin,
|
|
"lamax": lamax,
|
|
"lomax": lomax,
|
|
}
|
|
)
|
|
except Exception:
|
|
LOG.warning("flight_traffic: could not parse region %r, skipping", chunk, exc_info=True)
|
|
return regions
|
|
|
|
|
|
def _access_token(client_id, client_secret):
|
|
response = requests.post(
|
|
TOKEN_URL,
|
|
data={
|
|
"grant_type": "client_credentials",
|
|
"client_id": client_id,
|
|
"client_secret": client_secret,
|
|
},
|
|
timeout=HTTP_TIMEOUT,
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()["access_token"]
|
|
|
|
|
|
def _is_military(callsign, prefixes):
|
|
return any(callsign.startswith(prefix) for prefix in prefixes)
|
|
|
|
|
|
def _summarise(region, states, prefixes, max_military):
|
|
military = []
|
|
military_total = 0
|
|
on_ground = 0
|
|
|
|
for state in states:
|
|
if state[_ON_GROUND]:
|
|
on_ground += 1
|
|
callsign = (state[_CALLSIGN] or "").strip().upper()
|
|
if not callsign or not _is_military(callsign, prefixes):
|
|
continue
|
|
military_total += 1
|
|
if len(military) >= max_military:
|
|
continue
|
|
military.append(
|
|
{
|
|
"callsign": callsign,
|
|
"icao24": state[_ICAO24],
|
|
"origin_country": state[_ORIGIN_COUNTRY],
|
|
"lat": state[_LATITUDE],
|
|
"lon": state[_LONGITUDE],
|
|
"altitude_m": state[_GEO_ALTITUDE] if state[_GEO_ALTITUDE] is not None else state[_BARO_ALTITUDE],
|
|
"velocity_ms": state[_VELOCITY],
|
|
"on_ground": state[_ON_GROUND],
|
|
}
|
|
)
|
|
|
|
return {
|
|
"source": "opensky",
|
|
"category": "flight_traffic",
|
|
"region": region["name"],
|
|
"bbox": [region["lamin"], region["lomin"], region["lamax"], region["lomax"]],
|
|
"observed_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
|
"aircraft_total": len(states),
|
|
"aircraft_on_ground": on_ground,
|
|
"military_callsign_matches": military,
|
|
"military_callsign_match_count": military_total,
|
|
"attribution": "Data: The OpenSky Network, https://opensky-network.org",
|
|
"caveat": (
|
|
"Instantaneous snapshot of ADS-B-visible aircraft only. Military callsign "
|
|
"matching is a prefix heuristic; aircraft with transponders off are invisible."
|
|
),
|
|
}
|
|
|
|
|
|
def fetch(lookback_hours):
|
|
del lookback_hours # the free tier serves the current state vector only; there is no window to query
|
|
|
|
regions = _parse_regions(os.environ.get("FLIGHT_REGIONS", DEFAULT_REGIONS))
|
|
if not regions:
|
|
LOG.warning("flight_traffic: FLIGHT_REGIONS parsed to nothing, skipping")
|
|
return []
|
|
|
|
prefixes = [item.upper() for item in _csv_list("FLIGHT_MILITARY_CALLSIGN_PREFIXES", DEFAULT_MILITARY_PREFIXES)]
|
|
max_military = int(os.environ.get("FLIGHT_MAX_MILITARY_PER_REGION", "20"))
|
|
|
|
headers = {}
|
|
client_id = os.environ.get("OPENSKY_CLIENT_ID", "").strip()
|
|
client_secret = os.environ.get("OPENSKY_CLIENT_SECRET", "").strip()
|
|
if client_id and client_secret:
|
|
try:
|
|
headers["Authorization"] = "Bearer " + _access_token(client_id, client_secret)
|
|
except Exception:
|
|
# Anonymous access still works at a tenth of the credit budget, which is
|
|
# enough for the default region list, so a bad token is not fatal.
|
|
LOG.warning("flight_traffic: OpenSky token request failed, falling back to anonymous", exc_info=True)
|
|
else:
|
|
LOG.info("flight_traffic: no OPENSKY_CLIENT_ID/SECRET, using anonymous access (400 credits/day)")
|
|
|
|
results = []
|
|
for region in regions:
|
|
try:
|
|
response = requests.get(
|
|
STATES_URL,
|
|
params={
|
|
"lamin": region["lamin"],
|
|
"lomin": region["lomin"],
|
|
"lamax": region["lamax"],
|
|
"lomax": region["lomax"],
|
|
},
|
|
headers=headers,
|
|
timeout=HTTP_TIMEOUT,
|
|
)
|
|
response.raise_for_status()
|
|
states = response.json().get("states") or []
|
|
results.append(_summarise(region, states, prefixes, max_military))
|
|
except Exception:
|
|
LOG.warning("flight_traffic: region %s failed, skipping", region["name"], exc_info=True)
|
|
|
|
LOG.info("flight_traffic: %d of %d region(s) sampled", len(results), len(regions))
|
|
return results
|