SmartestHome/digest-engine/ingest/flight_traffic.py

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