diff --git a/.env.example b/.env.example index 72b0774..abdb94a 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,12 @@ PUBLIC_BASE_URL=https://boarding-pass.example.com AIRTRAIL_BASE_URL=https://airtrail.example.com AIRTRAIL_API_KEY= +# Optional: RapidAPI AeroDataBox key, used to fill in scheduled/actual GATE +# times (same provider AirTrail's own "Search" button uses). Leave blank to +# skip this - gate times just stay blank for AirTrail's manual Search to +# fill in, same as before this was added. +AERODATABOX_API_KEY= + # Container timezone. Set explicitly - flight_date + 1 day and the daily # job's run time are date-sensitive. TZ=Australia/Sydney diff --git a/aerodatabox.py b/aerodatabox.py new file mode 100644 index 0000000..8ba4e12 --- /dev/null +++ b/aerodatabox.py @@ -0,0 +1,150 @@ +"""Fetch scheduled + actual gate departure/arrival times, plus actual +takeoff/landing (runway) times, from AeroDataBox (RapidAPI) - the same +provider AirTrail's own "Search" button uses internally when you fill in +a flight manually. + +Confirmed directly against AirTrail's source (johanohly/AirTrail): +- src/lib/server/utils/flight-lookup/aerodatabox.ts (endpoint, headers, + response shape, and the actualTime -> revisedTime -> scheduledTime + fallback order for "best known actual time") +- src/lib/zod/flight.ts (target field names: departure/departureScheduled/ + arrival/arrivalScheduled expect full-seconds ISO-8601 datetimes) + +Why this exists as well as flightaware.py: FlightAware's public history +page (scraped there) only gives us a GPS track - no gate/schedule times. +BCBP boarding passes don't carry a time-of-day at all. AeroDataBox is the +only piece that can fill in actual scheduled/gate-actual times without +requiring a logged-in AirTrail session (its own lookup is cookie-authed, +not reachable with our API key). + +VERIFIED (2026-09-12, real key, Free Tier): a flight 4 days in the past +(VA559, 2026-09-08) returned full data - scheduledTime/revisedTime/ +runwayTime on both legs. Free Tier quota is 400 API units / period, 1 +unit per lookup here, so the daily one-flight job has plenty of headroom. +A missing/empty response should still fail soft (see lookup_gate_times' +return of None) rather than block the FlightAware track or the +review/approve flow - just in case an individual flight falls outside +whatever window AeroDataBox actually indexed. + +Bonus found in that same real response: AeroDataBox also returns +departure.runwayTime / arrival.runwayTime - actual takeoff/landing off +the runway, not just gate times. AirTrail's own aerodatabox.ts never +reads this field at all (confirmed against its source above - it types +departure/arrival with only scheduledTime/revisedTime/actualTime, no +runwayTime), which is why AirTrail's own Search button leaves +Takeoff/Landing blank even after a successful search. We use it here for +takeoffActual/landingActual - preferred in scheduler.py over the +FlightAware-track-derived guess when present, since it's an authoritative +field rather than an inferred one (the track only tells us when the +transponder started/stopped squawking, which is close to but not exactly +wheels-up/touchdown). +""" + +import json +import re +import urllib.error +import urllib.request +from datetime import date, datetime + +import config + +BASE_URL = "https://aerodatabox.p.rapidapi.com" + +# Fields this module can fill in build_save_payload(), keyed the same way +# AirTrail's /api/flight/save expects them. +GATE_FIELDS = ( + "departureScheduled", + "departure", + "arrivalScheduled", + "arrival", + "takeoffActual", + "landingActual", +) + + +class AeroDataBoxError(Exception): + """Network/config/HTTP error - safe to retry on a later run, same + treatment as FlightAwareTransientError.""" + + +def _sanitize(flight_number: str) -> str: + return re.sub(r"[\s-]", "", flight_number).upper() + + +def _parse_local(local: str | None) -> str | None: + """AeroDataBox 'local' strings look like '2026-06-22 13:45+10:00' - + already carrying their own UTC offset, no separate airport-timezone + lookup needed. Reformats to the seconds-precision ISO-8601 string + AirTrail's zod schema (z.string().datetime({offset: true})) requires, + e.g. '2026-06-22T13:45:00+10:00'.""" + if not local: + return None + try: + dt = datetime.strptime(local, "%Y-%m-%d %H:%M%z") + except ValueError: + return None + return dt.isoformat(timespec="seconds") + + +def lookup_gate_times(flight_number: str, flight_date: date) -> dict | None: + """Returns a dict with some/all of GATE_FIELDS set (None for anything + AeroDataBox didn't have), or None if the lookup failed outright or + matched nothing. Never raises for "no data" - only for genuine + network/config failures (AeroDataBoxError), mirroring flightaware.py's + distinction between "definitively not found" and "try again later".""" + if not config.AERODATABOX_API_KEY: + return None + + cleaned = _sanitize(flight_number) + url = ( + f"{BASE_URL}/flights/number/{cleaned}/{flight_date.isoformat()}" + "?dateLocalRole=Both&withAircraftImage=false&withLocation=false" + ) + req = urllib.request.Request( + url, headers={"x-rapidapi-key": config.AERODATABOX_API_KEY} + ) + try: + with urllib.request.urlopen(req, timeout=20) as resp: + raw = resp.read() + except urllib.error.HTTPError as e: + if e.code == 204: + return None + raise AeroDataBoxError(f"HTTP {e.code} from AeroDataBox") from e + except urllib.error.URLError as e: + raise AeroDataBoxError(f"error reaching AeroDataBox: {e}") from e + + try: + data = json.loads(raw.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError) as e: + raise AeroDataBoxError(f"bad AeroDataBox response: {e}") from e + + if not isinstance(data, list) or not data: + return None + + # BCBP gives no time-of-day, so - same limitation as the FlightAware + # step - we can't disambiguate multiple same-day legs of the same + # flight number from the boarding pass alone. Take the first result, + # same as AirTrail's own lookup falls back to when nothing else to + # filter on (from/to aren't sent here, unlike AirTrail's UI search, + # since AeroDataBox's API doesn't support filtering by them server-side + # anyway - see the PR that added client-side from/to filtering upstream). + flight = data[0] + dep = flight.get("departure") or {} + arr = flight.get("arrival") or {} + + return { + "departureScheduled": _parse_local((dep.get("scheduledTime") or {}).get("local")), + "departure": _parse_local( + (dep.get("actualTime") or {}).get("local") + or (dep.get("revisedTime") or {}).get("local") + or (dep.get("scheduledTime") or {}).get("local") + ), + "arrivalScheduled": _parse_local((arr.get("scheduledTime") or {}).get("local")), + "arrival": _parse_local( + (arr.get("actualTime") or {}).get("local") + or (arr.get("revisedTime") or {}).get("local") + or (arr.get("scheduledTime") or {}).get("local") + ), + "takeoffActual": _parse_local((dep.get("runwayTime") or {}).get("local")), + "landingActual": _parse_local((arr.get("runwayTime") or {}).get("local")), + } diff --git a/airtrail_client.py b/airtrail_client.py index a51b702..962f989 100644 --- a/airtrail_client.py +++ b/airtrail_client.py @@ -98,7 +98,10 @@ def build_save_payload(row: dict, existing_id: int | None) -> dict: payload = { "from": row["from_iata"], "to": row["to_iata"], - "departure": f"{flight_date_str}T00:00:00.000+00:00", + # Falls back to the bare date (day precision, no time) exactly like + # before if AeroDataBox never returned a gate-actual time - e.g. no + # AERODATABOX_API_KEY configured, or the lookup came up empty. + "departure": row.get("gate_departure_actual") or f"{flight_date_str}T00:00:00.000+00:00", "datePrecision": "day", "flightNumber": flight_number or None, "airline": row.get("operating_carrier_icao"), @@ -117,6 +120,23 @@ def build_save_payload(row: dict, existing_id: int | None) -> dict: ], } + # Scheduled/actual gate times from AeroDataBox (scheduler._fetch_gate_times), + # and actual takeoff/landing derived from the FlightAware track's first/last + # timestamp (scheduler.process_one_flight). Any of these can be missing - + # e.g. no AERODATABOX_API_KEY set, the lookup came up empty, or no track was + # found - in which case the field is simply omitted and AirTrail's own + # manual Search remains the fallback, same as before this change. + if row.get("gate_departure_scheduled"): + payload["departureScheduled"] = row["gate_departure_scheduled"] + if row.get("gate_arrival_scheduled"): + payload["arrivalScheduled"] = row["gate_arrival_scheduled"] + if row.get("gate_arrival_actual"): + payload["arrival"] = row["gate_arrival_actual"] + if row.get("takeoff_actual"): + payload["takeoffActual"] = row["takeoff_actual"] + if row.get("landing_actual"): + payload["landingActual"] = row["landing_actual"] + coordinates = row.get("track_coordinates") if coordinates: times = row.get("track_times") diff --git a/config.py b/config.py index e497ae4..25f93bc 100644 --- a/config.py +++ b/config.py @@ -13,6 +13,12 @@ PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", "").rstrip("/") AIRTRAIL_BASE_URL = os.environ.get("AIRTRAIL_BASE_URL", "").rstrip("/") AIRTRAIL_API_KEY = os.environ.get("AIRTRAIL_API_KEY", "") +# RapidAPI AeroDataBox key - same provider/plan AirTrail's own "Search" +# button uses internally to fill in schedule/actual gate times. Optional: +# if unset, gate scheduled/actual times are simply left for AirTrail's own +# manual Search to fill in, same as today. +AERODATABOX_API_KEY = os.environ.get("AERODATABOX_API_KEY", "") + FLIGHTAWARE_USER_AGENT = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0 Safari/537.36" diff --git a/db.py b/db.py index 3cd82ff..5e4a2cc 100644 --- a/db.py +++ b/db.py @@ -33,6 +33,13 @@ CREATE TABLE IF NOT EXISTS pending_flights ( track_times TEXT, track_point_count INTEGER, flightaware_url TEXT, + takeoff_actual TEXT, + landing_actual TEXT, + gate_departure_scheduled TEXT, + gate_departure_actual TEXT, + gate_arrival_scheduled TEXT, + gate_arrival_actual TEXT, + schedule_error TEXT, airtrail_flight_id INTEGER, parse_warnings TEXT, error_message TEXT, @@ -68,6 +75,29 @@ def connect(): conn.close() +# Columns added after the original schema. CREATE TABLE IF NOT EXISTS above +# is a no-op against an already-existing table, so anything added there +# needs a matching entry here too or it silently never appears on an +# upgraded (vs. freshly created) database. +MIGRATIONS = [ + ("takeoff_actual", "TEXT"), + ("landing_actual", "TEXT"), + ("gate_departure_scheduled", "TEXT"), + ("gate_departure_actual", "TEXT"), + ("gate_arrival_scheduled", "TEXT"), + ("gate_arrival_actual", "TEXT"), + ("schedule_error", "TEXT"), +] + + +def _run_migrations(conn: sqlite3.Connection) -> None: + existing = {row["name"] for row in conn.execute("PRAGMA table_info(pending_flights)")} + for column, coltype in MIGRATIONS: + if column not in existing: + conn.execute(f"ALTER TABLE pending_flights ADD COLUMN {column} {coltype}") + + def init_db() -> None: with connect() as conn: conn.executescript(SCHEMA) + _run_migrations(conn) diff --git a/scheduler.py b/scheduler.py index ab2e70b..cb77644 100644 --- a/scheduler.py +++ b/scheduler.py @@ -11,8 +11,9 @@ review page's "re-query" button - same function, so a corrected field import logging import threading import time -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone +import aerodatabox import config import flightaware import models @@ -34,6 +35,41 @@ def next_run_datetime(hhmm: str, now: datetime | None = None) -> datetime: return candidate +def _iso_from_unix(ts: int) -> str: + return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(timespec="seconds") + + +def _fetch_gate_times(flight_number: str, flight_date: date, updates: dict) -> None: + """Best-effort AeroDataBox lookup for scheduled/actual GATE times + (departure/arrival) - the data BCBP and the FlightAware track can't + provide. Failures here are recorded (schedule_error) but never block + the FlightAware track or push status to track_unavailable: worst case + the gate fields stay blank exactly like they do today, and the + existing fallback (AirTrail's own manual Search) still works.""" + try: + gate_times = aerodatabox.lookup_gate_times(flight_number, flight_date) + except aerodatabox.AeroDataBoxError as e: + updates["schedule_error"] = str(e) + return + + updates["schedule_error"] = None + if not gate_times: + return + + updates["gate_departure_scheduled"] = gate_times.get("departureScheduled") + updates["gate_departure_actual"] = gate_times.get("departure") + updates["gate_arrival_scheduled"] = gate_times.get("arrivalScheduled") + updates["gate_arrival_actual"] = gate_times.get("arrival") + # AeroDataBox's runwayTime is an authoritative actual takeoff/landing + # time - prefer it. Only set here if present; the FlightAware-track + # fallback below fills in behind it (checks "not in updates") rather + # than overwriting it unconditionally. + if gate_times.get("takeoffActual"): + updates["takeoff_actual"] = gate_times["takeoffActual"] + if gate_times.get("landingActual"): + updates["landing_actual"] = gate_times["landingActual"] + + def process_one_flight(row: dict) -> None: flight_id = row["id"] @@ -45,6 +81,19 @@ def process_one_flight(row: dict) -> None: updates = {"from_icao": from_icao, "to_icao": to_icao, "operating_carrier_icao": carrier_icao} + try: + flight_date = date.fromisoformat(row["flight_date"]) + except (TypeError, ValueError): + flight_date = None + + # Gate times only need a flight number + date (no ICAO resolution), so + # attempt this regardless of whether the airport/carrier lookups below + # succeed - an unresolved airport shouldn't cost us data we could + # otherwise have gotten. + iata_flight_number = f"{row.get('operating_carrier_iata') or ''}{row.get('flight_number') or ''}" + if iata_flight_number.strip() and flight_date: + _fetch_gate_times(iata_flight_number, flight_date, updates) + if not from_icao or not to_icao or not carrier_icao: missing = [ name @@ -57,9 +106,7 @@ def process_one_flight(row: dict) -> None: notifier.notify_flight_staged(models.get_flight(flight_id)) return - try: - flight_date = date.fromisoformat(row["flight_date"]) - except (TypeError, ValueError): + if flight_date is None: updates["status"] = "track_unavailable" updates["error_message"] = f"invalid flight_date: {row.get('flight_date')!r}" models.update_flight(flight_id, **updates) @@ -98,6 +145,16 @@ def process_one_flight(row: dict) -> None: updates["track_times"] = times updates["track_point_count"] = len(coordinates) updates["error_message"] = None + # The ADS-B track only exists while the aircraft is squawking - + # roughly wheels-up to touchdown - so its first/last timestamps are + # a decent proxy for actual takeoff/landing when AeroDataBox didn't + # already give us its authoritative runwayTime above (no key + # configured, lookup failed, or that particular flight had no + # runwayTime in its response). Never overwrite a value AeroDataBox + # already set - that one's more precise. + if times: + updates.setdefault("takeoff_actual", _iso_from_unix(times[0])) + updates.setdefault("landing_actual", _iso_from_unix(times[-1])) models.update_flight(flight_id, **updates) notifier.notify_flight_staged(models.get_flight(flight_id)) diff --git a/templates/review_detail.html b/templates/review_detail.html index f6ac0cc..b21e042 100644 --- a/templates/review_detail.html +++ b/templates/review_detail.html @@ -61,6 +61,21 @@ {% if flight.track_point_count %}
Track points
{{ flight.track_point_count }}
{% endif %} + {% if flight.gate_departure_scheduled or flight.gate_departure_actual %} +
Gate departure
sched {{ flight.gate_departure_scheduled or '—' }} / actual {{ flight.gate_departure_actual or '—' }}
+ {% endif %} + {% if flight.gate_arrival_scheduled or flight.gate_arrival_actual %} +
Gate arrival
sched {{ flight.gate_arrival_scheduled or '—' }} / actual {{ flight.gate_arrival_actual or '—' }}
+ {% endif %} + {% if flight.takeoff_actual %} +
Takeoff (actual)
{{ flight.takeoff_actual }}
+ {% endif %} + {% if flight.landing_actual %} +
Landing (actual)
{{ flight.landing_actual }}
+ {% endif %} + {% if flight.schedule_error %} +
Schedule lookup
{{ flight.schedule_error }}
+ {% endif %} {% if flight.airtrail_flight_id %}
AirTrail flight
{{ flight.airtrail_flight_id }}
{% endif %}