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 pub const TONE_PATTERN: &str = "tone_pattern";
42
43 pub const ALL: &[&str] = &[CALL_START, CALL_END, CALL_CONCLUDED, UNIT, AUDIO, STATUS, TONE_PATTERN];
44}
45
46pub mod format {
49 pub const M4A: &str = "m4a";
51 pub const WAV: &str = "wav";
54}
55
56#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
58#[serde(default)]
59pub struct Manifest {
60 pub id: String,
62 pub name: String,
64 pub version: String,
66 pub description: String,
67 pub api: u32,
69 pub subscribe: Vec<String>,
71 pub audio_formats: Vec<String>,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub config: Option<Value>,
77 #[serde(skip_serializing_if = "Option::is_none")]
80 pub system_config: Option<Value>,
81 #[serde(skip_serializing_if = "String::is_empty")]
82 pub homepage: String,
83 #[serde(skip_serializing_if = "String::is_empty")]
84 pub repository: String,
85 #[serde(skip_serializing_if = "Vec::is_empty")]
86 pub authors: Vec<String>,
87 #[serde(skip_serializing_if = "String::is_empty")]
88 pub license: String,
89 #[serde(skip_serializing_if = "Option::is_none")]
93 pub page: Option<Page>,
94 #[serde(skip_serializing_if = "Vec::is_empty")]
97 pub contribute: Vec<Contribution>,
98 #[serde(skip_serializing_if = "std::ops::Not::not")]
101 pub live_settings: bool,
102}
103
104#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
106#[serde(default)]
107pub struct Page {
108 #[serde(skip_serializing_if = "String::is_empty")]
110 pub title: String,
111}
112
113#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
123#[serde(default)]
124pub struct Contribution {
125 pub on: String,
127 pub label: String,
129 #[serde(skip_serializing_if = "String::is_empty")]
131 pub hint: String,
132 pub append: String,
134 pub item: Value,
136}
137
138impl Manifest {
139 pub fn subscribes(&self, topic: &str) -> bool {
140 self.subscribe.iter().any(|t| t == topic)
141 }
142 pub fn wants_format(&self, format: &str) -> bool {
143 self.audio_formats.iter().any(|f| f == format)
144 }
145}
146
147#[allow(clippy::large_enum_variant)]
151#[derive(Clone, Debug, Serialize, Deserialize)]
152#[serde(tag = "type")]
153pub enum HostMessage {
154 #[serde(rename = "hello")]
156 Hello(Hello),
157 #[serde(rename = "call.start")]
158 CallStart(CallInfo),
159 #[serde(rename = "call.end")]
160 CallEnd(CallInfo),
161 #[serde(rename = "call.concluded")]
162 CallConcluded(ConcludedCall),
163 #[serde(rename = "unit")]
164 Unit(UnitEvent),
165 #[serde(rename = "audio")]
166 Audio(AudioChunk),
167 #[serde(rename = "status")]
168 Status(Status),
169 #[serde(rename = "tone_pattern")]
170 TonePattern(TonePatternEvent),
171 #[serde(rename = "action")]
175 Action(Action),
176 #[serde(rename = "settings")]
179 Settings(Hello),
180 #[serde(rename = "shutdown")]
184 Shutdown(Shutdown),
185 #[serde(other)]
187 Unknown,
188}
189
190#[derive(Clone, Debug, Default, Serialize, Deserialize)]
191#[serde(default)]
192pub struct Hello {
193 pub api: u32,
195 pub host: HostInfo,
196 pub config: Value,
199 pub systems: Vec<SystemInfo>,
201 pub capture_dir: PathBuf,
203 pub data_dir: PathBuf,
205 pub audio_formats: Vec<String>,
208}
209
210#[derive(Clone, Debug, Default, Serialize, Deserialize)]
211#[serde(default)]
212pub struct HostInfo {
213 pub name: String,
214 pub version: String,
215}
216
217#[derive(Clone, Debug, Default, Serialize, Deserialize)]
218#[serde(default)]
219pub struct SystemInfo {
220 pub index: u16,
225 pub short_name: String,
229 pub kind: String,
231 #[serde(skip_serializing_if = "String::is_empty")]
233 pub name: String,
234 #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
238 pub fcc: std::collections::BTreeMap<String, FccLicense>,
239 #[serde(skip_serializing_if = "String::is_empty")]
242 pub fcc_call_sign: String,
243 #[serde(skip_serializing_if = "String::is_empty")]
245 pub fcc_name: String,
246 #[serde(skip_serializing_if = "Vec::is_empty")]
249 pub sites: Vec<SiteInfo>,
250 pub config: Value,
253 #[serde(skip_serializing_if = "Value::is_null")]
257 pub settings: Value,
258}
259
260#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
262#[serde(default)]
263pub struct SiteInfo {
264 pub name: String,
266}
267
268#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
270#[serde(default)]
271pub struct FccLicense {
272 pub call_sign: String,
273 #[serde(skip_serializing_if = "String::is_empty")]
275 pub name: String,
276}
277
278#[derive(Clone, Debug, Default, Serialize, Deserialize)]
280#[serde(default)]
281pub struct CallInfo {
282 pub id: u32,
284 pub system: u16,
286 pub short_name: String,
287 #[serde(skip_serializing_if = "String::is_empty")]
291 pub site: String,
292 pub talkgroup: u32,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub talkgroup_ota: Option<u32>,
299 pub talkgroup_tag: String,
301 pub freq_hz: u64,
302 pub tdma_slot: Option<u8>,
304 pub analog: bool,
305 pub encrypted: bool,
306 pub algid: Option<u8>,
309 pub key_id: Option<u16>,
310 pub decrypted: bool,
312 pub emergency: bool,
313 pub duplex: bool,
316 pub packet: bool,
317 pub priority: u8,
318 pub recording: bool,
320 pub reason: Option<String>,
322 pub start_time: f64,
324 pub units: Vec<u32>,
326 pub sources: Vec<CallSource>,
329 pub patched_talkgroups: Vec<u32>,
332}
333
334#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
338#[serde(default)]
339pub struct CallSource {
340 pub unit: u32,
341 #[serde(skip_serializing_if = "String::is_empty")]
344 pub tag: String,
345 pub time: f64,
347 pub emergency: bool,
348 pub encrypted: bool,
349 pub duplex: bool,
351 pub packet: bool,
353 pub priority: u8,
355}
356
357#[derive(Clone, Debug, Default, Serialize, Deserialize)]
359#[serde(default)]
360pub struct ConcludedCall {
361 pub path: String,
364 pub system: u16,
366 pub call: CallRecord,
368 pub files: CallFiles,
369}
370
371#[derive(Clone, Debug, Default, Serialize, Deserialize)]
372#[serde(default)]
373pub struct CallFiles {
374 pub json: PathBuf,
375 pub wav: PathBuf,
379 #[serde(skip_serializing_if = "Option::is_none")]
381 pub m4a: Option<PathBuf>,
382}
383
384#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
395#[serde(default)]
396pub struct CallRecord {
397 pub call_num: u64,
398 #[serde(skip_serializing_if = "String::is_empty")]
403 pub transmission_id: String,
404 pub freq: u64,
406 pub freq_error: i64,
408 #[serde(serialize_with = "tenth_or_null")]
410 pub signal: Option<f64>,
411 #[serde(serialize_with = "tenth_or_null")]
412 pub noise: Option<f64>,
413 #[serde(serialize_with = "tenth_or_null")]
414 pub snr: Option<f64>,
415 #[serde(serialize_with = "tenth_or_null")]
417 pub clean_voice_pct: Option<f64>,
418 pub source_num: i64,
419 pub recorder_num: i64,
420 pub tdma_slot: i64,
421 #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
422 pub phase2_tdma: bool,
423 pub start_time: i64,
425 pub stop_time: i64,
426 pub start_time_ms: i64,
428 pub stop_time_ms: i64,
429 #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
430 pub emergency: bool,
431 pub priority: i64,
432 #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
434 pub mode: bool,
435 #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
436 pub duplex: bool,
437 #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
438 pub encrypted: bool,
439 pub algid: i64,
441 pub key_id: i64,
442 #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
444 pub decrypted: bool,
445 #[serde(skip_serializing_if = "std::ops::Not::not")]
449 pub clock_off: bool,
450 #[serde(serialize_with = "plain")]
452 pub call_length: f64,
453 pub call_length_ms: i64,
454 pub talkgroup: u32,
455 #[serde(default, skip_serializing_if = "Option::is_none")]
460 pub talkgroup_ota: Option<u32>,
461 pub talkgroup_tag: String,
462 pub talkgroup_description: String,
463 pub talkgroup_group_tag: String,
464 pub talkgroup_group: String,
465 pub nac: String,
469 pub color_code: i64,
471 pub ran: i64,
473 pub tone_mode: String,
475 pub tone_detected: String,
476 #[serde(serialize_with = "thousandth")]
477 pub tone_confidence: f64,
478 pub audio_type: String,
480 #[serde(skip_serializing_if = "String::is_empty")]
483 pub system_type: String,
484 #[serde(skip_serializing_if = "String::is_empty")]
488 pub voice_mode: String,
489 pub short_name: String,
490 #[serde(skip_serializing_if = "String::is_empty")]
493 pub site: String,
494 #[serde(skip_serializing_if = "Vec::is_empty")]
497 pub patched_talkgroups: Vec<u32>,
498 #[serde(rename = "freqList")]
499 pub freq_list: Vec<FreqEntry>,
500 #[serde(rename = "errorList")]
502 pub error_list: Vec<ErrorInterval>,
503 #[serde(rename = "srcList")]
504 pub src_list: Vec<SrcEntry>,
505 #[serde(skip_serializing_if = "Vec::is_empty")]
508 pub tones: Vec<ToneEntry>,
509 #[serde(skip_serializing_if = "Vec::is_empty")]
511 pub tone_patterns: Vec<TonePattern>,
512 #[serde(skip_serializing_if = "Vec::is_empty")]
514 pub signals: Vec<String>,
515 #[serde(flatten)]
516 pub extra: Map<String, Value>,
517}
518
519impl Default for CallRecord {
520 fn default() -> Self {
521 CallRecord {
522 call_num: 0,
523 transmission_id: String::new(),
524 freq: 0,
525 freq_error: 0,
526 signal: None,
527 noise: None,
528 snr: None,
529 clean_voice_pct: None,
530 source_num: 0,
531 recorder_num: 0,
532 tdma_slot: 0,
533 phase2_tdma: false,
534 start_time: 0,
535 stop_time: 0,
536 start_time_ms: 0,
537 stop_time_ms: 0,
538 emergency: false,
539 priority: 0,
540 mode: false,
541 duplex: false,
542 encrypted: false,
543 algid: -1,
544 key_id: -1,
545 decrypted: false,
546 clock_off: false,
547 call_length: 0.0,
548 call_length_ms: 0,
549 talkgroup: 0,
550 talkgroup_ota: None,
551 talkgroup_tag: String::new(),
552 talkgroup_description: String::new(),
553 talkgroup_group_tag: String::new(),
554 talkgroup_group: String::new(),
555 nac: String::new(),
556 color_code: -1,
557 ran: -1,
558 tone_mode: "off".into(),
559 tone_detected: String::new(),
560 tone_confidence: 0.0,
561 audio_type: "digital".into(),
562 system_type: String::new(),
563 voice_mode: String::new(),
564 short_name: String::new(),
565 site: String::new(),
566 patched_talkgroups: Vec::new(),
567 freq_list: Vec::new(),
568 error_list: Vec::new(),
569 src_list: Vec::new(),
570 tones: Vec::new(),
571 tone_patterns: Vec::new(),
572 signals: Vec::new(),
573 extra: Map::new(),
574 }
575 }
576}
577
578impl CallRecord {
579 pub fn error_count(&self) -> u64 {
581 self.freq_list.iter().map(|f| f.error_count).sum()
582 }
583 pub fn spike_count(&self) -> u64 {
584 self.freq_list.iter().map(|f| f.spike_count).sum()
585 }
586 pub fn to_json(&self) -> String {
588 serde_json::to_string(self).unwrap_or_default()
589 }
590}
591
592#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
593#[serde(default)]
594pub struct FreqEntry {
595 pub freq: u64,
596 pub time: i64,
598 #[serde(serialize_with = "plain")]
600 pub pos: f64,
601 #[serde(serialize_with = "plain")]
603 pub len: f64,
604 pub error_count: u64,
605 pub spike_count: u64,
606 #[serde(flatten)]
607 pub extra: Map<String, Value>,
608}
609
610#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
612#[serde(default)]
613pub struct ErrorInterval {
614 #[serde(serialize_with = "plain")]
616 pub pos: f64,
617 #[serde(serialize_with = "plain")]
619 pub len: f64,
620 pub frames: u64,
621 pub error_count: u64,
623 pub bad_frames: u64,
625 pub max_frame_errors: u64,
626}
627
628#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
630#[serde(default)]
631pub struct SrcEntry {
632 pub src: i64,
634 pub time: i64,
636 #[serde(serialize_with = "plain")]
638 pub pos: f64,
639 #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
642 pub emergency: bool,
643 #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
644 pub encrypted: bool,
645 #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
646 pub duplex: bool,
647 #[serde(deserialize_with = "flag", serialize_with = "flag_out")]
649 pub mode: bool,
650 pub priority: i64,
652 pub signal_system: String,
653 pub tag: String,
655 pub tag_ota: String,
657 #[serde(skip_serializing_if = "Option::is_none", serialize_with = "millionth")]
659 pub lat: Option<f64>,
660 #[serde(skip_serializing_if = "Option::is_none", serialize_with = "millionth")]
661 pub lon: Option<f64>,
662 #[serde(flatten)]
663 pub extra: Map<String, Value>,
664}
665
666#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
668#[serde(default)]
669pub struct ToneEntry {
670 #[serde(rename = "type")]
672 pub kind: String,
673 pub value: String,
676 #[serde(serialize_with = "plain")]
678 pub pos: f64,
679 #[serde(serialize_with = "plain")]
681 pub len: f64,
682 pub src: i64,
684 pub origin: String,
688 #[serde(skip_serializing_if = "Option::is_none", serialize_with = "tenth_or_null")]
690 pub level: Option<f64>,
691 #[serde(flatten)]
692 pub extra: Map<String, Value>,
693}
694
695#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
697#[serde(default)]
698pub struct TonePattern {
699 pub pattern: String,
704 pub tones: Vec<f64>,
707 pub lengths: Vec<f64>,
710 #[serde(skip_serializing_if = "String::is_empty")]
713 pub digits: String,
714 #[serde(skip_serializing_if = "std::ops::Not::not")]
715 pub knox: bool,
716 #[serde(skip_serializing_if = "is_zero")]
718 pub cycles: u32,
719 #[serde(skip_serializing_if = "String::is_empty")]
721 pub format: String,
722 #[serde(serialize_with = "plain")]
724 pub pos: f64,
725 #[serde(serialize_with = "plain")]
727 pub len: f64,
728 pub src: i64,
730 pub origin: String,
732 pub text: String,
734 #[serde(skip_serializing_if = "Vec::is_empty")]
736 pub signals: Vec<String>,
737 #[serde(flatten)]
738 pub extra: Map<String, Value>,
739}
740
741fn is_zero(v: &u32) -> bool {
742 *v == 0
743}
744
745fn number<S: serde::Serializer>(text: String, s: S) -> Result<S::Ok, S::Error> {
750 use serde::ser::Error;
751 serde_json::value::RawValue::from_string(text).map_err(S::Error::custom)?.serialize(s)
752}
753
754fn finite<S: serde::Serializer>(v: f64, text: impl FnOnce(f64) -> String, s: S) -> Result<S::Ok, S::Error> {
755 if v.is_finite() { number(text(v), s) } else { s.serialize_none() }
756}
757
758fn flag_out<S: serde::Serializer>(v: &bool, s: S) -> Result<S::Ok, S::Error> {
759 s.serialize_u8(*v as u8)
760}
761
762fn plain<S: serde::Serializer>(v: &f64, s: S) -> Result<S::Ok, S::Error> {
763 finite(*v, |v| format!("{v}"), s)
764}
765
766fn thousandth<S: serde::Serializer>(v: &f64, s: S) -> Result<S::Ok, S::Error> {
767 finite(*v, |v| format!("{v:.3}"), s)
768}
769
770fn tenth_or_null<S: serde::Serializer>(v: &Option<f64>, s: S) -> Result<S::Ok, S::Error> {
771 match v {
772 Some(v) => finite(*v, |v| format!("{v:.1}"), s),
773 None => s.serialize_none(),
774 }
775}
776
777fn millionth<S: serde::Serializer>(v: &Option<f64>, s: S) -> Result<S::Ok, S::Error> {
778 match v {
779 Some(v) => finite(*v, |v| format!("{v:.6}"), s),
780 None => s.serialize_none(),
781 }
782}
783
784fn flag<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
786 Ok(match Value::deserialize(d)? {
787 Value::Bool(b) => b,
788 Value::Number(n) => n.as_f64().is_some_and(|v| v != 0.0),
789 Value::String(s) => s == "true" || s == "1",
790 _ => false,
791 })
792}
793
794#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
800#[serde(default)]
801pub struct TonePatternEvent {
802 pub short_name: String,
803 #[serde(skip_serializing_if = "String::is_empty")]
804 pub site: String,
805 pub call_num: u64,
807 pub talkgroup: u32,
808 pub alpha_tag: String,
809 #[serde(skip_serializing_if = "String::is_empty")]
811 pub src_tag: String,
812 #[serde(skip_serializing_if = "std::ops::Not::not")]
815 pub decrypted: bool,
816 pub time: f64,
818 #[serde(flatten)]
819 pub pattern: TonePattern,
820}
821
822#[derive(Clone, Debug, Default, Serialize, Deserialize)]
824#[serde(default)]
825pub struct UnitEvent {
826 pub system: u16,
828 pub short_name: String,
829 #[serde(skip_serializing_if = "String::is_empty")]
831 pub site: String,
832 pub kind: String,
836 pub unit: u32,
838 #[serde(skip_serializing_if = "String::is_empty")]
840 pub tag: String,
841 pub talkgroup: Option<u32>,
844 #[serde(skip_serializing_if = "String::is_empty")]
846 pub alpha_tag: String,
847 pub emergency: bool,
850 pub time: f64,
852}
853
854#[derive(Clone, Debug, Default, Serialize, Deserialize)]
856#[serde(default)]
857pub struct AudioChunk {
858 pub call_id: u32,
859 pub system: u16,
861 pub short_name: String,
862 pub talkgroup: u32,
863 pub sample_rate: u32,
864 pub pcm: String,
865}
866
867impl AudioChunk {
868 pub fn new(call_id: u32, system: u16, talkgroup: u32, sample_rate: u32, samples: &[i16]) -> Self {
869 let bytes: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
870 AudioChunk { call_id, system, talkgroup, sample_rate, pcm: base64::encode(&bytes), ..Default::default() }
871 }
872 pub fn samples(&self) -> Vec<i16> {
874 let b = base64::decode(&self.pcm);
875 b.chunks_exact(2).map(|c| i16::from_le_bytes([c[0], c[1]])).collect()
876 }
877}
878
879#[derive(Clone, Debug, Default, Serialize, Deserialize)]
880#[serde(default)]
881pub struct Status {
882 pub time: f64,
884 pub systems: Vec<SystemStatus>,
885}
886
887#[derive(Clone, Debug, Default, Serialize, Deserialize)]
888#[serde(default)]
889pub struct SystemStatus {
890 pub index: u16,
891 pub short_name: String,
892 pub site_name: String,
895 pub control_channel_hz: Option<u64>,
897 pub decode_rate: f64,
899 pub active_calls: u32,
900 pub recording: u32,
901 #[serde(skip_serializing_if = "Option::is_none")]
905 pub nac: Option<u32>,
906 #[serde(skip_serializing_if = "Option::is_none")]
907 pub wacn: Option<u32>,
908 #[serde(skip_serializing_if = "Option::is_none")]
909 pub sys_id: Option<u32>,
910 #[serde(skip_serializing_if = "Option::is_none")]
911 pub rfss: Option<u32>,
912 #[serde(skip_serializing_if = "Option::is_none")]
913 pub site: Option<u32>,
914}
915
916#[derive(Clone, Debug, Default, Serialize, Deserialize)]
917#[serde(default)]
918pub struct Shutdown {
919 pub grace_s: f64,
921}
922
923#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
925#[serde(default)]
926pub struct Action {
927 pub request: u64,
929 pub action: String,
931 pub value: Value,
934}
935
936#[derive(Clone, Debug, Serialize, Deserialize)]
939#[serde(tag = "type")]
940pub enum PluginMessage {
941 #[serde(rename = "ready")]
943 Ready,
944 #[serde(rename = "log")]
945 Log { level: Level, message: String },
946 #[serde(rename = "status")]
948 Status {
949 state: State,
950 #[serde(default)]
951 message: String,
952 },
953 #[serde(rename = "call.result")]
955 CallResult {
956 path: String,
957 outcome: Outcome,
958 #[serde(default, skip_serializing_if = "String::is_empty")]
959 message: String,
960 #[serde(default, skip_serializing_if = "String::is_empty")]
962 url: String,
963 #[serde(default, skip_serializing_if = "Option::is_none")]
968 took_ms: Option<u64>,
969 },
970 #[serde(rename = "metrics")]
974 Metrics(Metrics),
975 #[serde(rename = "action.result")]
977 ActionResult {
978 request: u64,
979 ok: bool,
980 #[serde(default, skip_serializing_if = "String::is_empty")]
981 message: String,
982 },
983 #[serde(other)]
984 Unknown,
985}
986
987#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
990#[serde(default, rename_all = "camelCase")]
991pub struct Metrics {
992 #[serde(skip_serializing_if = "Option::is_none")]
994 pub queued: Option<u64>,
995 #[serde(skip_serializing_if = "Option::is_none")]
997 pub retrying: Option<u64>,
998 #[serde(skip_serializing_if = "Option::is_none")]
1000 pub in_flight: Option<u64>,
1001 #[serde(skip_serializing_if = "Option::is_none")]
1003 pub retries: Option<u64>,
1004 #[serde(skip_serializing_if = "Option::is_none")]
1006 pub bytes_sent: Option<u64>,
1007 #[serde(skip_serializing_if = "Option::is_none")]
1009 pub latency_ms: Option<f64>,
1010 #[serde(skip_serializing_if = "Option::is_none")]
1012 pub last_ok: Option<f64>,
1013 #[serde(skip_serializing_if = "Option::is_none")]
1014 pub last_error: Option<f64>,
1015 #[serde(skip_serializing_if = "String::is_empty")]
1016 pub last_error_text: String,
1017 #[serde(skip_serializing_if = "Vec::is_empty")]
1019 pub endpoints: Vec<Endpoint>,
1020 #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
1022 pub extra: std::collections::BTreeMap<String, serde_json::Value>,
1023}
1024
1025#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
1027#[serde(default, rename_all = "camelCase")]
1028pub struct Endpoint {
1029 pub name: String,
1031 pub state: EndpointState,
1032 #[serde(skip_serializing_if = "Option::is_none")]
1033 pub latency_ms: Option<f64>,
1034 #[serde(skip_serializing_if = "Option::is_none")]
1035 pub last_ok: Option<f64>,
1036 #[serde(skip_serializing_if = "String::is_empty")]
1037 pub last_error: String,
1038}
1039
1040#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
1041#[serde(rename_all = "lowercase")]
1042pub enum EndpointState {
1043 #[default]
1044 Unknown,
1045 Up,
1047 Degraded,
1049 Down,
1051}
1052
1053#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
1054#[serde(rename_all = "lowercase")]
1055pub enum Level {
1056 Error,
1057 Warn,
1058 Info,
1059 Debug,
1060}
1061
1062#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1063#[serde(rename_all = "lowercase")]
1064pub enum State {
1065 Ok,
1067 Warning,
1069 Error,
1071}
1072
1073#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
1074#[serde(rename_all = "lowercase")]
1075pub enum Outcome {
1076 Ok,
1078 Skipped,
1080 Failed,
1082}
1083
1084pub mod base64 {
1086 const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1087
1088 pub fn encode(b: &[u8]) -> String {
1089 let mut o = String::with_capacity(b.len().div_ceil(3) * 4);
1090 for c in b.chunks(3) {
1091 let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
1092 for i in 0..4 {
1093 if i <= c.len() {
1094 o.push(A[(n >> (18 - 6 * i) & 63) as usize] as char);
1095 } else {
1096 o.push('=');
1097 }
1098 }
1099 }
1100 o
1101 }
1102
1103 pub fn decode(s: &str) -> Vec<u8> {
1105 let mut o = Vec::with_capacity(s.len() / 4 * 3);
1106 let (mut acc, mut bits) = (0u32, 0);
1107 for ch in s.bytes() {
1108 let v = match ch {
1109 b'A'..=b'Z' => ch - b'A',
1110 b'a'..=b'z' => ch - b'a' + 26,
1111 b'0'..=b'9' => ch - b'0' + 52,
1112 b'+' => 62,
1113 b'/' => 63,
1114 _ => continue,
1115 };
1116 acc = acc << 6 | v as u32;
1117 bits += 6;
1118 if bits >= 8 {
1119 bits -= 8;
1120 o.push((acc >> bits) as u8);
1121 }
1122 }
1123 o
1124 }
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129 use super::*;
1130
1131 #[test]
1135 fn a_call_record_round_trips_as_the_recorders_files_are() {
1136 let tr = r#"{"freq":851012500,"signal":-31.24,"snr":null,"emergency":true,"encrypted":false,"call_length":4,"talkgroup":101,"short_name":"dcfd",
1137 "srcList":[{"src":7,"pos":1.5,"emergency":false,"lat":30.0529}],"freqList":[{"freq":851012500,"len":4.0}],"rec_state_type":"STOPPED"}"#;
1138 let r: CallRecord = serde_json::from_str(tr).unwrap();
1139 assert!(r.emergency && !r.encrypted && r.signal == Some(-31.24) && r.snr.is_none());
1140 let back = r.to_json();
1141 assert!(back.contains("\"signal\":-31.2,\"noise\":null,\"snr\":null,"), "{back}");
1142 assert!(back.contains("\"emergency\":1,") && back.contains("\"encrypted\":0,"), "{back}");
1143 assert!(back.contains("\"call_length\":4,") && back.contains("\"len\":4,"), "{back}");
1144 assert!(back.contains("\"pos\":1.5,\"emergency\":0,") && back.contains("\"lat\":30.052900"), "{back}");
1145 assert!(back.ends_with("\"rec_state_type\":\"STOPPED\"}"), "{back}");
1146 assert_eq!(serde_json::from_str::<CallRecord>(&back).unwrap().to_json(), back);
1148 }
1149
1150 #[test]
1151 fn base64_roundtrip() {
1152 for n in 0..10 {
1153 let b: Vec<u8> = (0..n).map(|i| (i * 37 + 5) as u8).collect();
1154 assert_eq!(base64::decode(&base64::encode(&b)), b);
1155 }
1156 assert_eq!(base64::encode(b"hello"), "aGVsbG8=");
1157 }
1158
1159 #[test]
1160 fn unknown_types_and_fields_are_ignored() {
1161 let m: HostMessage = serde_json::from_str(r#"{"type":"from.the.future","x":1}"#).unwrap();
1162 assert!(matches!(m, HostMessage::Unknown));
1163 let m: HostMessage = serde_json::from_str(r#"{"type":"shutdown","grace_s":5,"new_field":true}"#).unwrap();
1164 assert!(matches!(m, HostMessage::Shutdown(Shutdown { grace_s }) if grace_s == 5.0));
1165 }
1166
1167 #[test]
1168 fn call_record_reads_trunk_recorder_json() {
1169 let j = r#"{"call_num":7,"freq":851012500,"start_time":1700000000,"stop_time":1700000005,"emergency":0,"encrypted":1,
1170 "call_length":5,"talkgroup":101,"talkgroup_tag":"Fire Disp","audio_type":"digital","short_name":"dcfd","phase2_tdma":0,
1171 "freqList":[{"freq":851012500,"time":1700000000,"pos":0,"len":5,"error_count":3,"spike_count":1}],
1172 "srcList":[{"src":1234,"time":1700000000,"pos":0.5,"emergency":0,"signal_system":"","tag":"","tag_ota":"E1"}],
1173 "color_code":-1,"patched_talkgroups":[101,65001]}"#;
1174 let c: CallRecord = serde_json::from_str(j).unwrap();
1175 assert!(c.encrypted && !c.emergency);
1176 assert_eq!(c.patched_talkgroups, [101, 65001]);
1177 assert_eq!((c.error_count(), c.spike_count()), (3, 1));
1178 assert_eq!(c.src_list[0].tag_ota, "E1");
1179 assert_eq!(c.color_code, -1);
1180 let v = serde_json::to_value(&c).unwrap();
1182 assert_eq!(v["color_code"], -1);
1183 assert_eq!(v["srcList"][0]["src"], 1234);
1184 assert_eq!(v["patched_talkgroups"], serde_json::json!([101, 65001]));
1185 let unpatched = serde_json::to_value(CallRecord::default()).unwrap();
1186 assert!(unpatched.get("patched_talkgroups").is_none());
1187 }
1188
1189 #[test]
1190 fn system_identity_is_there_once_stated() {
1191 let s: SystemStatus = serde_json::from_str(r#"{"index":0,"short_name":"county","decode_rate":39.8}"#).unwrap();
1193 assert_eq!((s.nac, s.wacn, s.sys_id, s.rfss, s.site), (None, None, None, None, None));
1194 let v = serde_json::to_value(&s).unwrap();
1196 assert!(["nac", "wacn", "sys_id", "rfss", "site"].iter().all(|k| v.get(k).is_none()), "{v}");
1197 let s = SystemStatus { nac: Some(0x3A0), wacn: Some(0xBEE00), sys_id: Some(0x3AB), rfss: Some(1), site: Some(5), ..s };
1198 let v = serde_json::to_value(&s).unwrap();
1199 assert_eq!((v["nac"].as_u64(), v["wacn"].as_u64(), v["site"].as_u64()), (Some(0x3A0), Some(0xBEE00), Some(5)));
1200 }
1201}