aerocontext_planning/flightplan/
garmin.rs1use 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#[derive(Debug, thiserror::Error)]
15#[non_exhaustive]
16pub enum FplError {
17 #[error("flight plan is not valid XML")]
19 Xml(#[from] quick_xml::Error),
20 #[error("flight plan text could not be decoded")]
22 Encoding(#[from] quick_xml::encoding::EncodingError),
23 #[error("waypoint {identifier:?} is missing its {field}")]
25 IncompleteWaypoint {
26 identifier: String,
28 field: &'static str,
30 },
31 #[error("waypoint {identifier:?} has an unparseable {field} {value:?}")]
33 BadCoordinate {
34 identifier: String,
36 field: &'static str,
38 value: String,
40 },
41 #[error("route point {identifier:?} is not in the waypoint table")]
44 UnknownRoutePoint {
45 identifier: String,
47 },
48 #[error("flight plan has no route")]
50 EmptyRoute,
51}
52
53#[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
63pub(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
123fn 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
158fn 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
201pub(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 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
279fn 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
303fn 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
318fn 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('&', "&")
336 .replace('<', "<")
337 .replace('>', ">")
338}