use std::collections::HashMap;
use aerocontext_core::GeoPoint;
use chrono::{DateTime, NaiveDateTime, Utc};
use quick_xml::Reader;
use quick_xml::events::Event;
use super::{FlightPlan, PlanWaypoint, WaypointType};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum FplError {
#[error("flight plan is not valid XML")]
Xml(#[from] quick_xml::Error),
#[error("flight plan text could not be decoded")]
Encoding(#[from] quick_xml::encoding::EncodingError),
#[error("waypoint {identifier:?} is missing its {field}")]
IncompleteWaypoint {
identifier: String,
field: &'static str,
},
#[error("waypoint {identifier:?} has an unparseable {field} {value:?}")]
BadCoordinate {
identifier: String,
field: &'static str,
value: String,
},
#[error("route point {identifier:?} is not in the waypoint table")]
UnknownRoutePoint {
identifier: String,
},
#[error("flight plan has no route")]
EmptyRoute,
}
#[derive(Default)]
struct PartialWaypoint {
identifier: Option<String>,
kind: Option<String>,
lat: Option<String>,
lon: Option<String>,
country_code: Option<String>,
}
pub(super) fn parse(bytes: &[u8]) -> Result<FlightPlan, FplError> {
let text = decode(bytes);
let mut reader = Reader::from_str(&text);
reader.config_mut().trim_text(true);
let mut table: HashMap<String, PlanWaypoint> = HashMap::new();
let mut order: Vec<(String, Option<String>)> = Vec::new();
let mut plan = FlightPlan::default();
let mut path: Vec<String> = Vec::new();
let mut current = PartialWaypoint::default();
let mut text_buf = String::new();
loop {
match reader.read_event()? {
Event::Eof => break,
Event::Start(start) => {
let name = local_name(start.name().as_ref());
if name == "waypoint" && in_path(&path, "waypoint-table") {
current = PartialWaypoint::default();
}
path.push(name);
text_buf.clear();
}
Event::Text(event) => {
text_buf.push_str(&event.xml_content()?);
}
Event::End(end) => {
let name = local_name(end.name().as_ref());
let value = text_buf.trim().to_owned();
handle_leaf(&path, &value, &mut plan, &mut current, &mut order);
if name == "waypoint"
&& in_path(&path[..path.len().saturating_sub(1)], "waypoint-table")
{
finish_waypoint(std::mem::take(&mut current), &mut table)?;
}
path.pop();
text_buf.clear();
}
_ => {}
}
}
if order.is_empty() {
return Err(FplError::EmptyRoute);
}
plan.route = order
.into_iter()
.map(|(ident, _)| {
table
.get(&ident)
.cloned()
.ok_or(FplError::UnknownRoutePoint { identifier: ident })
})
.collect::<Result<_, _>>()?;
Ok(plan)
}
fn handle_leaf(
path: &[String],
value: &str,
plan: &mut FlightPlan,
current: &mut PartialWaypoint,
order: &mut Vec<(String, Option<String>)>,
) {
let Some(leaf) = path.last() else {
return;
};
let parent = path.get(path.len().wrapping_sub(2)).map(String::as_str);
match (parent, leaf.as_str()) {
(_, "created") => plan.created = parse_garmin_time(value),
(Some("flight-data"), "etd-zulu") => plan.etd = parse_garmin_time(value),
(Some("flight-data"), "altitude-ft") => {
plan.cruise_altitude_ft = value.trim().parse().ok();
}
(Some("aircraft"), "aircraft-tailnumber") => plan.aircraft_tail = some_nonempty(value),
(Some("route"), "route-name") => plan.name = some_nonempty(value),
(Some("waypoint"), "identifier") => current.identifier = some_nonempty(value),
(Some("waypoint"), "type") => current.kind = some_nonempty(value),
(Some("waypoint"), "lat") => current.lat = some_nonempty(value),
(Some("waypoint"), "lon") => current.lon = some_nonempty(value),
(Some("waypoint"), "country-code") => current.country_code = some_nonempty(value),
(Some("route-point"), "waypoint-identifier") => {
if let Some(ident) = some_nonempty(value) {
order.push((ident, None));
}
}
_ => {}
}
}
fn finish_waypoint(
partial: PartialWaypoint,
table: &mut HashMap<String, PlanWaypoint>,
) -> Result<(), FplError> {
let identifier = partial.identifier.clone().unwrap_or_default();
let kind_str = partial.kind.ok_or(FplError::IncompleteWaypoint {
identifier: identifier.clone(),
field: "type",
})?;
if identifier.is_empty() {
return Err(FplError::IncompleteWaypoint {
identifier,
field: "identifier",
});
}
let kind = WaypointType::parse(&kind_str).unwrap_or(WaypointType::User);
let lat = coord(&identifier, "lat", partial.lat)?;
let lon = coord(&identifier, "lon", partial.lon)?;
table.insert(
identifier.clone(),
PlanWaypoint {
identifier,
kind,
position: GeoPoint { lat, lon },
country_code: partial.country_code,
},
);
Ok(())
}
fn coord(identifier: &str, field: &'static str, value: Option<String>) -> Result<f64, FplError> {
let raw = value.ok_or(FplError::IncompleteWaypoint {
identifier: identifier.to_owned(),
field,
})?;
raw.trim().parse().map_err(|_| FplError::BadCoordinate {
identifier: identifier.to_owned(),
field,
value: raw,
})
}
pub(super) fn to_xml(plan: &FlightPlan) -> String {
let mut out = String::new();
out.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
out.push_str("<flight-plan xmlns=\"http://www8.garmin.com/xmlschemas/FlightPlan/v1\">\n");
if let Some(created) = plan.created {
out.push_str(&format!(
" <created>{}</created>\n",
format_garmin_time(created)
));
}
out.push_str(" <aircraft>\n");
out.push_str(&format!(
" <aircraft-tailnumber>{}</aircraft-tailnumber>\n",
escape(plan.aircraft_tail.as_deref().unwrap_or("NONE"))
));
out.push_str(" </aircraft>\n");
if plan.etd.is_some() || plan.cruise_altitude_ft.is_some() {
out.push_str(" <flight-data>\n");
if let Some(etd) = plan.etd {
out.push_str(&format!(
" <etd-zulu>{}</etd-zulu>\n",
format_garmin_time(etd)
));
}
if let Some(alt) = plan.cruise_altitude_ft {
out.push_str(&format!(" <altitude-ft>{alt}</altitude-ft>\n"));
}
out.push_str(" </flight-data>\n");
}
out.push_str(" <waypoint-table>\n");
let mut seen: Vec<&str> = Vec::new();
for w in &plan.route {
if seen.contains(&w.identifier.as_str()) {
continue;
}
seen.push(&w.identifier);
out.push_str(" <waypoint>\n");
out.push_str(&format!(
" <identifier>{}</identifier>\n",
escape(&w.identifier)
));
out.push_str(&format!(" <type>{}</type>\n", w.kind.as_str()));
if let Some(country) = &w.country_code {
out.push_str(&format!(
" <country-code>{}</country-code>\n",
escape(country)
));
}
out.push_str(&format!(" <lat>{}</lat>\n", w.position.lat));
out.push_str(&format!(" <lon>{}</lon>\n", w.position.lon));
out.push_str(" </waypoint>\n");
}
out.push_str(" </waypoint-table>\n");
out.push_str(" <route>\n");
if let Some(name) = &plan.name {
out.push_str(&format!(" <route-name>{}</route-name>\n", escape(name)));
}
out.push_str(" <flight-plan-index>1</flight-plan-index>\n");
for w in &plan.route {
out.push_str(" <route-point>\n");
out.push_str(&format!(
" <waypoint-identifier>{}</waypoint-identifier>\n",
escape(&w.identifier)
));
out.push_str(&format!(
" <waypoint-type>{}</waypoint-type>\n",
w.kind.as_str()
));
out.push_str(" </route-point>\n");
}
out.push_str(" </route>\n");
out.push_str("</flight-plan>\n");
out
}
fn decode(bytes: &[u8]) -> String {
match bytes {
[0xFF, 0xFE, rest @ ..] => decode_utf16(rest, false),
[0xFE, 0xFF, rest @ ..] => decode_utf16(rest, true),
[0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(),
_ => String::from_utf8_lossy(bytes).into_owned(),
}
}
fn decode_utf16(bytes: &[u8], big_endian: bool) -> String {
let units = bytes.chunks_exact(2).map(|pair| {
if big_endian {
u16::from_be_bytes([pair[0], pair[1]])
} else {
u16::from_le_bytes([pair[0], pair[1]])
}
});
char::decode_utf16(units)
.map(|r| r.unwrap_or('\u{FFFD}'))
.collect()
}
fn local_name(raw: &[u8]) -> String {
let name = String::from_utf8_lossy(raw);
name.rsplit(':').next().unwrap_or(&name).to_owned()
}
fn in_path(path: &[String], ancestor: &str) -> bool {
path.iter().any(|p| p == ancestor)
}
fn some_nonempty(value: &str) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
fn parse_garmin_time(value: &str) -> Option<DateTime<Utc>> {
let value = value.trim();
if let Ok(naive) = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H:%M:%SZ") {
return Some(naive.and_utc());
}
DateTime::parse_from_rfc3339(value)
.ok()
.map(|dt| dt.with_timezone(&Utc))
}
fn format_garmin_time(time: DateTime<Utc>) -> String {
time.format("%Y%m%dT%H:%M:%SZ").to_string()
}
fn escape(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}