use super::video::video_detail_xml; use arma_rs::{FromArma, FromArmaError}; use chrono::{Duration, SecondsFormat, Utc}; fn escape_xml(value: &str) -> String { value .replace('&', "&") .replace('"', """) .replace('<', "<") .replace('>', ">") .replace('\'', "'") } fn parse_rtsp_url(url: &str) -> Option<(String, String, String)> { let without_proto = url.strip_prefix("rtsp://")?; let slash_pos = without_proto.find('/')?; let host_port = &without_proto[..slash_pos]; let path = &without_proto[slash_pos..]; let colon_pos = host_port.rfind(':')?; let address = host_port[..colon_pos].to_string(); let port = host_port[colon_pos + 1..].to_string(); Some((address, port, path.to_string())) } pub struct UasPlatformCoTPayload { pub uid: String, pub cot_type: String, pub callsign: String, pub point_lat: f64, pub point_lon: f64, pub point_hae: f32, pub track_course: i32, pub track_speed: f32, pub sensor_azimuth: i32, pub sensor_elevation: i32, pub sensor_fov: i32, pub sensor_vfov: i32, pub sensor_range: i32, pub attitude_yaw: i32, pub attitude_pitch: f32, pub attitude_roll: f32, pub hal: f32, pub vehicle_type_tag: String, pub is_flying: i32, pub link_uid: String, } impl FromArma for UasPlatformCoTPayload { fn from_arma(data: String) -> Result { let ( uid, cot_type, callsign, point_lat, point_lon, point_hae, track_course, track_speed, sensor_azimuth, sensor_elevation, sensor_fov, sensor_vfov, sensor_range, attitude_yaw, attitude_pitch, attitude_roll, hal, vehicle_type_tag, is_flying, link_uid, ) = <( String, String, String, f64, f64, f32, i32, f32, i32, i32, i32, i32, i32, i32, f32, f32, f32, String, i32, String, )>::from_arma(data)?; Ok(Self { uid, cot_type, callsign, point_lat, point_lon, point_hae, track_course, track_speed, sensor_azimuth, sensor_elevation, sensor_fov, sensor_vfov, sensor_range, attitude_yaw, attitude_pitch, attitude_roll, hal, vehicle_type_tag, is_flying, link_uid, }) } } impl UasPlatformCoTPayload { pub fn to_xml(&self) -> String { let uid = escape_xml(&self.uid); let cot_type = escape_xml(&self.cot_type); let callsign = escape_xml(&self.callsign); let link_uid = escape_xml(&self.link_uid); let (vehicle_type_tag, video_url) = match self.vehicle_type_tag.split_once("|armatak_video_url=") { Some((vehicle_type_tag, video_url)) => ( escape_xml(vehicle_type_tag), Some(escape_xml(video_url.trim())).filter(|value| !value.is_empty()), ), None => (escape_xml(&self.vehicle_type_tag), None), }; let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); let stale = (Utc::now() + Duration::milliseconds(3500)) .to_rfc3339_opts(SecondsFormat::Millis, true); let mut xml = String::new(); xml.push_str(""); xml.push_str(&format!( "", cot_type = cot_type, uid = uid, now = now, stale = stale, )); xml.push_str(&format!( "", lat = self.point_lat, lon = self.point_lon, hae = self.point_hae, )); xml.push_str(""); xml.push_str("<_uastool extendedCot=\"true\" activeRoute=\"false\"/>"); xml.push_str(&format!( "", self.track_course, self.track_speed, )); xml.push_str(&format!( "", self.sensor_elevation, self.sensor_vfov, self.sensor_range, self.sensor_azimuth, self.sensor_fov, )); xml.push_str(&format!( "", self.attitude_roll, self.attitude_pitch, self.attitude_yaw, )); xml.push_str(&format!( "", self.hal, vehicle_type_tag, if self.is_flying != 0 { "true" } else { "false" }, )); xml.push_str("<_radio rssi=\"-2147483648\" gps=\"false\"/>"); xml.push_str(&format!("", callsign)); xml.push_str(""); xml.push_str(&format!("<_route sender=\"{}\"/>", link_uid)); xml.push_str(""); if let Some(video_url) = video_url { xml.push_str(&video_detail_xml(&video_url, &self.uid, &self.callsign)); } else { xml.push_str("<__video>"); } xml.push_str(&format!( "", link_uid )); xml.push_str(""); xml } } pub struct UasVideoCoTPayload { pub uid: String, pub callsign: String, pub video_url: String, } impl FromArma for UasVideoCoTPayload { fn from_arma(data: String) -> Result { let (uid, callsign, video_url) = <(String, String, String)>::from_arma(data)?; Ok(Self { uid, callsign, video_url, }) } } impl UasVideoCoTPayload { pub fn to_xml(&self) -> String { let (address, port, path) = match parse_rtsp_url(&self.video_url) { Some(parts) => parts, None => { log::warn!( "UasVideoCoTPayload: could not parse RTSP URL: {}", self.video_url ); return String::new(); } }; let callsign = escape_xml(&self.callsign); let uid = escape_xml(&self.uid); let address = escape_xml(&address); let path = escape_xml(&path); let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); let stale = (Utc::now() + Duration::seconds(3600)).to_rfc3339_opts(SecondsFormat::Millis, true); let mut xml = String::new(); xml.push_str(""); xml.push_str(&format!( "", uid = uid, now = now, stale = stale )); xml.push_str( "", ); xml.push_str(""); xml.push_str("<__video>"); xml.push_str(&format!( "", path = path, address = address, port = port, uid = uid, callsign = callsign, )); xml.push_str(""); xml.push_str(&format!("", callsign)); xml.push_str(""); xml.push_str(""); xml } } pub struct UasSensorCoTPayload { pub uid: String, pub video_uid: String, pub callsign: String, pub point_lat: f64, pub point_lon: f64, pub point_hae: f32, pub azimuth: i32, pub fov: i32, pub range: i32, } impl FromArma for UasSensorCoTPayload { fn from_arma(data: String) -> Result { let (uid, video_uid, callsign, point_lat, point_lon, point_hae, azimuth, fov, range) = <(String, String, String, f64, f64, f32, i32, i32, i32)>::from_arma(data)?; Ok(Self { uid, video_uid, callsign, point_lat, point_lon, point_hae, azimuth, fov, range, }) } } impl UasSensorCoTPayload { pub fn to_xml(&self) -> String { let uid = escape_xml(&self.uid); let video_uid = escape_xml(&self.video_uid); let callsign = escape_xml(&self.callsign); let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); let stale = (Utc::now() + Duration::seconds(60)).to_rfc3339_opts(SecondsFormat::Millis, true); let mut xml = String::new(); xml.push_str(""); xml.push_str(&format!( "", uid = uid, now = now, stale = stale, )); xml.push_str(&format!( "", lat = self.point_lat, lon = self.point_lon, hae = self.point_hae, )); xml.push_str(""); xml.push_str(&format!( "", fov = self.fov, range = self.range, az = self.azimuth, )); xml.push_str(&format!("<__video uid=\"{}\"/>", video_uid)); xml.push_str(&format!("", callsign)); xml.push_str(""); xml.push_str(""); xml } } #[cfg(test)] mod tests { use super::{ escape_xml, parse_rtsp_url, UasPlatformCoTPayload, UasSensorCoTPayload, UasVideoCoTPayload, }; use arma_rs::FromArma; #[test] fn escapes_uas_xml_values_and_parses_rtsp_components() { assert_eq!(escape_xml("A&B\"'"), "A&B"<C>'"); assert_eq!( parse_rtsp_url("rtsp://video.example.test:8554/live/main"), Some(( "video.example.test".to_string(), "8554".to_string(), "/live/main".to_string(), )) ); assert!(parse_rtsp_url("https://video.example.test:8554/live").is_none()); assert!(parse_rtsp_url("rtsp://video.example.test:8554").is_none()); assert!(parse_rtsp_url("rtsp://video.example.test/live").is_none()); } #[test] fn parses_and_serializes_uas_platform_with_embedded_video_url() { let payload = UasPlatformCoTPayload::from_arma( r#"["uas<&","a-f-A-M-F-Q","Falcon<&",-30.1,-51.2,100,180,12.5,90,-15,60,40,1200,181,2.5,1.5,3.2,"Quad<&|armatak_video_url=rtsp://video.example.test:8554/live",1,"operator<&"]"# .to_string(), ) .expect("UAS platform payload should parse"); assert_eq!(payload.uid, "uas<&"); assert_eq!(payload.vehicle_type_tag, "Quad<&|armatak_video_url=rtsp://video.example.test:8554/live"); let xml = payload.to_xml(); assert!(xml.contains("uid=\"uas<&\"")); assert!(xml.contains("typeTag=\"Quad<&\"")); assert!(xml.contains("isFlying=\"true\"")); assert!(xml.contains("")); assert!(xml.contains("")); assert!(xml.contains("<_route sender=\"operator<&\"/>")); assert!(xml.contains("")); } #[test] fn parses_and_serializes_standalone_uas_video_event() { let payload = UasVideoCoTPayload::from_arma( r#"["video<&","Falcon<&","rtsp://video.example.test:8554/live/main"]"# .to_string(), ) .expect("UAS video payload should parse"); let xml = payload.to_xml(); assert!(xml.contains("type=\"b-i-v\"")); assert!(xml.contains("uid=\"video<&\"")); assert!(xml.contains("path=\"/live/main\"")); assert!(xml.contains("address=\"video.example.test\"")); assert!(xml.contains("port=\"8554\"")); assert!(xml.contains("alias=\"Falcon<&\"")); } #[test] fn rejects_invalid_uas_video_url() { let payload = UasVideoCoTPayload { uid: "video-2".to_string(), callsign: "Raven".to_string(), video_url: "https://video.example.test/live".to_string(), }; assert_eq!(payload.to_xml(), ""); } #[test] fn parses_and_serializes_uas_sensor_event() { let payload = UasSensorCoTPayload::from_arma( r#"["sensor<&","video<&","Sensor<&",-30.1,-51.2,25,270,45,850]"# .to_string(), ) .expect("UAS sensor payload should parse"); let xml = payload.to_xml(); assert!(xml.contains("type=\"b-m-p-s-p-loc\"")); assert!(xml.contains("uid=\"sensor<&\"")); assert!(xml.contains("lat=\"-30.1\" lon=\"-51.2\" hae=\"25\"")); assert!(xml.contains("fov=\"45\"")); assert!(xml.contains("range=\"850\"")); assert!(xml.contains("azimuth=\"270\"")); assert!(xml.contains("<__video uid=\"video<&\"/>")); assert!(xml.contains("")); } }