Live feed: FR24 primary, OpenSky fallback

OpenSky's free anonymous tier has sparse ground coverage — at DAL it
returned a single airborne aircraft when there were 3+ SWA jets
visibly parked on the apron. FR24's feed.js aggregates ASDE-X, MLAT,
and multiple community ADS-B feeds and reliably surfaces ground
aircraft at major airports. We now query FR24 first and fall back to
OpenSky only when FR24 errors.

FR24's payload also carries departure + arrival IATA + flight number
+ aircraft type + tail number inline, so we shortcut the
route-explorer schedule lookup in the detail sheet: a new
`LiveAircraft.Enrichment` struct holds those fields, and the
ResolvedRoute cascade gains a `.fromFR24` first-tier case that uses
them directly. The `typeCode` and `airlineICAO` computed properties
prefer enrichment values over the AircraftDatabase / callsign-prefix
heuristics — this also fixes the case where FR24 callsigns use the
IATA carrier ("AA0013") which our 3-letter-prefix derivation would
have rejected.

OpenSky still owns trail polylines and recent-flights history; only
the live position fetch swapped sources.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-27 07:52:56 -05:00
parent 68c60ec087
commit 92bc6ed52e
8 changed files with 352 additions and 23 deletions
+4
View File
@@ -59,6 +59,7 @@
LVBB000BBBB000BBBB000001 /* LiveFilterPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVBB000BBBB000BBBB000002 /* LiveFilterPicker.swift */; };
LVCC000CCCC000CCCC000001 /* aircraftDB.json in Resources */ = {isa = PBXBuildFile; fileRef = LVCC000CCCC000CCCC000002 /* aircraftDB.json */; };
LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVDD000DDDD000DDDD000002 /* LocationService.swift */; };
LVEE000EEEE000EEEE000001 /* FR24Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = LVEE000EEEE000EEEE000002 /* FR24Client.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -126,6 +127,7 @@
LVBB000BBBB000BBBB000002 /* LiveFilterPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveFilterPicker.swift; sourceTree = "<group>"; };
LVCC000CCCC000CCCC000002 /* aircraftDB.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = aircraftDB.json; sourceTree = "<group>"; };
LVDD000DDDD000DDDD000002 /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = "<group>"; };
LVEE000EEEE000EEEE000002 /* FR24Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FR24Client.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -246,6 +248,7 @@
LV7700007777000077770002 /* OpenSkyCredentials.swift */,
LVAA000AAAA000AAAA000002 /* AircraftDatabase.swift */,
LVDD000DDDD000DDDD000002 /* LocationService.swift */,
LVEE000EEEE000EEEE000002 /* FR24Client.swift */,
);
path = Services;
sourceTree = "<group>";
@@ -418,6 +421,7 @@
LVAA000AAAA000AAAA000001 /* AircraftDatabase.swift in Sources */,
LVBB000BBBB000BBBB000001 /* LiveFilterPicker.swift in Sources */,
LVDD000DDDD000DDDD000001 /* LocationService.swift in Sources */,
LVEE000EEEE000EEEE000001 /* FR24Client.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
+3 -1
View File
@@ -6,6 +6,7 @@ struct FlightsApp: App {
let loadService: AirlineLoadService
let routeExplorer = RouteExplorerClient()
let openSky = OpenSkyClient()
let fr24 = FR24Client()
init() {
let db = AirportDatabase()
@@ -25,7 +26,8 @@ struct FlightsApp: App {
database: database,
loadService: loadService,
routeExplorer: routeExplorer,
openSky: openSky
openSky: openSky,
fr24: fr24
)
}
}
+28 -8
View File
@@ -43,6 +43,23 @@ struct LiveAircraft: Identifiable, Hashable, Sendable {
/// When the position was last updated (server-side).
let lastContact: Date
/// Extra fields the FR24 feed provides inline (departure/arrival IATA,
/// flight number, aircraft model, tail number, airline ICAO). Always
/// nil for aircraft sourced from OpenSky.
let enrichment: Enrichment?
/// FR24-only inline data. None of these are guaranteed even when the
/// outer envelope is FR24-sourced gate aircraft often have no
/// flight number, GA aircraft no airline, etc.
struct Enrichment: Hashable, Sendable {
let modelType: String? // ICAO type designator, e.g. "B738"
let registration: String? // Tail number, e.g. "N971NN"
let flightIATA: String? // "AA2152"
let departureIATA: String? // "DFW"
let arrivalIATA: String? // "MSP"
let airlineICAO: String? // "AAL"
}
var coordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
}
@@ -69,12 +86,12 @@ struct LiveAircraft: Identifiable, Hashable, Sendable {
return .level
}
/// ICAO aircraft type designator (e.g. "B738", "A21N") looked up from
/// the bundled aircraft DB. Nil if the airframe isn't in our slimmed
/// commercial-class DB typically true for GA / experimental / cargo
/// freight without a public registration.
/// ICAO aircraft type designator (e.g. "B738", "A21N"). Prefers the
/// FR24-supplied model when present (more accurate, includes
/// recent retrofits), else falls back to the bundled DB lookup.
var typeCode: String? {
AircraftDatabase.shared.typeCode(forICAO24: icao24)
if let m = enrichment?.modelType, !m.isEmpty { return m }
return AircraftDatabase.shared.typeCode(forICAO24: icao24)
}
var trimmedCallsign: String? {
@@ -82,10 +99,13 @@ struct LiveAircraft: Identifiable, Hashable, Sendable {
return s.isEmpty ? nil : s
}
/// 3-letter ICAO airline prefix from the callsign (e.g. "DAL" from "DAL1234").
/// Returns nil when the callsign doesn't follow the standard pattern (e.g.
/// GA tail numbers like "N12345").
/// 3-letter ICAO airline prefix. Prefers the FR24-supplied value when
/// available it correctly identifies SWA at "AA0013" style callsigns
/// where the prefix derivation would fail. Falls back to extracting
/// the leading letters from the callsign (works for OpenSky-style
/// "AAL2152").
var airlineICAO: String? {
if let a = enrichment?.airlineICAO, !a.isEmpty { return a }
guard let cs = trimmedCallsign else { return nil }
let letters = cs.prefix(while: { $0.isLetter })
guard letters.count == 3 else { return nil }
+201
View File
@@ -0,0 +1,201 @@
import Foundation
/// Live aircraft feed sourced from flightradar24.com's public
/// `/zones/fcgi/feed.js` endpoint. We use it as the primary live data
/// source because, unlike OpenSky's anonymous tier, FR24 aggregates
/// ASDE-X / MLAT / multiple ADS-B receivers and has solid ground
/// coverage at major airports i.e. the parked SWA jet at DAL that
/// OpenSky just doesn't return.
///
/// Feed format (positional array per aircraft):
/// [0] icao24 hex (uppercase)
/// [1] latitude
/// [2] longitude
/// [3] heading (deg true)
/// [4] altitude (feet, baro)
/// [5] ground speed (knots)
/// [6] squawk
/// [7] radar source id (e.g. "T-KDFW42") informational
/// [8] ICAO aircraft type designator ("B738")
/// [9] registration / tail number
/// [10] unix timestamp (seconds)
/// [11] departure airport IATA
/// [12] arrival airport IATA
/// [13] flight number with IATA carrier ("AA2152")
/// [14] on_ground (0/1)
/// [15] vertical rate (ft/min)
/// [16] callsign with ICAO carrier ("AAL2152")
/// [17] is_glider/special
/// [18] airline ICAO ("AAL")
///
/// The endpoint requires browser-shaped request headers (User-Agent +
/// Referer pointing at flightradar24.com). Plain curl is rejected.
actor FR24Client {
enum ClientError: LocalizedError {
case http(Int)
case decode(String)
case network(Error)
var errorDescription: String? {
switch self {
case .http(let c): return "FR24 returned HTTP \(c)."
case .decode(let s): return "Couldn't read FR24 response: \(s)."
case .network(let e): return e.localizedDescription
}
}
}
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
/// Fetch every aircraft currently inside the bbox. The map view passes
/// the visible region's corners typical bbox at city zoom returns
/// ~330 entries; continental zoom can return several hundred.
func states(latMin: Double, lonMin: Double, latMax: Double, lonMax: Double) async throws -> [LiveAircraft] {
// FR24 expects bounds in the order: latNorth, latSouth, lonWest, lonEast.
let bounds = String(format: "%.4f,%.4f,%.4f,%.4f", latMax, latMin, lonMin, lonMax)
var comps = URLComponents(string: "https://data-cloud.flightradar24.com/zones/fcgi/feed.js")!
comps.queryItems = [
URLQueryItem(name: "bounds", value: bounds),
URLQueryItem(name: "faa", value: "1"),
URLQueryItem(name: "satellite", value: "1"),
URLQueryItem(name: "mlat", value: "1"),
URLQueryItem(name: "flarm", value: "1"),
URLQueryItem(name: "adsb", value: "1"),
URLQueryItem(name: "gnd", value: "1"),
URLQueryItem(name: "air", value: "1"),
URLQueryItem(name: "vehicles", value: "1"),
URLQueryItem(name: "estimated", value: "1"),
URLQueryItem(name: "maxage", value: "14400"),
URLQueryItem(name: "gliders", value: "1"),
URLQueryItem(name: "stats", value: "1"),
]
var req = URLRequest(url: comps.url!)
req.timeoutInterval = 12
req.setValue(
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
forHTTPHeaderField: "User-Agent"
)
req.setValue("https://www.flightradar24.com/", forHTTPHeaderField: "Referer")
req.setValue("application/json", forHTTPHeaderField: "Accept")
let data: Data
let response: URLResponse
do {
(data, response) = try await session.data(for: req)
} catch {
throw ClientError.network(error)
}
guard let http = response as? HTTPURLResponse else {
throw ClientError.network(URLError(.badServerResponse))
}
guard (200..<300).contains(http.statusCode) else {
throw ClientError.http(http.statusCode)
}
return try parse(data: data)
}
private func parse(data: Data) throws -> [LiveAircraft] {
let root: Any
do {
root = try JSONSerialization.jsonObject(with: data, options: [])
} catch {
throw ClientError.decode("not json")
}
guard let dict = root as? [String: Any] else {
throw ClientError.decode("root not object")
}
var out: [LiveAircraft] = []
out.reserveCapacity(dict.count)
for (_, value) in dict {
guard let arr = value as? [Any] else { continue }
if arr.count < 18 { continue }
if let ac = Self.aircraft(from: arr) {
out.append(ac)
}
}
return out
}
/// Convert one positional entry into a LiveAircraft, returning nil
/// when required fields are missing (no position, no icao24).
private static func aircraft(from a: [Any]) -> LiveAircraft? {
guard let icaoRaw = a[0] as? String, !icaoRaw.isEmpty,
let lat = doubleVal(a[1]),
let lon = doubleVal(a[2]) else {
return nil
}
let heading = doubleVal(a[3])
let altFeet = intVal(a[4]) ?? 0
let speedKnots = doubleVal(a[5])
let squawkRaw = a[6] as? String
let modelType = nonEmpty(a[8] as? String)
let registration = nonEmpty(a[9] as? String)
let timestamp = intVal(a[10]) ?? Int(Date().timeIntervalSince1970)
let depIATA = nonEmpty(a[11] as? String)
let arrIATA = nonEmpty(a[12] as? String)
let flightIATA = nonEmpty(a[13] as? String)
let onGround = (intVal(a[14]) ?? 0) != 0
let vertRateFpm = doubleVal(a[15]) ?? 0
let callsign = nonEmpty(a[16] as? String)
let airlineICAO = nonEmpty(a.count > 18 ? a[18] as? String : nil)
// Unit conversions LiveAircraft stores baroAlt in meters,
// velocity in m/s, vertical rate in m/s (matching OpenSky).
let baroAltMeters: Double? = altFeet > 0 ? Double(altFeet) * 0.3048 : nil
let velocityMps: Double? = speedKnots.map { $0 * 0.514444 }
let vertRateMps: Double? = vertRateFpm != 0 ? vertRateFpm * 0.00508 : nil
return LiveAircraft(
icao24: icaoRaw.lowercased(),
callsign: callsign,
originCountry: "",
latitude: lat,
longitude: lon,
baroAltitude: baroAltMeters,
geoAltitude: nil,
velocity: velocityMps,
trueTrack: heading,
verticalRate: vertRateMps,
onGround: onGround,
squawk: nonEmpty(squawkRaw),
category: nil,
lastContact: Date(timeIntervalSince1970: TimeInterval(timestamp)),
enrichment: LiveAircraft.Enrichment(
modelType: modelType,
registration: registration,
flightIATA: flightIATA,
departureIATA: depIATA,
arrivalIATA: arrIATA,
airlineICAO: airlineICAO
)
)
}
private static func doubleVal(_ x: Any) -> Double? {
if let d = x as? Double { return d }
if let i = x as? Int { return Double(i) }
if let s = x as? String { return Double(s) }
return nil
}
private static func intVal(_ x: Any) -> Int? {
if let i = x as? Int { return i }
if let d = x as? Double { return Int(d) }
if let s = x as? String { return Int(s) }
return nil
}
private static func nonEmpty(_ s: String?) -> String? {
guard let s, !s.isEmpty else { return nil }
return s
}
}
+2 -1
View File
@@ -236,7 +236,8 @@ private extension LiveAircraft {
onGround: raw.onGround,
squawk: raw.squawk,
category: raw.category,
lastContact: Date(timeIntervalSince1970: TimeInterval(raw.lastContact))
lastContact: Date(timeIntervalSince1970: TimeInterval(raw.lastContact)),
enrichment: nil
)
}
}
+81 -1
View File
@@ -16,7 +16,13 @@ struct LiveFlightDetailSheet: View {
@State private var resolvedRoute: ResolvedRoute?
enum ResolvedRoute {
/// Real schedule match from route-explorer. Best fidelity.
/// Inline data straight off the FR24 feed (departure + arrival
/// IATA, plus the flight number). This is the fast path no
/// extra network call required because FR24 already gave us
/// these in the feed.js response.
case fromFR24(departureIATA: String?, arrivalIATA: String?, flightIATA: String?)
/// Real schedule match from route-explorer. Best fidelity for
/// flights that aren't FR24-sourced.
case scheduled(RouteFlight)
/// OpenSky historical flight. departure + arrival from FAA tracking.
case fromOpenSky(OpenSkyFlight, ageHours: Int)
@@ -98,6 +104,21 @@ struct LiveFlightDetailSheet: View {
isLoadingRoute = true
defer { isLoadingRoute = false }
// 0) Fast path: FR24's feed gave us departure + arrival inline.
// This is the common case now that FR24 is the primary feed.
// Skips the route-explorer network call entirely.
if let e = aircraft.enrichment,
e.departureIATA != nil || e.arrivalIATA != nil {
resolvedRoute = .fromFR24(
departureIATA: e.departureIATA,
arrivalIATA: e.arrivalIATA,
flightIATA: e.flightIATA
)
// Fire OpenSky history in the background for "recent flights".
recentFlights = await openSky.recentFlights(icao24: aircraft.icao24)
return
}
// 1) Try the scheduled lookup if we have a parseable airline + flight.
if let scheduled = await tryScheduledLookup() {
resolvedRoute = .scheduled(scheduled)
@@ -286,6 +307,10 @@ struct LiveFlightDetailSheet: View {
Circle()
.fill(FlightTheme.onTime)
.frame(width: 6, height: 6)
} else if case .fromFR24 = resolved {
Circle()
.fill(FlightTheme.onTime)
.frame(width: 6, height: 6)
}
}
resolvedRouteCard(resolved)
@@ -315,6 +340,8 @@ struct LiveFlightDetailSheet: View {
private func headerLabel(for r: ResolvedRoute) -> String {
switch r {
case .fromFR24:
return aircraft.onGround ? "FLIGHT (ON GROUND)" : "IN FLIGHT"
case .scheduled:
return aircraft.onGround ? "FLIGHT (ON GROUND)" : "IN FLIGHT"
case .fromOpenSky(_, let hoursAgo):
@@ -329,6 +356,27 @@ struct LiveFlightDetailSheet: View {
@ViewBuilder
private func resolvedRouteCard(_ r: ResolvedRoute) -> some View {
switch r {
case .fromFR24(let dep, let arr, _):
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 16) {
fr24Endpoint(
iata: dep,
label: aircraft.onGround ? "From" : "Departed",
emptyText: "Unknown"
)
Image(systemName: "airplane")
.font(.title3)
.foregroundStyle(FlightTheme.accent)
.rotationEffect(.degrees(-45))
fr24Endpoint(
iata: arr,
label: aircraft.onGround ? "To" : "Heading to",
emptyText: "Unknown"
)
}
}
.flightCard()
case .scheduled(let f):
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 16) {
@@ -414,6 +462,38 @@ struct LiveFlightDetailSheet: View {
}
}
/// Endpoint cell for FR24-sourced routes. Different from
/// `routeEndpoint` because FR24 gives us IATA codes directly (no
/// ICAO-to-IATA conversion) and no scheduled time.
private func fr24Endpoint(iata: String?, label: String, emptyText: String) -> some View {
let code = iata ?? ""
let cityName: String = {
guard let iata, let m = database.airport(byIATA: iata) else { return "" }
return m.name
}()
return VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption2)
.foregroundStyle(FlightTheme.textTertiary)
.tracking(0.5)
Text(code.isEmpty ? "" : code)
.font(FlightTheme.airportCode(28))
.foregroundStyle(code.isEmpty ? FlightTheme.textTertiary : FlightTheme.textPrimary)
if !cityName.isEmpty {
Text(cityName)
.font(.caption2)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
} else if code.isEmpty {
Text(emptyText)
.font(.caption2)
.foregroundStyle(FlightTheme.textSecondary)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func routeEndpoint(code: String?, label: String, time: Date) -> some View {
let iata = code.flatMap(icaoToIATA(_:)) ?? code ?? ""
let cityName: String = {
+30 -11
View File
@@ -4,6 +4,7 @@ import CoreLocation
struct LiveFlightsView: View {
let openSky: OpenSkyClient
let fr24: FR24Client
let routeExplorer: RouteExplorerClient
let database: AirportDatabase
@@ -622,12 +623,39 @@ struct LiveFlightsView: View {
defer { isLoading = false }
let bb = boundingBox(of: r)
// Primary: FR24. Their feed includes ASDE-X + MLAT and reliably
// returns ground aircraft at major airports OpenSky's free tier
// does not, which was the root cause of "no SWA jets at DAL".
// We fall through to OpenSky only when FR24 hard-errors (rare).
if let results = try? await fr24.states(
latMin: bb.latMin, lonMin: bb.lonMin, latMax: bb.latMax, lonMax: bb.lonMax
) {
commitResults(results, bb: bb)
return
}
// Fallback: OpenSky. Same shape, missing the inline route data
// FR24 carries (departure/arrival/flight#), so the detail sheet
// route-resolver picks up the slack.
do {
let results = try await openSky.states(
latMin: bb.latMin, lonMin: bb.lonMin, latMax: bb.latMax, lonMax: bb.lonMax
)
// Suppress the implicit crossfade SwiftUI would apply to the
// map annotations when the underlying array swaps wholesale.
commitResults(results, bb: bb)
} catch {
self.error = (error as? OpenSkyClient.ClientError)?.errorDescription
?? error.localizedDescription
if case OpenSkyClient.ClientError.throttled = error {
try? await Task.sleep(nanoseconds: 60 * 1_000_000_000)
}
}
}
/// Commit a fresh aircraft list to state. Suppresses SwiftUI's
/// implicit crossfade when annotations swap so the map doesn't
/// flicker every 15 seconds.
private func commitResults(_ results: [LiveAircraft], bb: (latMin: Double, lonMin: Double, latMax: Double, lonMax: Double)) {
var tx = Transaction()
tx.disablesAnimations = true
withTransaction(tx) {
@@ -636,15 +664,6 @@ struct LiveFlightsView: View {
lastFetchAt = Date()
lastFetchedBoundingBox = bb
error = nil
} catch {
self.error = (error as? OpenSkyClient.ClientError)?.errorDescription
?? error.localizedDescription
if case OpenSkyClient.ClientError.throttled = error {
// On 429, slow the loop down to once per minute for the
// next sleep cycle.
try? await Task.sleep(nanoseconds: 60 * 1_000_000_000)
}
}
}
private func boundingBox(of r: MKCoordinateRegion) -> (latMin: Double, lonMin: Double, latMax: Double, lonMax: Double) {
+2
View File
@@ -9,6 +9,7 @@ struct RootView: View {
let loadService: AirlineLoadService
let routeExplorer: RouteExplorerClient
let openSky: OpenSkyClient
let fr24: FR24Client
@State private var selectedTab: Tab = .search
@@ -29,6 +30,7 @@ struct RootView: View {
NavigationStack {
LiveFlightsView(
openSky: openSky,
fr24: fr24,
routeExplorer: routeExplorer,
database: database
)