1use std::path::PathBuf;
12
13use serde::{Deserialize, Deserializer, Serialize};
14use serde_json::{Map, Value};
15
16pub const API_VERSION: u32 = 1;
19
20pub const EXIT_CONFIG: i32 = 78;
24
25pub mod topic {
27 pub const CALL_START: &str = "call.start";
29 pub const CALL_END: &str = "call.end";
31 pub const CALL_CONCLUDED: &str = "call.concluded";
33 pub const UNIT: &str = "unit";
35 pub const AUDIO: &str = "audio";
37 pub const STATUS: &str = "status";
39
40 pub const ALL: &[&str] = &[CALL_START, CALL_END, CALL_CONCLUDED, UNIT, AUDIO, STATUS];
41}
42
43pub mod format {
46 pub const M4A: &str = "m4a";
48 pub const WAV: &str = "wav";
51}
52
53#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
55#[serde(default)]
56pub struct Manifest {
57 pub id: String,
59 pub name: String,
61 pub version: String,
63 pub description: String,
64 pub api: u32,
66 pub subscribe: Vec<String>,
68 pub audio_formats: Vec<String>,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub config: Option<Value>,
74 #[serde(skip_serializing_if = "Option::is_none")]
77 pub system_config: Option<Value>,
78 #[serde(skip_serializing_if = "String::is_empty")]
79 pub homepage: String,
80 #[serde(skip_serializing_if = "String::is_empty")]
81 pub repository: String,
82 #[serde(skip_serializing_if = "Vec::is_empty")]
83 pub authors: Vec<String>,
84 #[serde(skip_serializing_if = "String::is_empty")]
85 pub license: String,
86}
87
88impl Manifest {
89 pub fn subscribes(&self, topic: &str) -> bool {
90 self.subscribe.iter().any(|t| t == topic)
91 }
92 pub fn wants_format(&self, format: &str) -> bool {
93 self.audio_formats.iter().any(|f| f == format)
94 }
95}
96
97#[allow(clippy::large_enum_variant)]
101#[derive(Clone, Debug, Serialize, Deserialize)]
102#[serde(tag = "type")]
103pub enum HostMessage {
104 #[serde(rename = "hello")]
106 Hello(Hello),
107 #[serde(rename = "call.start")]
108 CallStart(CallInfo),
109 #[serde(rename = "call.end")]
110 CallEnd(CallInfo),
111 #[serde(rename = "call.concluded")]
112 CallConcluded(ConcludedCall),
113 #[serde(rename = "unit")]
114 Unit(UnitEvent),
115 #[serde(rename = "audio")]
116 Audio(AudioChunk),
117 #[serde(rename = "status")]
118 Status(Status),
119 #[serde(rename = "shutdown")]
123 Shutdown(Shutdown),
124 #[serde(other)]
126 Unknown,
127}
128
129#[derive(Clone, Debug, Default, Serialize, Deserialize)]
130#[serde(default)]
131pub struct Hello {
132 pub api: u32,
134 pub host: HostInfo,
135 pub config: Value,
138 pub systems: Vec<SystemInfo>,
140 pub capture_dir: PathBuf,
142 pub data_dir: PathBuf,
144 pub audio_formats: Vec<String>,
147}
148
149#[derive(Clone, Debug, Default, Serialize, Deserialize)]
150#[serde(default)]
151pub struct HostInfo {
152 pub name: String,
153 pub version: String,
154}
155
156#[derive(Clone, Debug, Default, Serialize, Deserialize)]
157#[serde(default)]
158pub struct SystemInfo {
159 pub index: u16,
164 pub short_name: String,
168 pub kind: String,
170 pub config: Value,
173 #[serde(skip_serializing_if = "Value::is_null")]
177 pub settings: Value,
178}
179
180#[derive(Clone, Debug, Default, Serialize, Deserialize)]
182#[serde(default)]
183pub struct CallInfo {
184 pub id: u32,
186 pub system: u16,
188 pub short_name: String,
189 #[serde(skip_serializing_if = "String::is_empty")]
193 pub site: String,
194 pub talkgroup: u32,
195 pub talkgroup_tag: String,
197 pub freq_hz: u64,
198 pub tdma_slot: Option<u8>,
200 pub analog: bool,
201 pub encrypted: bool,
202 pub algid: Option<u8>,
205 pub key_id: Option<u16>,
206 pub decrypted: bool,
208 pub emergency: bool,
209 pub duplex: bool,
212 pub packet: bool,
213 pub priority: u8,
214 pub recording: bool,
216 pub reason: Option<String>,
218 pub start_time: f64,
220 pub units: Vec<u32>,
222 pub sources: Vec<CallSource>,
225 pub patched_talkgroups: Vec<u32>,
228}
229
230#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
234#[serde(default)]
235pub struct CallSource {
236 pub unit: u32,
237 pub time: f64,
239 pub emergency: bool,
240 pub encrypted: bool,
241 pub duplex: bool,
243 pub packet: bool,
245 pub priority: u8,
247}
248
249#[derive(Clone, Debug, Default, Serialize, Deserialize)]
251#[serde(default)]
252pub struct ConcludedCall {
253 pub path: String,
256 pub system: u16,
258 pub call: CallRecord,
260 pub files: CallFiles,
261}
262
263#[derive(Clone, Debug, Default, Serialize, Deserialize)]
264#[serde(default)]
265pub struct CallFiles {
266 pub json: PathBuf,
267 pub wav: PathBuf,
271 #[serde(skip_serializing_if = "Option::is_none")]
273 pub m4a: Option<PathBuf>,
274}
275
276#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
287#[serde(default)]
288pub struct CallRecord {
289 pub call_num: u64,
290 pub freq: u64,
292 pub freq_error: i64,
294 #[serde(serialize_with = "tenth_or_null")]
296 pub signal: Option<f64>,
297 #[serde(serialize_with = "tenth_or_null")]
298 pub noise: Option<f64>,
299 #[serde(serialize_with = "tenth_or_null")]
300 pub snr: Option<f64>,
301 #[serde(serialize_with = "tenth_or_null")]
303 pub clean_voice_pct: Option<f64>,
304 pub source_num: i64,
305 pub recorder_num: i64,
306 pub tdma_slot: i64,
307 #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
308 pub phase2_tdma: bool,
309 pub start_time: i64,
311 pub stop_time: i64,
312 pub start_time_ms: i64,
314 pub stop_time_ms: i64,
315 #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
316 pub emergency: bool,
317 pub priority: i64,
318 #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
320 pub mode: bool,
321 #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
322 pub duplex: bool,
323 #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
324 pub encrypted: bool,
325 pub algid: i64,
327 pub key_id: i64,
328 #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
330 pub decrypted: bool,
331 #[serde(serialize_with = "plain")]
333 pub call_length: f64,
334 pub call_length_ms: i64,
335 pub talkgroup: u32,
336 pub talkgroup_tag: String,
337 pub talkgroup_description: String,
338 pub talkgroup_group_tag: String,
339 pub talkgroup_group: String,
340 pub color_code: i64,
342 pub ran: i64,
344 pub tone_mode: String,
346 pub tone_detected: String,
347 #[serde(serialize_with = "thousandth")]
348 pub tone_confidence: f64,
349 pub audio_type: String,
351 pub short_name: String,
352 #[serde(skip_serializing_if = "String::is_empty")]
355 pub site: String,
356 #[serde(skip_serializing_if = "Vec::is_empty")]
359 pub patched_talkgroups: Vec<u32>,
360 #[serde(rename = "freqList")]
361 pub freq_list: Vec<FreqEntry>,
362 #[serde(rename = "errorList")]
364 pub error_list: Vec<ErrorInterval>,
365 #[serde(rename = "srcList")]
366 pub src_list: Vec<SrcEntry>,
367 #[serde(flatten)]
368 pub extra: Map<String, Value>,
369}
370
371impl Default for CallRecord {
372 fn default() -> Self {
373 CallRecord {
374 call_num: 0,
375 freq: 0,
376 freq_error: 0,
377 signal: None,
378 noise: None,
379 snr: None,
380 clean_voice_pct: None,
381 source_num: 0,
382 recorder_num: 0,
383 tdma_slot: 0,
384 phase2_tdma: false,
385 start_time: 0,
386 stop_time: 0,
387 start_time_ms: 0,
388 stop_time_ms: 0,
389 emergency: false,
390 priority: 0,
391 mode: false,
392 duplex: false,
393 encrypted: false,
394 algid: -1,
395 key_id: -1,
396 decrypted: false,
397 call_length: 0.0,
398 call_length_ms: 0,
399 talkgroup: 0,
400 talkgroup_tag: String::new(),
401 talkgroup_description: String::new(),
402 talkgroup_group_tag: String::new(),
403 talkgroup_group: String::new(),
404 color_code: -1,
405 ran: -1,
406 tone_mode: "off".into(),
407 tone_detected: String::new(),
408 tone_confidence: 0.0,
409 audio_type: "digital".into(),
410 short_name: String::new(),
411 site: String::new(),
412 patched_talkgroups: Vec::new(),
413 freq_list: Vec::new(),
414 error_list: Vec::new(),
415 src_list: Vec::new(),
416 extra: Map::new(),
417 }
418 }
419}
420
421impl CallRecord {
422 pub fn error_count(&self) -> u64 {
424 self.freq_list.iter().map(|f| f.error_count).sum()
425 }
426 pub fn spike_count(&self) -> u64 {
427 self.freq_list.iter().map(|f| f.spike_count).sum()
428 }
429 pub fn to_json(&self) -> String {
431 serde_json::to_string(self).unwrap_or_default()
432 }
433}
434
435#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
436#[serde(default)]
437pub struct FreqEntry {
438 pub freq: u64,
439 pub time: i64,
441 #[serde(serialize_with = "plain")]
443 pub pos: f64,
444 #[serde(serialize_with = "plain")]
446 pub len: f64,
447 pub error_count: u64,
448 pub spike_count: u64,
449 #[serde(flatten)]
450 pub extra: Map<String, Value>,
451}
452
453#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
455#[serde(default)]
456pub struct ErrorInterval {
457 #[serde(serialize_with = "plain")]
459 pub pos: f64,
460 #[serde(serialize_with = "plain")]
462 pub len: f64,
463 pub frames: u64,
464 pub error_count: u64,
466 pub bad_frames: u64,
468 pub max_frame_errors: u64,
469}
470
471#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
473#[serde(default)]
474pub struct SrcEntry {
475 pub src: i64,
477 pub time: i64,
479 #[serde(serialize_with = "plain")]
481 pub pos: f64,
482 #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
485 pub emergency: bool,
486 #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
487 pub encrypted: bool,
488 #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
489 pub duplex: bool,
490 #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
492 pub mode: bool,
493 pub priority: i64,
495 pub signal_system: String,
496 pub tag: String,
498 pub tag_ota: String,
500 #[serde(skip_serializing_if = "Option::is_none", serialize_with = "millionth")]
502 pub lat: Option<f64>,
503 #[serde(skip_serializing_if = "Option::is_none", serialize_with = "millionth")]
504 pub lon: Option<f64>,
505 #[serde(flatten)]
506 pub extra: Map<String, Value>,
507}
508
509fn number<S: serde::Serializer>(text: String, s: S) -> Result<S::Ok, S::Error> {
514 use serde::ser::Error;
515 serde_json::value::RawValue::from_string(text).map_err(S::Error::custom)?.serialize(s)
516}
517
518fn finite<S: serde::Serializer>(v: f64, text: impl FnOnce(f64) -> String, s: S) -> Result<S::Ok, S::Error> {
519 if v.is_finite() { number(text(v), s) } else { s.serialize_none() }
520}
521
522fn flag_out<S: serde::Serializer>(v: &bool, s: S) -> Result<S::Ok, S::Error> {
523 s.serialize_u8(*v as u8)
524}
525
526fn plain<S: serde::Serializer>(v: &f64, s: S) -> Result<S::Ok, S::Error> {
527 finite(*v, |v| format!("{v}"), s)
528}
529
530fn thousandth<S: serde::Serializer>(v: &f64, s: S) -> Result<S::Ok, S::Error> {
531 finite(*v, |v| format!("{v:.3}"), s)
532}
533
534fn tenth_or_null<S: serde::Serializer>(v: &Option<f64>, s: S) -> Result<S::Ok, S::Error> {
535 match v {
536 Some(v) => finite(*v, |v| format!("{v:.1}"), s),
537 None => s.serialize_none(),
538 }
539}
540
541fn millionth<S: serde::Serializer>(v: &Option<f64>, s: S) -> Result<S::Ok, S::Error> {
542 match v {
543 Some(v) => finite(*v, |v| format!("{v:.6}"), s),
544 None => s.serialize_none(),
545 }
546}
547
548fn flag<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
550 Ok(match Value::deserialize(d)? {
551 Value::Bool(b) => b,
552 Value::Number(n) => n.as_f64().is_some_and(|v| v != 0.0),
553 Value::String(s) => s == "true" || s == "1",
554 _ => false,
555 })
556}
557
558#[derive(Clone, Debug, Default, Serialize, Deserialize)]
560#[serde(default)]
561pub struct UnitEvent {
562 pub system: u16,
564 pub short_name: String,
565 #[serde(skip_serializing_if = "String::is_empty")]
567 pub site: String,
568 pub kind: String,
572 pub unit: u32,
574 pub talkgroup: Option<u32>,
577 pub emergency: bool,
580 pub time: f64,
582}
583
584#[derive(Clone, Debug, Default, Serialize, Deserialize)]
586#[serde(default)]
587pub struct AudioChunk {
588 pub call_id: u32,
589 pub system: u16,
591 pub short_name: String,
592 pub talkgroup: u32,
593 pub sample_rate: u32,
594 pub pcm: String,
595}
596
597impl AudioChunk {
598 pub fn new(call_id: u32, system: u16, talkgroup: u32, sample_rate: u32, samples: &[i16]) -> Self {
599 let bytes: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
600 AudioChunk { call_id, system, talkgroup, sample_rate, pcm: base64::encode(&bytes), ..Default::default() }
601 }
602 pub fn samples(&self) -> Vec<i16> {
604 let b = base64::decode(&self.pcm);
605 b.chunks_exact(2).map(|c| i16::from_le_bytes([c[0], c[1]])).collect()
606 }
607}
608
609#[derive(Clone, Debug, Default, Serialize, Deserialize)]
610#[serde(default)]
611pub struct Status {
612 pub time: f64,
614 pub systems: Vec<SystemStatus>,
615}
616
617#[derive(Clone, Debug, Default, Serialize, Deserialize)]
618#[serde(default)]
619pub struct SystemStatus {
620 pub index: u16,
621 pub short_name: String,
622 pub site_name: String,
625 pub control_channel_hz: Option<u64>,
627 pub decode_rate: f64,
629 pub active_calls: u32,
630 pub recording: u32,
631 #[serde(skip_serializing_if = "Option::is_none")]
635 pub nac: Option<u32>,
636 #[serde(skip_serializing_if = "Option::is_none")]
637 pub wacn: Option<u32>,
638 #[serde(skip_serializing_if = "Option::is_none")]
639 pub sys_id: Option<u32>,
640 #[serde(skip_serializing_if = "Option::is_none")]
641 pub rfss: Option<u32>,
642 #[serde(skip_serializing_if = "Option::is_none")]
643 pub site: Option<u32>,
644}
645
646#[derive(Clone, Debug, Default, Serialize, Deserialize)]
647#[serde(default)]
648pub struct Shutdown {
649 pub grace_s: f64,
651}
652
653#[derive(Clone, Debug, Serialize, Deserialize)]
656#[serde(tag = "type")]
657pub enum PluginMessage {
658 #[serde(rename = "ready")]
660 Ready,
661 #[serde(rename = "log")]
662 Log { level: Level, message: String },
663 #[serde(rename = "status")]
665 Status {
666 state: State,
667 #[serde(default)]
668 message: String,
669 },
670 #[serde(rename = "call.result")]
672 CallResult {
673 path: String,
674 outcome: Outcome,
675 #[serde(default, skip_serializing_if = "String::is_empty")]
676 message: String,
677 #[serde(default, skip_serializing_if = "String::is_empty")]
679 url: String,
680 },
681 #[serde(rename = "metrics")]
685 Metrics(Metrics),
686 #[serde(other)]
687 Unknown,
688}
689
690#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
693#[serde(default, rename_all = "camelCase")]
694pub struct Metrics {
695 #[serde(skip_serializing_if = "Option::is_none")]
697 pub queued: Option<u64>,
698 #[serde(skip_serializing_if = "Option::is_none")]
700 pub retrying: Option<u64>,
701 #[serde(skip_serializing_if = "Option::is_none")]
703 pub in_flight: Option<u64>,
704 #[serde(skip_serializing_if = "Option::is_none")]
706 pub retries: Option<u64>,
707 #[serde(skip_serializing_if = "Option::is_none")]
709 pub bytes_sent: Option<u64>,
710 #[serde(skip_serializing_if = "Option::is_none")]
712 pub latency_ms: Option<f64>,
713 #[serde(skip_serializing_if = "Option::is_none")]
715 pub last_ok: Option<f64>,
716 #[serde(skip_serializing_if = "Option::is_none")]
717 pub last_error: Option<f64>,
718 #[serde(skip_serializing_if = "String::is_empty")]
719 pub last_error_text: String,
720 #[serde(skip_serializing_if = "Vec::is_empty")]
722 pub endpoints: Vec<Endpoint>,
723 #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
725 pub extra: std::collections::BTreeMap<String, serde_json::Value>,
726}
727
728#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
730#[serde(default, rename_all = "camelCase")]
731pub struct Endpoint {
732 pub name: String,
734 pub state: EndpointState,
735 #[serde(skip_serializing_if = "Option::is_none")]
736 pub latency_ms: Option<f64>,
737 #[serde(skip_serializing_if = "Option::is_none")]
738 pub last_ok: Option<f64>,
739 #[serde(skip_serializing_if = "String::is_empty")]
740 pub last_error: String,
741}
742
743#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
744#[serde(rename_all = "lowercase")]
745pub enum EndpointState {
746 #[default]
747 Unknown,
748 Up,
750 Degraded,
752 Down,
754}
755
756#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
757#[serde(rename_all = "lowercase")]
758pub enum Level {
759 Error,
760 Warn,
761 Info,
762 Debug,
763}
764
765#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
766#[serde(rename_all = "lowercase")]
767pub enum State {
768 Ok,
770 Warning,
772 Error,
774}
775
776#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
777#[serde(rename_all = "lowercase")]
778pub enum Outcome {
779 Ok,
781 Skipped,
783 Failed,
785}
786
787pub mod base64 {
789 const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
790
791 pub fn encode(b: &[u8]) -> String {
792 let mut o = String::with_capacity(b.len().div_ceil(3) * 4);
793 for c in b.chunks(3) {
794 let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
795 for i in 0..4 {
796 if i <= c.len() {
797 o.push(A[(n >> (18 - 6 * i) & 63) as usize] as char);
798 } else {
799 o.push('=');
800 }
801 }
802 }
803 o
804 }
805
806 pub fn decode(s: &str) -> Vec<u8> {
808 let mut o = Vec::with_capacity(s.len() / 4 * 3);
809 let (mut acc, mut bits) = (0u32, 0);
810 for ch in s.bytes() {
811 let v = match ch {
812 b'A'..=b'Z' => ch - b'A',
813 b'a'..=b'z' => ch - b'a' + 26,
814 b'0'..=b'9' => ch - b'0' + 52,
815 b'+' => 62,
816 b'/' => 63,
817 _ => continue,
818 };
819 acc = acc << 6 | v as u32;
820 bits += 6;
821 if bits >= 8 {
822 bits -= 8;
823 o.push((acc >> bits) as u8);
824 }
825 }
826 o
827 }
828}
829
830#[cfg(test)]
831mod tests {
832 use super::*;
833
834 #[test]
838 fn a_call_record_round_trips_as_the_recorders_files_are() {
839 let tr = r#"{"freq":851012500,"signal":-31.24,"snr":null,"emergency":true,"encrypted":false,"call_length":4,"talkgroup":101,"short_name":"dcfd",
840 "srcList":[{"src":7,"pos":1.5,"emergency":false,"lat":30.0529}],"freqList":[{"freq":851012500,"len":4.0}],"rec_state_type":"STOPPED"}"#;
841 let r: CallRecord = serde_json::from_str(tr).unwrap();
842 assert!(r.emergency && !r.encrypted && r.signal == Some(-31.24) && r.snr.is_none());
843 let back = r.to_json();
844 assert!(back.contains("\"signal\":-31.2,\"noise\":null,\"snr\":null,"), "{back}");
845 assert!(back.contains("\"emergency\":1,") && back.contains("\"encrypted\":0,"), "{back}");
846 assert!(back.contains("\"call_length\":4,") && back.contains("\"len\":4,"), "{back}");
847 assert!(back.contains("\"pos\":1.5,\"emergency\":0,") && back.contains("\"lat\":30.052900"), "{back}");
848 assert!(back.ends_with("\"rec_state_type\":\"STOPPED\"}"), "{back}");
849 assert_eq!(serde_json::from_str::<CallRecord>(&back).unwrap().to_json(), back);
851 }
852
853 #[test]
854 fn base64_roundtrip() {
855 for n in 0..10 {
856 let b: Vec<u8> = (0..n).map(|i| (i * 37 + 5) as u8).collect();
857 assert_eq!(base64::decode(&base64::encode(&b)), b);
858 }
859 assert_eq!(base64::encode(b"hello"), "aGVsbG8=");
860 }
861
862 #[test]
863 fn unknown_types_and_fields_are_ignored() {
864 let m: HostMessage = serde_json::from_str(r#"{"type":"from.the.future","x":1}"#).unwrap();
865 assert!(matches!(m, HostMessage::Unknown));
866 let m: HostMessage = serde_json::from_str(r#"{"type":"shutdown","grace_s":5,"new_field":true}"#).unwrap();
867 assert!(matches!(m, HostMessage::Shutdown(Shutdown { grace_s }) if grace_s == 5.0));
868 }
869
870 #[test]
871 fn call_record_reads_trunk_recorder_json() {
872 let j = r#"{"call_num":7,"freq":851012500,"start_time":1700000000,"stop_time":1700000005,"emergency":0,"encrypted":1,
873 "call_length":5,"talkgroup":101,"talkgroup_tag":"Fire Disp","audio_type":"digital","short_name":"dcfd","phase2_tdma":0,
874 "freqList":[{"freq":851012500,"time":1700000000,"pos":0,"len":5,"error_count":3,"spike_count":1}],
875 "srcList":[{"src":1234,"time":1700000000,"pos":0.5,"emergency":0,"signal_system":"","tag":"","tag_ota":"E1"}],
876 "color_code":-1,"patched_talkgroups":[101,65001]}"#;
877 let c: CallRecord = serde_json::from_str(j).unwrap();
878 assert!(c.encrypted && !c.emergency);
879 assert_eq!(c.patched_talkgroups, [101, 65001]);
880 assert_eq!((c.error_count(), c.spike_count()), (3, 1));
881 assert_eq!(c.src_list[0].tag_ota, "E1");
882 assert_eq!(c.color_code, -1);
883 let v = serde_json::to_value(&c).unwrap();
885 assert_eq!(v["color_code"], -1);
886 assert_eq!(v["srcList"][0]["src"], 1234);
887 assert_eq!(v["patched_talkgroups"], serde_json::json!([101, 65001]));
888 let unpatched = serde_json::to_value(CallRecord::default()).unwrap();
889 assert!(unpatched.get("patched_talkgroups").is_none());
890 }
891
892 #[test]
893 fn system_identity_is_there_once_stated() {
894 let s: SystemStatus = serde_json::from_str(r#"{"index":0,"short_name":"county","decode_rate":39.8}"#).unwrap();
896 assert_eq!((s.nac, s.wacn, s.sys_id, s.rfss, s.site), (None, None, None, None, None));
897 let v = serde_json::to_value(&s).unwrap();
899 assert!(["nac", "wacn", "sys_id", "rfss", "site"].iter().all(|k| v.get(k).is_none()), "{v}");
900 let s = SystemStatus { nac: Some(0x3A0), wacn: Some(0xBEE00), sys_id: Some(0x3AB), rfss: Some(1), site: Some(5), ..s };
901 let v = serde_json::to_value(&s).unwrap();
902 assert_eq!((v["nac"].as_u64(), v["wacn"].as_u64(), v["site"].as_u64()), (Some(0x3A0), Some(0xBEE00), Some(5)));
903 }
904}