"""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