|
| 1 | +import Foundation |
| 2 | +import ArgumentParser |
| 3 | +import SwiftMETAR |
| 4 | +import METARFormatting |
| 5 | + |
| 6 | +@available(macOS 15.0, *) |
| 7 | +@main |
| 8 | +struct DecodeMETAR: AsyncParsableCommand { |
| 9 | + static let configuration = CommandConfiguration( |
| 10 | + abstract: "Decodes a METAR into human-readable text.", |
| 11 | + discussion: """ |
| 12 | + This tool can be used with an airport code (in ICAO format [i.e., include the "K"]), |
| 13 | + in which case it will download the latest METARs from AWC; or it can be used with |
| 14 | + a raw METAR string, in which case that string will be parsed. |
| 15 | + """) |
| 16 | + |
| 17 | + @Argument(help: "The ICAO code of an airport, or a METAR string to decode") |
| 18 | + var airportCodeOrMETAR: String? = nil |
| 19 | + |
| 20 | + @Option(name: [.customLong("metar-url"), .short], help: "The URL to load the METAR CSV from.", transform: { URL(string: $0)! }) |
| 21 | + var METAR_URL = URL(string: "https://aviationweather.gov/data/cache/metars.cache.csv")! |
| 22 | + |
| 23 | + @Flag(name: .long, inversion: .prefixedNo, help: "Include raw METAR text") |
| 24 | + var raw = false |
| 25 | + |
| 26 | + @Flag(name: .shortAndLong, inversion: .prefixedNo, help: "Include remarks") |
| 27 | + var remarks = true |
| 28 | + |
| 29 | + @Flag(name: .long, help: "Parse and decode all METARs (!)") |
| 30 | + var all = false |
| 31 | + |
| 32 | + @Flag(name: .long, help: "Only show stations with parsing errors (intended to be used with `--all`") |
| 33 | + var errorsOnly = false |
| 34 | + |
| 35 | + private var session: URLSession { .init(configuration: .ephemeral) } |
| 36 | + |
| 37 | + func run() async throws { |
| 38 | + try await all ? parseAll() : parsePrompt() |
| 39 | + } |
| 40 | + |
| 41 | + private func parseAll() async throws { |
| 42 | + let METARs = try await loadMETARs { raw, error in |
| 43 | + print(raw) |
| 44 | + print(" Parse error: \(error.localizedDescription)") |
| 45 | + print() |
| 46 | + } |
| 47 | + if !errorsOnly { |
| 48 | + for metar in METARs.values { |
| 49 | + printMETAR(metar) |
| 50 | + } |
| 51 | + } |
| 52 | + } |
| 53 | + |
| 54 | + private func parsePrompt() async throws { |
| 55 | + let airportCodeOrMETAR = promptMETAR() |
| 56 | + let metar = try await airportCodeOrMETAR.count == 4 ? parse(code: airportCodeOrMETAR) : parse(raw: airportCodeOrMETAR) |
| 57 | + printMETAR(metar) |
| 58 | + } |
| 59 | + |
| 60 | + private func promptMETAR() -> String { |
| 61 | + var airportCodeOrMETAR = self.airportCodeOrMETAR |
| 62 | + |
| 63 | + while airportCodeOrMETAR == nil || airportCodeOrMETAR!.isEmpty { |
| 64 | + print("Enter airport code or METAR: ", terminator: "") |
| 65 | + airportCodeOrMETAR = readLine(strippingNewline: true) |
| 66 | + } |
| 67 | + |
| 68 | + return airportCodeOrMETAR! |
| 69 | + } |
| 70 | + |
| 71 | + private func parse(code: String) async throws -> METAR { |
| 72 | + guard let metar = try await getMETAR(airportCode: code) else { |
| 73 | + throw Errors.unknownAirportID(code) |
| 74 | + } |
| 75 | + return metar |
| 76 | + } |
| 77 | + |
| 78 | + private func parse(raw: String) async throws -> METAR { |
| 79 | + try await METAR.from(string: raw) |
| 80 | + } |
| 81 | + |
| 82 | + private func loadMETARs(errorHandler: ((String, Swift.Error) throws -> Void)) async throws -> Dictionary<String, METAR> { |
| 83 | + print("Loading METARs…") |
| 84 | + print() |
| 85 | + |
| 86 | + let (data, response) = try await session.bytes(from: METAR_URL) |
| 87 | + guard let response = response as? HTTPURLResponse else { |
| 88 | + throw Errors.badResponse(response) |
| 89 | + } |
| 90 | + guard response.statusCode/100 == 2 else { |
| 91 | + throw Errors.badStatus(response: response) |
| 92 | + } |
| 93 | + |
| 94 | + var METARs = Dictionary<String, METAR>() |
| 95 | + for try await line in data.lines { |
| 96 | + guard let range = line.rangeOfCharacter(from: CharacterSet(charactersIn: ",")) else { continue } |
| 97 | + let string = String(line[line.startIndex..<range.lowerBound]) |
| 98 | + guard string.starts(with: "K") else { continue } |
| 99 | + do { |
| 100 | + let metar = try await METAR.from(string: string) |
| 101 | + METARs[metar.stationID] = metar |
| 102 | + } catch { |
| 103 | + try errorHandler(string, error) |
| 104 | + } |
| 105 | + } |
| 106 | + |
| 107 | + return METARs |
| 108 | + } |
| 109 | + |
| 110 | + private func getMETAR(airportCode: String) async throws -> METAR? { |
| 111 | + let METARs = try await loadMETARs { raw, error in |
| 112 | + if raw.starts(with: airportCode) { |
| 113 | + throw Errors.badMETAR(raw: raw) |
| 114 | + } |
| 115 | + } |
| 116 | + |
| 117 | + return METARs[airportCode] |
| 118 | + } |
| 119 | + |
| 120 | + private func printMETAR(_ metar: METAR) { |
| 121 | + print("Airport: \(metar.stationID)") |
| 122 | + if raw, let text = metar.text { |
| 123 | + print(text) |
| 124 | + } |
| 125 | + |
| 126 | + lprint("Issued: \(metar.date, format: .dateTime) (\(metar.issuance, format: .issuance))") |
| 127 | + lprint("Observer: \(metar.observer, format: .observer)") |
| 128 | + if let wind = metar.wind { |
| 129 | + lprint("Wind: \(wind, format: .wind)") |
| 130 | + } |
| 131 | + if let visibility = metar.visibility { |
| 132 | + lprint("Visibility: \(visibility, format: .visibility)") |
| 133 | + } |
| 134 | + for visibility in metar.runwayVisibility { |
| 135 | + lprint("\(visibility.runwayID) Visibility: \(visibility.visibility, format: .visibility)") |
| 136 | + } |
| 137 | + if let weathers = metar.weather, !weathers.isEmpty { |
| 138 | + lprint("Weather: \(weathers, format: .list(memberStyle: .weather, type: .and))") |
| 139 | + } |
| 140 | + if !metar.conditions.isEmpty { |
| 141 | + lprint("Conditions: \(metar.conditions, format: .list(memberStyle: .condition, type: .and))") |
| 142 | + } |
| 143 | + if let temperature = metar.temperatureMeasurement { |
| 144 | + lprint("Temperature: \(temperature, format: .measurement(width: .abbreviated, usage: .asProvided))") |
| 145 | + } |
| 146 | + if let dewpoint = metar.dewpointMeasurement { |
| 147 | + lprint("Dewpoint: \(dewpoint, format: .measurement(width: .abbreviated, usage: .asProvided))") |
| 148 | + } |
| 149 | + if let altimeter = metar.altimeter?.measurement { |
| 150 | + lprint("Altimeter: \(altimeter, format: .measurement(width: .abbreviated, usage: .asProvided))") |
| 151 | + } |
| 152 | + |
| 153 | + if remarks { |
| 154 | + print() |
| 155 | + printRemarks(metar: metar) |
| 156 | + } |
| 157 | + |
| 158 | + print() |
| 159 | + } |
| 160 | + |
| 161 | + private func printRemarks(metar: METAR) { |
| 162 | + for remark in metar.remarks.sorted(using: RemarkComparator()) { |
| 163 | + print(RemarkEntry.FormatStyle.remark().format(remark)) |
| 164 | + } |
| 165 | + } |
| 166 | + |
| 167 | + private func lprint(_ str: LocalizedStringResource) { |
| 168 | + print(String(localized: str)) |
| 169 | + } |
| 170 | +} |
| 171 | + |
| 172 | +enum Errors: Swift.Error { |
| 173 | + case badResponse(_ response: URLResponse) |
| 174 | + case badStatus(response: URLResponse) |
| 175 | + case unknownAirportID(_ ID: String) |
| 176 | + case badMETAR(raw: String) |
| 177 | +} |
| 178 | + |
| 179 | +extension Errors: LocalizedError { |
| 180 | + public var errorDescription: String? { |
| 181 | + switch self { |
| 182 | + case let .badResponse(response): "Bad response from AWC API: \(response)" |
| 183 | + case let .badStatus(response): "Bad status from AWC API: \(response)" |
| 184 | + case let .unknownAirportID(ID): "Airport ID “\(ID)” was not found" |
| 185 | + case let .badMETAR(raw): "Couldn’t parse METAR: \(raw)" |
| 186 | + } |
| 187 | + } |
| 188 | + |
| 189 | + var failureReason: String? { |
| 190 | + switch self { |
| 191 | + case .badResponse, .badStatus: "The AWC API may have changed, or may not be functioning properly." |
| 192 | + case .unknownAirportID: "The airport is not in the AWC METAR database." |
| 193 | + case .badMETAR: "Either the METAR is incorrectly formatted, or SwiftMETAR should be fixed." |
| 194 | + } |
| 195 | + } |
| 196 | +} |
0 commit comments