Skip to main content

aerocontext_planning/flightplan/
garmin.rs

1//! Garmin FlightPlan v1 wire codec: decode UTF-16/UTF-8 `.fpl` bytes into
2//! a [`FlightPlan`], and serialize one back out.
3
4use std::collections::HashMap;
5
6use aerocontext_core::GeoPoint;
7use chrono::{DateTime, NaiveDateTime, Utc};
8use quick_xml::Reader;
9use quick_xml::events::Event;
10
11use super::{FlightPlan, PlanWaypoint, WaypointType};
12
13/// Failure parsing a Garmin `.fpl` document.
14#[derive(Debug, thiserror::Error)]
15#[non_exhaustive]
16pub enum FplError {
17    /// The bytes were not valid XML.
18    #[error("flight plan is not valid XML")]
19    Xml(#[from] quick_xml::Error),
20    /// A text node could not be decoded.
21    #[error("flight plan text could not be decoded")]
22    Encoding(#[from] quick_xml::encoding::EncodingError),
23    /// A waypoint-table entry was missing a required field.
24    #[error("waypoint {identifier:?} is missing its {field}")]
25    IncompleteWaypoint {
26        /// The waypoint identifier, when known.
27        identifier: String,
28        /// The missing field name.
29        field: &'static str,
30    },
31    /// A `<lat>`/`<lon>` value did not parse as a coordinate.
32    #[error("waypoint {identifier:?} has an unparseable {field} {value:?}")]
33    BadCoordinate {
34        /// The waypoint identifier.
35        identifier: String,
36        /// `lat` or `lon`.
37        field: &'static str,
38        /// The offending text.
39        value: String,
40    },
41    /// A route point referenced an identifier absent from the waypoint
42    /// table; a corridor must never be built from a hole.
43    #[error("route point {identifier:?} is not in the waypoint table")]
44    UnknownRoutePoint {
45        /// The dangling identifier.
46        identifier: String,
47    },
48    /// The document carried no route points.
49    #[error("flight plan has no route")]
50    EmptyRoute,
51}
52
53/// A waypoint-table entry under construction.
54#[derive(Default)]
55struct PartialWaypoint {
56    identifier: Option<String>,
57    kind: Option<String>,
58    lat: Option<String>,
59    lon: Option<String>,
60    country_code: Option<String>,
61}
62
63/// Decode `.fpl` bytes (UTF-16 with BOM, as ForeFlight exports, or UTF-8)
64/// into a [`FlightPlan`].
65pub(super) fn parse(bytes: &[u8]) -> Result<FlightPlan, FplError> {
66    let text = decode(bytes);
67    let mut reader = Reader::from_str(&text);
68    reader.config_mut().trim_text(true);
69
70    let mut table: HashMap<String, PlanWaypoint> = HashMap::new();
71    let mut order: Vec<(String, Option<String>)> = Vec::new();
72
73    let mut plan = FlightPlan::default();
74    let mut path: Vec<String> = Vec::new();
75    let mut current = PartialWaypoint::default();
76    let mut text_buf = String::new();
77
78    loop {
79        match reader.read_event()? {
80            Event::Eof => break,
81            Event::Start(start) => {
82                let name = local_name(start.name().as_ref());
83                if name == "waypoint" && in_path(&path, "waypoint-table") {
84                    current = PartialWaypoint::default();
85                }
86                path.push(name);
87                text_buf.clear();
88            }
89            Event::Text(event) => {
90                text_buf.push_str(&event.xml_content()?);
91            }
92            Event::End(end) => {
93                let name = local_name(end.name().as_ref());
94                let value = text_buf.trim().to_owned();
95                handle_leaf(&path, &value, &mut plan, &mut current, &mut order);
96                if name == "waypoint"
97                    && in_path(&path[..path.len().saturating_sub(1)], "waypoint-table")
98                {
99                    finish_waypoint(std::mem::take(&mut current), &mut table)?;
100                }
101                path.pop();
102                text_buf.clear();
103            }
104            _ => {}
105        }
106    }
107
108    if order.is_empty() {
109        return Err(FplError::EmptyRoute);
110    }
111    plan.route = order
112        .into_iter()
113        .map(|(ident, _)| {
114            table
115                .get(&ident)
116                .cloned()
117                .ok_or(FplError::UnknownRoutePoint { identifier: ident })
118        })
119        .collect::<Result<_, _>>()?;
120    Ok(plan)
121}
122
123/// Apply a finished leaf element's text to the plan or the current
124/// waypoint, by its position in the element tree.
125fn handle_leaf(
126    path: &[String],
127    value: &str,
128    plan: &mut FlightPlan,
129    current: &mut PartialWaypoint,
130    order: &mut Vec<(String, Option<String>)>,
131) {
132    let Some(leaf) = path.last() else {
133        return;
134    };
135    let parent = path.get(path.len().wrapping_sub(2)).map(String::as_str);
136    match (parent, leaf.as_str()) {
137        (_, "created") => plan.created = parse_garmin_time(value),
138        (Some("flight-data"), "etd-zulu") => plan.etd = parse_garmin_time(value),
139        (Some("flight-data"), "altitude-ft") => {
140            plan.cruise_altitude_ft = value.trim().parse().ok();
141        }
142        (Some("aircraft"), "aircraft-tailnumber") => plan.aircraft_tail = some_nonempty(value),
143        (Some("route"), "route-name") => plan.name = some_nonempty(value),
144        (Some("waypoint"), "identifier") => current.identifier = some_nonempty(value),
145        (Some("waypoint"), "type") => current.kind = some_nonempty(value),
146        (Some("waypoint"), "lat") => current.lat = some_nonempty(value),
147        (Some("waypoint"), "lon") => current.lon = some_nonempty(value),
148        (Some("waypoint"), "country-code") => current.country_code = some_nonempty(value),
149        (Some("route-point"), "waypoint-identifier") => {
150            if let Some(ident) = some_nonempty(value) {
151                order.push((ident, None));
152            }
153        }
154        _ => {}
155    }
156}
157
158/// Resolve a finished waypoint-table entry into the table.
159fn finish_waypoint(
160    partial: PartialWaypoint,
161    table: &mut HashMap<String, PlanWaypoint>,
162) -> Result<(), FplError> {
163    let identifier = partial.identifier.clone().unwrap_or_default();
164    let kind_str = partial.kind.ok_or(FplError::IncompleteWaypoint {
165        identifier: identifier.clone(),
166        field: "type",
167    })?;
168    if identifier.is_empty() {
169        return Err(FplError::IncompleteWaypoint {
170            identifier,
171            field: "identifier",
172        });
173    }
174    let kind = WaypointType::parse(&kind_str).unwrap_or(WaypointType::User);
175    let lat = coord(&identifier, "lat", partial.lat)?;
176    let lon = coord(&identifier, "lon", partial.lon)?;
177    table.insert(
178        identifier.clone(),
179        PlanWaypoint {
180            identifier,
181            kind,
182            position: GeoPoint { lat, lon },
183            country_code: partial.country_code,
184        },
185    );
186    Ok(())
187}
188
189fn coord(identifier: &str, field: &'static str, value: Option<String>) -> Result<f64, FplError> {
190    let raw = value.ok_or(FplError::IncompleteWaypoint {
191        identifier: identifier.to_owned(),
192        field,
193    })?;
194    raw.trim().parse().map_err(|_| FplError::BadCoordinate {
195        identifier: identifier.to_owned(),
196        field,
197        value: raw,
198    })
199}
200
201/// Serialize a [`FlightPlan`] to the ForeFlight dialect of Garmin v1.
202pub(super) fn to_xml(plan: &FlightPlan) -> String {
203    let mut out = String::new();
204    out.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
205    out.push_str("<flight-plan xmlns=\"http://www8.garmin.com/xmlschemas/FlightPlan/v1\">\n");
206    if let Some(created) = plan.created {
207        out.push_str(&format!(
208            "  <created>{}</created>\n",
209            format_garmin_time(created)
210        ));
211    }
212    out.push_str("  <aircraft>\n");
213    out.push_str(&format!(
214        "    <aircraft-tailnumber>{}</aircraft-tailnumber>\n",
215        escape(plan.aircraft_tail.as_deref().unwrap_or("NONE"))
216    ));
217    out.push_str("  </aircraft>\n");
218    if plan.etd.is_some() || plan.cruise_altitude_ft.is_some() {
219        out.push_str("  <flight-data>\n");
220        if let Some(etd) = plan.etd {
221            out.push_str(&format!(
222                "    <etd-zulu>{}</etd-zulu>\n",
223                format_garmin_time(etd)
224            ));
225        }
226        if let Some(alt) = plan.cruise_altitude_ft {
227            out.push_str(&format!("    <altitude-ft>{alt}</altitude-ft>\n"));
228        }
229        out.push_str("  </flight-data>\n");
230    }
231    // The waypoint table is the deduplicated dictionary the route refers
232    // to (first occurrence wins, matching its position).
233    out.push_str("  <waypoint-table>\n");
234    let mut seen: Vec<&str> = Vec::new();
235    for w in &plan.route {
236        if seen.contains(&w.identifier.as_str()) {
237            continue;
238        }
239        seen.push(&w.identifier);
240        out.push_str("    <waypoint>\n");
241        out.push_str(&format!(
242            "      <identifier>{}</identifier>\n",
243            escape(&w.identifier)
244        ));
245        out.push_str(&format!("      <type>{}</type>\n", w.kind.as_str()));
246        if let Some(country) = &w.country_code {
247            out.push_str(&format!(
248                "      <country-code>{}</country-code>\n",
249                escape(country)
250            ));
251        }
252        out.push_str(&format!("      <lat>{}</lat>\n", w.position.lat));
253        out.push_str(&format!("      <lon>{}</lon>\n", w.position.lon));
254        out.push_str("    </waypoint>\n");
255    }
256    out.push_str("  </waypoint-table>\n");
257    out.push_str("  <route>\n");
258    if let Some(name) = &plan.name {
259        out.push_str(&format!("    <route-name>{}</route-name>\n", escape(name)));
260    }
261    out.push_str("    <flight-plan-index>1</flight-plan-index>\n");
262    for w in &plan.route {
263        out.push_str("    <route-point>\n");
264        out.push_str(&format!(
265            "      <waypoint-identifier>{}</waypoint-identifier>\n",
266            escape(&w.identifier)
267        ));
268        out.push_str(&format!(
269            "      <waypoint-type>{}</waypoint-type>\n",
270            w.kind.as_str()
271        ));
272        out.push_str("    </route-point>\n");
273    }
274    out.push_str("  </route>\n");
275    out.push_str("</flight-plan>\n");
276    out
277}
278
279/// Decode `.fpl` bytes to a string: UTF-16 by BOM (ForeFlight's export),
280/// else UTF-8 (BOM stripped).
281fn decode(bytes: &[u8]) -> String {
282    match bytes {
283        [0xFF, 0xFE, rest @ ..] => decode_utf16(rest, false),
284        [0xFE, 0xFF, rest @ ..] => decode_utf16(rest, true),
285        [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(),
286        _ => String::from_utf8_lossy(bytes).into_owned(),
287    }
288}
289
290fn decode_utf16(bytes: &[u8], big_endian: bool) -> String {
291    let units = bytes.chunks_exact(2).map(|pair| {
292        if big_endian {
293            u16::from_be_bytes([pair[0], pair[1]])
294        } else {
295            u16::from_le_bytes([pair[0], pair[1]])
296        }
297    });
298    char::decode_utf16(units)
299        .map(|r| r.unwrap_or('\u{FFFD}'))
300        .collect()
301}
302
303/// Local element name without any namespace prefix.
304fn local_name(raw: &[u8]) -> String {
305    let name = String::from_utf8_lossy(raw);
306    name.rsplit(':').next().unwrap_or(&name).to_owned()
307}
308
309fn in_path(path: &[String], ancestor: &str) -> bool {
310    path.iter().any(|p| p == ancestor)
311}
312
313fn some_nonempty(value: &str) -> Option<String> {
314    let trimmed = value.trim();
315    (!trimmed.is_empty()).then(|| trimmed.to_owned())
316}
317
318/// Garmin basic time `20260612T04:31:24Z`, falling back to RFC 3339.
319fn parse_garmin_time(value: &str) -> Option<DateTime<Utc>> {
320    let value = value.trim();
321    if let Ok(naive) = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H:%M:%SZ") {
322        return Some(naive.and_utc());
323    }
324    DateTime::parse_from_rfc3339(value)
325        .ok()
326        .map(|dt| dt.with_timezone(&Utc))
327}
328
329fn format_garmin_time(time: DateTime<Utc>) -> String {
330    time.format("%Y%m%dT%H:%M:%SZ").to_string()
331}
332
333fn escape(value: &str) -> String {
334    value
335        .replace('&', "&amp;")
336        .replace('<', "&lt;")
337        .replace('>', "&gt;")
338}