| struct BusArrivalsService {
/// Free key from https://register.developer.bus.mta.info
static var apiKey: String = "a0ef7464-ed93-468f-9f98-e26336a1d378"
private static let baseURL = "https://bustime.mta.info/api/siri/stop-monitoring.json"
/// Returns empty array when no key is configured or on any network error.
static func fetch(for stop: BusStop) async -> [BusArrivalInfo] {
guard !apiKey.isEmpty else { return [] }
var comps = URLComponents(string: baseURL)!
comps.queryItems = [
URLQueryItem(name: "key", value: apiKey),
URLQueryItem(name: "OperatorRef", value: "MTA"),
URLQueryItem(name: "MonitoringRef", value: stop.id),
URLQueryItem(name: "MaximumStopVisits", value: "9"),
]
guard let url = comps.url else { return [] }
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 8
cfg.timeoutIntervalForResource = 10
let session = URLSession(configuration: cfg)
defer { session.invalidateAndCancel() }
guard let (data, _) = try? await session.data(for: URLRequest(url: url, timeoutInterval: 8)),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return [] }
// Walk the SIRI response tree
guard let siri = json["Siri"] as? [String: Any],
let delivery = siri["ServiceDelivery"] as? [String: Any],
let smdArray = delivery["StopMonitoringDelivery"] as? [[String: Any]],
let smd = smdArray.first,
let visits = smd["MonitoredStopVisit"] as? [[String: Any]]
else { return [] }
let now = Date().timeIntervalSince1970
// BusTime timestamps include fractional seconds, e.g. "2026-03-31T23:22:32.177-04:00"
// ISO8601DateFormatter must opt-in to fractional seconds or the parse returns nil.
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
var raw: [(route: String, headsign: String, secs: Double)] = []
for visit in visits {
guard let mvj = visit["MonitoredVehicleJourney"] as? [String: Any],
let route = (mvj["PublishedLineName"] as? String)
?? (mvj["LineRef"] as? String)
else { continue }
let headsign = (mvj["DestinationName"] as? String) ?? ""
// Prefer ExpectedArrivalTime; fall back to AimedArrivalTime
let call = mvj["MonitoredCall"] as? [String: Any]
let timeStr = (call?["ExpectedArrivalTime"] as? String)
?? (call?["AimedArrivalTime"] as? String)
?? ""
guard let date = isoFormatter.date(from: timeStr) else { continue }
let secs = date.timeIntervalSince1970 - now
guard secs >= -60 else { continue } // ignore buses that left > 1 min ago
// Clean up route label: strip "MTA NYCT_" or "MTA_" prefix
let cleanRoute: String
if let under = route.firstIndex(of: "_") {
cleanRoute = String(route[route.index(after: under)...])
} else {
cleanRoute = route
}
raw.append((route: cleanRoute, headsign: headsign, secs: secs))
}
// Group by route+headsign, keep next 3 arrival times
raw.sort { $0.secs < $1.secs }
var first = [String: Double]()
var second = [String: Double]()
var third = [String: Double]()
var order = [String]()
for item in raw {
let key = "\(item.route)|\(item.headsign)"
if first[key] == nil {
first[key] = item.secs
order.append(key)
} else if second[key] == nil {
second[key] = item.secs
} else if third[key] == nil {
third[key] = item.secs
}
}
return order.compactMap { key -> BusArrivalInfo? in
guard let secs = first[key] else { return nil }
let parts = key.split(separator: "|", maxSplits: 1)
let route = parts.count > 0 ? String(parts[0]) : key
let headsign = parts.count > 1 ? String(parts[1]) : ""
return BusArrivalInfo(route: route, headsign: headsign,
seconds: secs,
secondSeconds: second[key],
thirdSeconds: third[key])
}
}
} | `BusArrivalsService` struct | Defines the `BusArrivalsService` struct. |