Skip to main content

peat_protocol/cot/
encoder.rs

1//! Peat → CoT message encoder
2//!
3//! Converts Peat messages to CoT XML format for TAK integration.
4
5use chrono::Duration;
6
7use super::event::{CotError, CotEvent, CotLink, CotPoint};
8use super::peat_extension::{
9    PeatCapability, PeatExtension, PeatHandoff, PeatHierarchy, PeatSource, PeatStatus,
10};
11use super::type_mapper::{Affiliation, CotRelation, CotTypeMapper};
12use super::types::{
13    CapabilityAdvertisement, FormationCapabilitySummary, HandoffMessage, TrackUpdate,
14};
15
16/// Configuration for CoT encoding
17#[derive(Debug, Clone)]
18pub struct CotEncoderConfig {
19    /// Default stale duration for track updates
20    pub track_stale_secs: i64,
21    /// Default stale duration for capability advertisements
22    pub capability_stale_secs: i64,
23    /// Default stale duration for handoff messages
24    pub handoff_stale_secs: i64,
25    /// Default affiliation for Peat entities
26    pub default_affiliation: Affiliation,
27    /// Include Peat extension in output
28    pub include_peat_extension: bool,
29}
30
31impl Default for CotEncoderConfig {
32    fn default() -> Self {
33        Self {
34            track_stale_secs: 30,
35            capability_stale_secs: 60,
36            handoff_stale_secs: 300,
37            default_affiliation: Affiliation::Friendly,
38            include_peat_extension: true,
39        }
40    }
41}
42
43/// Encoder for converting Peat messages to CoT XML
44#[derive(Debug, Clone)]
45pub struct CotEncoder {
46    /// Configuration
47    config: CotEncoderConfig,
48    /// Type mapper
49    type_mapper: CotTypeMapper,
50}
51
52impl Default for CotEncoder {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58impl CotEncoder {
59    /// Create a new encoder with default configuration
60    pub fn new() -> Self {
61        Self {
62            config: CotEncoderConfig::default(),
63            type_mapper: CotTypeMapper::new(),
64        }
65    }
66
67    /// Create encoder with custom configuration
68    pub fn with_config(config: CotEncoderConfig) -> Self {
69        Self {
70            config,
71            type_mapper: CotTypeMapper::new(),
72        }
73    }
74
75    /// Get mutable reference to type mapper for adding custom mappings
76    pub fn type_mapper_mut(&mut self) -> &mut CotTypeMapper {
77        &mut self.type_mapper
78    }
79
80    /// Build a CotEvent from a TrackUpdate
81    pub fn track_update_to_event(&self, track: &TrackUpdate) -> Result<CotEvent, CotError> {
82        let cot_type = self
83            .type_mapper
84            .map(&track.classification, self.config.default_affiliation);
85
86        let mut builder = CotEvent::builder()
87            .uid(&track.track_id)
88            .cot_type(cot_type)
89            .time(track.timestamp)
90            .stale_duration(Duration::seconds(self.config.track_stale_secs))
91            .point(CotPoint::with_full(
92                track.position.lat,
93                track.position.lon,
94                track.position.hae.unwrap_or(0.0),
95                track.position.cep_m.unwrap_or(9999999.0),
96                9999999.0, // LE unknown
97            ))
98            .remarks(&self.format_track_remarks(track));
99
100        // Add track velocity if present
101        if let Some(ref vel) = track.velocity {
102            builder = builder.track(vel.bearing, vel.speed_mps);
103        }
104
105        // Add Peat extension
106        if self.config.include_peat_extension {
107            let mut ext = PeatExtension::new()
108                .with_source(PeatSource::new(
109                    &track.source_platform,
110                    &track.source_model,
111                    &track.model_version,
112                ))
113                .with_confidence(track.confidence, Some(0.70));
114
115            // Add hierarchy if present
116            if track.cell_id.is_some() || track.formation_id.is_some() {
117                let mut hier = PeatHierarchy::new();
118                if let Some(ref cell_id) = track.cell_id {
119                    hier = hier.with_cell(cell_id, Some("tracker"));
120                }
121                if let Some(ref formation_id) = track.formation_id {
122                    hier = hier.with_formation(formation_id);
123                }
124                ext = ext.with_hierarchy(hier);
125            }
126
127            // Add attributes
128            for (key, value) in &track.attributes {
129                let (val_str, type_str) = self.json_value_to_attr(value);
130                ext = ext.with_attribute(key, &val_str, &type_str);
131            }
132
133            builder = builder.peat_extension(ext);
134        }
135
136        // Add link to source platform
137        builder = builder.link(
138            CotLink::new(&track.source_platform, "a-f-G-U-C", CotRelation::Observing)
139                .with_remarks("sensor-platform"),
140        );
141
142        // Add hierarchy links
143        if let Some(ref cell_id) = track.cell_id {
144            builder = builder.link(
145                CotLink::new(cell_id, "a-f-G-U-C", CotRelation::Parent).with_remarks("parent-cell"),
146            );
147        }
148
149        builder.build()
150    }
151
152    /// Encode a TrackUpdate to CoT XML
153    pub fn encode_track_update(&self, track: &TrackUpdate) -> Result<String, CotError> {
154        self.track_update_to_event(track)?.to_xml()
155    }
156
157    /// Build a CotEvent from a CapabilityAdvertisement
158    pub fn capability_to_event(&self, cap: &CapabilityAdvertisement) -> Result<CotEvent, CotError> {
159        let cot_type = self
160            .type_mapper
161            .map_platform(&cap.platform_type, self.config.default_affiliation);
162
163        let mut builder = CotEvent::builder()
164            .uid(&cap.platform_id)
165            .cot_type(cot_type)
166            .time(cap.timestamp)
167            .stale_duration(Duration::seconds(self.config.capability_stale_secs))
168            .point(CotPoint::with_full(
169                cap.position.lat,
170                cap.position.lon,
171                cap.position.hae.unwrap_or(0.0),
172                cap.position.cep_m.unwrap_or(9999999.0),
173                9999999.0,
174            ))
175            .callsign(&cap.platform_id)
176            .remarks(&self.format_capability_remarks(cap));
177
178        // Add group membership if cell assigned
179        if let Some(ref cell_id) = cap.cell_id {
180            builder = builder.group(cell_id, "Team Member");
181        }
182
183        // Add Peat extension
184        if self.config.include_peat_extension {
185            let mut ext =
186                PeatExtension::new().with_status(PeatStatus::new(cap.status, cap.readiness));
187
188            // Add hierarchy
189            if cap.cell_id.is_some() || cap.formation_id.is_some() {
190                let mut hier = PeatHierarchy::new();
191                if let Some(ref cell_id) = cap.cell_id {
192                    hier = hier.with_cell(cell_id, None);
193                }
194                if let Some(ref formation_id) = cap.formation_id {
195                    hier = hier.with_formation(formation_id);
196                }
197                ext = ext.with_hierarchy(hier);
198            }
199
200            // Add capabilities
201            for cap_info in &cap.capabilities {
202                ext = ext.with_capability(PeatCapability::new(
203                    &cap_info.capability_type,
204                    &cap_info.version,
205                    cap_info.precision,
206                    cap_info.status,
207                ));
208            }
209
210            builder = builder.peat_extension(ext);
211        }
212
213        // Add hierarchy links
214        if let Some(ref cell_id) = cap.cell_id {
215            builder = builder.link(
216                CotLink::new(cell_id, "a-f-G-U-C", CotRelation::Parent).with_remarks("parent-cell"),
217            );
218        }
219
220        builder.build()
221    }
222
223    /// Encode a CapabilityAdvertisement to CoT XML
224    pub fn encode_capability_advertisement(
225        &self,
226        cap: &CapabilityAdvertisement,
227    ) -> Result<String, CotError> {
228        self.capability_to_event(cap)?.to_xml()
229    }
230
231    /// Build a CotEvent from a HandoffMessage
232    pub fn handoff_to_event(&self, handoff: &HandoffMessage) -> Result<CotEvent, CotError> {
233        let cot_type = CotTypeMapper::handoff_type();
234
235        let mut builder = CotEvent::builder()
236            .uid(&format!("HANDOFF-{}", handoff.track_id))
237            .cot_type(cot_type)
238            .time(handoff.timestamp)
239            .stale_duration(Duration::seconds(self.config.handoff_stale_secs))
240            .point(CotPoint::with_full(
241                handoff.position.lat,
242                handoff.position.lon,
243                handoff.position.hae.unwrap_or(0.0),
244                handoff.position.cep_m.unwrap_or(9999999.0),
245                9999999.0,
246            ))
247            .remarks(&format!(
248                "Track {} handoff: {} → {} ({})",
249                handoff.track_id, handoff.source_cell, handoff.target_cell, handoff.reason
250            ));
251
252        // Map priority to flow tags
253        let flow_priority = match handoff.priority {
254            1 => "flash",
255            2 => "immediate",
256            3 => "routine",
257            4 => "deferred",
258            _ => "bulk",
259        };
260        builder = builder.flow_priority(flow_priority);
261
262        // Add Peat extension
263        if self.config.include_peat_extension {
264            let ext = PeatExtension::new().with_handoff(PeatHandoff::new(
265                &handoff.source_cell,
266                &handoff.target_cell,
267                handoff.state.as_str(),
268                &handoff.reason,
269            ));
270
271            builder = builder.peat_extension(ext);
272        }
273
274        // Add links to cells
275        builder = builder
276            .link(
277                CotLink::new(&handoff.source_cell, "a-f-G-U-C", CotRelation::Handoff)
278                    .with_remarks("handoff-source"),
279            )
280            .link(
281                CotLink::new(&handoff.target_cell, "a-f-G-U-C", CotRelation::Handoff)
282                    .with_remarks("handoff-target"),
283            )
284            .link(
285                CotLink::new(&handoff.track_id, "a-f-G-E-S", CotRelation::Observing)
286                    .with_remarks("handoff-track"),
287            );
288
289        builder.build()
290    }
291
292    /// Encode a HandoffMessage to CoT XML
293    pub fn encode_handoff(&self, handoff: &HandoffMessage) -> Result<String, CotError> {
294        self.handoff_to_event(handoff)?.to_xml()
295    }
296
297    /// Build a CotEvent from a FormationCapabilitySummary
298    pub fn formation_summary_to_event(
299        &self,
300        summary: &FormationCapabilitySummary,
301    ) -> Result<CotEvent, CotError> {
302        let cot_type = CotTypeMapper::formation_marker_type(self.config.default_affiliation);
303
304        let mut builder = CotEvent::builder()
305            .uid(&summary.formation_id)
306            .cot_type(cot_type)
307            .time(summary.timestamp)
308            .stale_duration(Duration::seconds(self.config.capability_stale_secs))
309            .point(CotPoint::with_full(
310                summary.center_position.lat,
311                summary.center_position.lon,
312                summary.center_position.hae.unwrap_or(0.0),
313                summary.center_position.cep_m.unwrap_or(9999999.0),
314                9999999.0,
315            ))
316            .callsign(&summary.callsign)
317            .remarks(&format!(
318                "Formation {} - {} platforms, {} cells, {:.0}% ready",
319                summary.callsign,
320                summary.platform_count,
321                summary.cell_count,
322                summary.readiness * 100.0
323            ));
324
325        // Add Peat extension with aggregated capabilities
326        if self.config.include_peat_extension {
327            let mut ext = PeatExtension::new()
328                .with_status(PeatStatus::new(
329                    super::types::OperationalStatus::Active,
330                    summary.readiness,
331                ))
332                .with_hierarchy(PeatHierarchy::new().with_formation(&summary.formation_id));
333
334            // Add aggregated capabilities
335            for agg_cap in &summary.capabilities {
336                ext = ext.with_capability(PeatCapability::new(
337                    &agg_cap.capability_type,
338                    &format!("{} units", agg_cap.count),
339                    agg_cap.avg_precision,
340                    if agg_cap.availability > 0.8 {
341                        super::types::OperationalStatus::Active
342                    } else if agg_cap.availability > 0.5 {
343                        super::types::OperationalStatus::Degraded
344                    } else {
345                        super::types::OperationalStatus::Offline
346                    },
347                ));
348            }
349
350            builder = builder.peat_extension(ext);
351        }
352
353        builder.build()
354    }
355
356    /// Encode a FormationCapabilitySummary to CoT XML
357    pub fn encode_formation_summary(
358        &self,
359        summary: &FormationCapabilitySummary,
360    ) -> Result<String, CotError> {
361        self.formation_summary_to_event(summary)?.to_xml()
362    }
363
364    fn format_track_remarks(&self, track: &TrackUpdate) -> String {
365        let mut remarks = format!(
366            "{}: {:.0}% confidence",
367            track.classification,
368            track.confidence * 100.0
369        );
370
371        // Add key attributes
372        for (key, value) in &track.attributes {
373            if let serde_json::Value::String(s) = value {
374                remarks.push_str(&format!(", {}={}", key, s));
375            } else if let serde_json::Value::Bool(b) = value {
376                if *b {
377                    remarks.push_str(&format!(", {}", key));
378                }
379            }
380        }
381
382        remarks
383    }
384
385    fn format_capability_remarks(&self, cap: &CapabilityAdvertisement) -> String {
386        let cap_list: Vec<_> = cap
387            .capabilities
388            .iter()
389            .map(|c| c.capability_type.as_str())
390            .collect();
391
392        format!(
393            "{} ({}) - {} ({:.0}% ready)",
394            cap.platform_type,
395            cap_list.join(", "),
396            cap.status.as_str(),
397            cap.readiness * 100.0
398        )
399    }
400
401    fn json_value_to_attr(&self, value: &serde_json::Value) -> (String, String) {
402        match value {
403            serde_json::Value::String(s) => (s.clone(), "string".to_string()),
404            serde_json::Value::Bool(b) => (b.to_string(), "boolean".to_string()),
405            serde_json::Value::Number(n) => (n.to_string(), "number".to_string()),
406            _ => (value.to_string(), "json".to_string()),
407        }
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use crate::cot::types::{CapabilityInfo, OperationalStatus, Position, Velocity};
415
416    #[test]
417    fn test_encode_track_update() {
418        let encoder = CotEncoder::new();
419
420        let track = TrackUpdate::new(
421            "TRACK-001".to_string(),
422            "person".to_string(),
423            0.89,
424            Position::with_accuracy(33.7749, -84.3958, 2.5),
425            "Alpha-2".to_string(),
426            "object_tracker".to_string(),
427            "1.3.0".to_string(),
428        )
429        .with_velocity(Velocity::new(45.0, 1.2))
430        .with_attribute("jacket_color", serde_json::json!("blue"))
431        .with_cell("Alpha-Team".to_string());
432
433        let xml = encoder.encode_track_update(&track).unwrap();
434
435        assert!(xml.contains("uid=\"TRACK-001\""));
436        assert!(xml.contains("type=\"a-f-G-E-S\""));
437        assert!(xml.contains("lat=\"33.7749\""));
438        assert!(xml.contains("<_peat_"));
439        assert!(xml.contains("platform=\"Alpha-2\""));
440        assert!(xml.contains("jacket_color"));
441    }
442
443    #[test]
444    fn test_encode_capability_advertisement() {
445        let encoder = CotEncoder::new();
446
447        let cap = CapabilityAdvertisement::new(
448            "Alpha-3".to_string(),
449            "UGV".to_string(),
450            Position::new(33.7749, -84.3958),
451            OperationalStatus::Active,
452            0.91,
453        )
454        .with_capability(CapabilityInfo {
455            capability_type: "OBJECT_TRACKING".to_string(),
456            model_name: "object_tracker".to_string(),
457            version: "1.3.0".to_string(),
458            precision: 0.94,
459            status: OperationalStatus::Active,
460        })
461        .with_cell("Alpha-Team".to_string());
462
463        let xml = encoder.encode_capability_advertisement(&cap).unwrap();
464
465        assert!(xml.contains("uid=\"Alpha-3\""));
466        assert!(xml.contains("callsign=\"Alpha-3\""));
467        assert!(xml.contains("__group"));
468        assert!(xml.contains("<capability"));
469    }
470
471    #[test]
472    fn test_encode_handoff() {
473        let encoder = CotEncoder::new();
474
475        let handoff = HandoffMessage::new(
476            "TRACK-001".to_string(),
477            Position::new(33.78, -84.40),
478            "Alpha-Team".to_string(),
479            "Bravo-Team".to_string(),
480            "boundary_crossing".to_string(),
481        )
482        .with_priority(2);
483
484        let xml = encoder.encode_handoff(&handoff).unwrap();
485
486        assert!(xml.contains("uid=\"HANDOFF-TRACK-001\""));
487        assert!(xml.contains("type=\"a-x-h-h\""));
488        assert!(xml.contains("<handoff"));
489        assert!(xml.contains("priority=\"immediate\""));
490    }
491
492    #[test]
493    fn test_encoder_without_peat_extension() {
494        let config = CotEncoderConfig {
495            include_peat_extension: false,
496            ..Default::default()
497        };
498
499        let encoder = CotEncoder::with_config(config);
500
501        let track = TrackUpdate::new(
502            "TRACK-001".to_string(),
503            "person".to_string(),
504            0.89,
505            Position::new(0.0, 0.0),
506            "platform".to_string(),
507            "model".to_string(),
508            "1.0".to_string(),
509        );
510
511        let xml = encoder.encode_track_update(&track).unwrap();
512
513        assert!(!xml.contains("<_peat_"));
514    }
515
516    #[test]
517    fn test_priority_to_flow_tags() {
518        let encoder = CotEncoder::new();
519
520        for (priority, expected_tag) in [
521            (1u8, "flash"),
522            (2, "immediate"),
523            (3, "routine"),
524            (4, "deferred"),
525            (5, "bulk"),
526        ] {
527            let handoff = HandoffMessage::new(
528                "TRACK".to_string(),
529                Position::new(0.0, 0.0),
530                "src".to_string(),
531                "dst".to_string(),
532                "test".to_string(),
533            )
534            .with_priority(priority);
535
536            let xml = encoder.encode_handoff(&handoff).unwrap();
537            assert!(
538                xml.contains(&format!("priority=\"{}\"", expected_tag)),
539                "Priority {} should map to {}",
540                priority,
541                expected_tag
542            );
543        }
544    }
545
546    #[test]
547    fn test_custom_type_mapping() {
548        let mut encoder = CotEncoder::new();
549        encoder
550            .type_mapper_mut()
551            .add_mapping("special_target", "a-h-G-I-T");
552
553        let track = TrackUpdate::new(
554            "TRACK-001".to_string(),
555            "special_target".to_string(),
556            0.95,
557            Position::new(0.0, 0.0),
558            "platform".to_string(),
559            "model".to_string(),
560            "1.0".to_string(),
561        );
562
563        let xml = encoder.encode_track_update(&track).unwrap();
564        assert!(xml.contains("type=\"a-h-G-I-T\""));
565    }
566}