Packages/MoonriseKit/Sources/Feeds/DSN.swift
95 lines · 3754 bytes
1import Foundation
2#if canImport(FoundationXML)
3import FoundationXML
4#endif
5
6/// NASA's Deep Space Network Now feed: what each antenna is talking to.
7public struct DSNStatus: Sendable {
8 public struct Link: Sendable, Hashable {
9 /// Display name, e.g. "Voyager 1".
10 public var spacecraft: String
11 public var receiving: Bool
12 public var sending: Bool
13 /// Bits per second of the active downlink, if any.
14 public var downlinkRate: Double?
15 /// Round-trip light time in seconds, if reported.
16 public var roundTrip: Double?
17 }
18
19 public struct Dish: Sendable, Identifiable {
20 public var name: String
21 public var station: String
22 public var activity: String
23 public var links: [Link]
24 public var id: String { name }
25 }
26
27 public var updated: Date
28 public var dishes: [Dish]
29
30 public static let url = URL(string: "https://eyes.nasa.gov/dsn/data/dsn.xml")!
31
32 public static func fetch(session: URLSession = .shared) async throws -> DSNStatus {
33 try decode(await session.data(from: url).0)
34 }
35
36 public static func decode(_ data: Data) throws -> DSNStatus {
37 let p = Parser()
38 let xml = XMLParser(data: data)
39 xml.delegate = p
40 guard xml.parse() else { throw xml.parserError ?? CocoaError(.coderReadCorrupt) }
41 return DSNStatus(updated: p.updated, dishes: p.dishes)
42 }
43
44 public static func displayName(_ code: String) -> String {
45 dsnSpacecraftNames[code.lowercased()] ?? code
46 }
47
48 final class Parser: NSObject, XMLParserDelegate {
49 var updated = Date.distantPast
50 var dishes: [Dish] = []
51 var station = ""
52 var current: Dish?
53 var signals: [String: (down: Bool, up: Bool, rate: Double?)] = [:]
54 var rtlt: [String: Double] = [:]
55
56 func parser(_ parser: XMLParser, didStartElement name: String, namespaceURI: String?, qualifiedName: String?, attributes a: [String: String]) {
57 switch name {
58 case "station":
59 station = a["friendlyName"] ?? a["name"] ?? ""
60 if let ms = a["timeUTC"].flatMap(Double.init) { updated = Date(timeIntervalSince1970: ms / 1000) }
61 case "dish":
62 current = Dish(name: a["name"] ?? "", station: station, activity: a["activity"] ?? "", links: [])
63 signals = [:]
64 rtlt = [:]
65 case "downSignal", "upSignal":
66 guard let sc = a["spacecraft"], !sc.isEmpty else { return }
67 let active = a["active"] == "true"
68 var s = signals[sc] ?? (false, false, nil)
69 if name == "downSignal" {
70 s.down = s.down || active
71 if active, let r = a["dataRate"].flatMap(Double.init), r > 0 { s.rate = r }
72 } else {
73 s.up = s.up || active
74 }
75 signals[sc] = s
76 case "target":
77 if let n = a["name"], let t = a["rtlt"].flatMap(Double.init), t > 0 { rtlt[n] = t }
78 default:
79 break
80 }
81 }
82
83 func parser(_ parser: XMLParser, didEndElement name: String, namespaceURI: String?, qualifiedName: String?) {
84 guard name == "dish", var dish = current else { return }
85 dish.links = signals
86 .filter { $0.value.down || $0.value.up }
87 .map { code, s in
88 Link(spacecraft: DSNStatus.displayName(code), receiving: s.down, sending: s.up, downlinkRate: s.rate, roundTrip: rtlt[code])
89 }
90 .sorted { $0.spacecraft < $1.spacecraft }
91 dishes.append(dish)
92 current = nil
93 }
94 }
95}