From 92bc6ed52ef5d4c64b52f184a2149bba561fd8f0 Mon Sep 17 00:00:00 2001 From: Trey T Date: Wed, 27 May 2026 07:52:56 -0500 Subject: [PATCH] Live feed: FR24 primary, OpenSky fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Flights.xcodeproj/project.pbxproj | 4 + Flights/FlightsApp.swift | 4 +- Flights/Models/LiveAircraft.swift | 36 +++- Flights/Services/FR24Client.swift | 201 ++++++++++++++++++++++ Flights/Services/OpenSkyClient.swift | 3 +- Flights/Views/LiveFlightDetailSheet.swift | 82 ++++++++- Flights/Views/LiveFlightsView.swift | 43 +++-- Flights/Views/RootView.swift | 2 + 8 files changed, 352 insertions(+), 23 deletions(-) create mode 100644 Flights/Services/FR24Client.swift diff --git a/Flights.xcodeproj/project.pbxproj b/Flights.xcodeproj/project.pbxproj index 0eedea2..377d64f 100644 --- a/Flights.xcodeproj/project.pbxproj +++ b/Flights.xcodeproj/project.pbxproj @@ -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 = ""; }; LVCC000CCCC000CCCC000002 /* aircraftDB.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = aircraftDB.json; sourceTree = ""; }; LVDD000DDDD000DDDD000002 /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = ""; }; + LVEE000EEEE000EEEE000002 /* FR24Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FR24Client.swift; sourceTree = ""; }; /* 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 = ""; @@ -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; }; diff --git a/Flights/FlightsApp.swift b/Flights/FlightsApp.swift index dcc848c..db92812 100644 --- a/Flights/FlightsApp.swift +++ b/Flights/FlightsApp.swift @@ -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 ) } } diff --git a/Flights/Models/LiveAircraft.swift b/Flights/Models/LiveAircraft.swift index c3539c8..5d3edb0 100644 --- a/Flights/Models/LiveAircraft.swift +++ b/Flights/Models/LiveAircraft.swift @@ -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 } diff --git a/Flights/Services/FR24Client.swift b/Flights/Services/FR24Client.swift new file mode 100644 index 0000000..fbf024f --- /dev/null +++ b/Flights/Services/FR24Client.swift @@ -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 + /// ~3–30 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 + } +} diff --git a/Flights/Services/OpenSkyClient.swift b/Flights/Services/OpenSkyClient.swift index 69f9758..c5c6a0a 100644 --- a/Flights/Services/OpenSkyClient.swift +++ b/Flights/Services/OpenSkyClient.swift @@ -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 ) } } diff --git a/Flights/Views/LiveFlightDetailSheet.swift b/Flights/Views/LiveFlightDetailSheet.swift index ed89759..a46c850 100644 --- a/Flights/Views/LiveFlightDetailSheet.swift +++ b/Flights/Views/LiveFlightDetailSheet.swift @@ -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 = { diff --git a/Flights/Views/LiveFlightsView.swift b/Flights/Views/LiveFlightsView.swift index a227d34..7bb938d 100644 --- a/Flights/Views/LiveFlightsView.swift +++ b/Flights/Views/LiveFlightsView.swift @@ -4,6 +4,7 @@ import CoreLocation struct LiveFlightsView: View { let openSky: OpenSkyClient + let fr24: FR24Client let routeExplorer: RouteExplorerClient let database: AirportDatabase @@ -622,31 +623,49 @@ 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. - var tx = Transaction() - tx.disablesAnimations = true - withTransaction(tx) { - aircraft = results - } - lastFetchAt = Date() - lastFetchedBoundingBox = bb - error = nil + commitResults(results, bb: bb) } 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) } } } + /// 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) { + aircraft = results + } + lastFetchAt = Date() + lastFetchedBoundingBox = bb + error = nil + } + private func boundingBox(of r: MKCoordinateRegion) -> (latMin: Double, lonMin: Double, latMax: Double, lonMax: Double) { let lat = r.center.latitude let lon = r.center.longitude diff --git a/Flights/Views/RootView.swift b/Flights/Views/RootView.swift index f244075..4591d7f 100644 --- a/Flights/Views/RootView.swift +++ b/Flights/Views/RootView.swift @@ -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 )